Skip to content

Commit d8658d6

Browse files
Pigbibicursoragent
andauthored
Gate LongBridge DCA; keep rotation whole-share (#226)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4c1c9f6 commit d8658d6

12 files changed

Lines changed: 340 additions & 35 deletions

application/execution_service.py

Lines changed: 85 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,9 @@ class ExecutionCycleResult:
261261

262262
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
263263
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
264+
MIN_FRACTIONAL_BUY_NOTIONAL_USD = 1.0
265+
DEFAULT_BUY_QUANTITY_STEP = 1.0
266+
FRACTIONAL_BUY_QUANTITY_STEP = 0.0001
264267
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"})
265268
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
266269
"SOXX": 0.90,
@@ -578,6 +581,13 @@ def _normalize_trade_quantity(quantity):
578581
return _floor_whole_share_quantity(raw_quantity)
579582

580583

584+
def _normalize_buy_quantity(quantity, *, quantity_step: float):
585+
raw_quantity = max(0.0, float(quantity or 0.0))
586+
if raw_quantity <= 0.0:
587+
return 0
588+
return normalize_order_quantity(floor_to_quantity_step(raw_quantity, quantity_step))
589+
590+
581591
def _market_symbol(symbol, *, symbol_suffix=".US"):
582592
normalized = str(symbol or "").strip().upper()
583593
if not normalized:
@@ -773,13 +783,15 @@ def estimate_cash_buy_quantity_safe(
773783
*,
774784
estimate_max_purchase_quantity,
775785
notify_issue,
786+
estimate_kwargs=None,
776787
):
777788
try:
778789
return estimate_max_purchase_quantity(
779790
trade_context,
780791
symbol,
781792
order_kind=order_kind,
782793
ref_price=ref_price,
794+
**dict(estimate_kwargs or {}),
783795
)
784796
except Exception:
785797
notify_issue(
@@ -799,20 +811,24 @@ def _estimate_buy_quantity_candidate(
799811
estimate_max_purchase_quantity,
800812
notify_issue,
801813
dry_run_only=False,
814+
quantity_step=DEFAULT_BUY_QUANTITY_STEP,
815+
estimate_kwargs=None,
802816
):
803-
budget_quantity = floor_to_quantity_step(can_buy_value / ref_price, 1.0)
817+
budget_quantity = floor_to_quantity_step(can_buy_value / ref_price, quantity_step)
804818
cash_limit_quantity = estimate_cash_buy_quantity_safe(
805819
trade_context,
806820
symbol,
807821
order_kind,
808822
ref_price,
809823
estimate_max_purchase_quantity=estimate_max_purchase_quantity,
810824
notify_issue=notify_issue,
825+
estimate_kwargs=estimate_kwargs,
811826
)
812827
if cash_limit_quantity is None:
813828
return None
814-
candidate_quantity = _normalize_trade_quantity(
829+
candidate_quantity = _normalize_buy_quantity(
815830
min(budget_quantity, float(cash_limit_quantity)),
831+
quantity_step=quantity_step,
816832
)
817833
return candidate_quantity, budget_quantity, float(cash_limit_quantity)
818834

@@ -843,6 +859,8 @@ def execute_rebalance_cycle(
843859
min_order_notional_usd=0.0,
844860
safe_haven_cash_substitute_threshold_usd=DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
845861
cash_only_execution=True,
862+
fractional_buy_execution: bool = False,
863+
buy_quantity_step: float = DEFAULT_BUY_QUANTITY_STEP,
846864
) -> ExecutionCycleResult:
847865
logs: list[str] = []
848866
skip_logs: list[str] = []
@@ -887,16 +905,21 @@ def record_quote_snapshot(snapshot) -> None:
887905
portfolio=portfolio,
888906
)
889907
cash_sweep_symbol = str(portfolio.get("cash_sweep_symbol") or "").strip().upper()
890-
plan, allocation = _apply_small_account_whole_share_compatibility(
891-
plan=plan,
892-
allocation=allocation,
893-
strategy_assets=strategy_assets,
894-
market_data_port=market_data_port,
895-
notify_issue=notify_issue,
896-
symbol_suffix=symbol_suffix,
897-
limit_buy_premium=limit_buy_premium,
898-
limit_buy_premium_by_symbol=limit_buy_premium_by_symbol,
908+
estimate_kwargs = {"fractional_shares": True} if fractional_buy_execution else {}
909+
effective_buy_quantity_step = (
910+
float(buy_quantity_step) if fractional_buy_execution else DEFAULT_BUY_QUANTITY_STEP
899911
)
912+
if not fractional_buy_execution:
913+
plan, allocation = _apply_small_account_whole_share_compatibility(
914+
plan=plan,
915+
allocation=allocation,
916+
strategy_assets=strategy_assets,
917+
market_data_port=market_data_port,
918+
notify_issue=notify_issue,
919+
symbol_suffix=symbol_suffix,
920+
limit_buy_premium=limit_buy_premium,
921+
limit_buy_premium_by_symbol=limit_buy_premium_by_symbol,
922+
)
900923
record_small_account_cash_substitution_notes(
901924
note_logs,
902925
allocation=allocation,
@@ -918,7 +941,10 @@ def record_quote_snapshot(snapshot) -> None:
918941
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
919942
investable_cash = float(execution["investable_cash"])
920943
min_order_notional = max(0.0, float(min_order_notional_usd or 0.0))
921-
current_min_trade = max(float(execution["current_min_trade"]), min_order_notional)
944+
if fractional_buy_execution:
945+
current_min_trade = max(float(execution["current_min_trade"]), MIN_FRACTIONAL_BUY_NOTIONAL_USD)
946+
else:
947+
current_min_trade = max(float(execution["current_min_trade"]), min_order_notional)
922948
dry_run_sale_proceeds = 0.0
923949
cash_sweep_sold_this_cycle = False
924950

@@ -932,10 +958,17 @@ def append_order_id_suffix(log_message, order_id):
932958
return f"{log_message} {suffix}"
933959

934960
def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, submitted_price=None):
961+
if fractional_buy_execution and side == "buy":
962+
normalized_quantity = _normalize_buy_quantity(
963+
quantity,
964+
quantity_step=effective_buy_quantity_step,
965+
)
966+
else:
967+
normalized_quantity = _floor_whole_share_quantity(quantity)
935968
order_intent = OrderIntent(
936969
symbol=symbol,
937970
side=side,
938-
quantity=_floor_whole_share_quantity(quantity),
971+
quantity=normalized_quantity,
939972
order_type=order_type,
940973
limit_price=float(submitted_price) if submitted_price is not None else None,
941974
)
@@ -1240,16 +1273,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
12401273
allocation,
12411274
portfolio=portfolio,
12421275
)
1243-
plan, allocation = _apply_small_account_whole_share_compatibility(
1244-
plan=plan,
1245-
allocation=allocation,
1246-
strategy_assets=tuple(allocation["strategy_symbols"]),
1247-
market_data_port=market_data_port,
1248-
notify_issue=notify_issue,
1249-
symbol_suffix=symbol_suffix,
1250-
limit_buy_premium=limit_buy_premium,
1251-
limit_buy_premium_by_symbol=limit_buy_premium_by_symbol,
1252-
)
1276+
if not fractional_buy_execution:
1277+
plan, allocation = _apply_small_account_whole_share_compatibility(
1278+
plan=plan,
1279+
allocation=allocation,
1280+
strategy_assets=tuple(allocation["strategy_symbols"]),
1281+
market_data_port=market_data_port,
1282+
notify_issue=notify_issue,
1283+
symbol_suffix=symbol_suffix,
1284+
limit_buy_premium=limit_buy_premium,
1285+
limit_buy_premium_by_symbol=limit_buy_premium_by_symbol,
1286+
)
12531287
record_small_account_cash_substitution_notes(
12541288
note_logs,
12551289
allocation=allocation,
@@ -1278,7 +1312,10 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
12781312
available_cash = float(portfolio["liquid_cash"])
12791313
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
12801314
investable_cash = float(execution["investable_cash"])
1281-
current_min_trade = max(float(execution["current_min_trade"]), min_order_notional)
1315+
if fractional_buy_execution:
1316+
current_min_trade = max(float(execution["current_min_trade"]), MIN_FRACTIONAL_BUY_NOTIONAL_USD)
1317+
else:
1318+
current_min_trade = max(float(execution["current_min_trade"]), min_order_notional)
12821319

12831320
if (
12841321
available_cash <= 0.0
@@ -1313,7 +1350,13 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13131350
notify_issue=notify_issue,
13141351
quote_recorder=record_quote_snapshot,
13151352
)
1316-
if price is None or can_buy_value <= price:
1353+
if price is None:
1354+
continue
1355+
if fractional_buy_execution:
1356+
if can_buy_value >= MIN_FRACTIONAL_BUY_NOTIONAL_USD:
1357+
estimated_buy_cost += can_buy_value
1358+
continue
1359+
if can_buy_value <= price:
13171360
continue
13181361
limit_price = _limit_buy_price(
13191362
symbol, price, limit_buy_premium, limit_buy_premium_by_symbol
@@ -1353,8 +1396,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13531396
if price is None:
13541397
continue
13551398
can_buy_value = min(diff, investable_cash)
1356-
if can_buy_value > price:
1357-
is_limit_order = symbol in limit_order_symbols or symbol == cash_sweep_symbol
1399+
can_afford_buy = (
1400+
can_buy_value >= MIN_FRACTIONAL_BUY_NOTIONAL_USD
1401+
if fractional_buy_execution
1402+
else can_buy_value > price
1403+
)
1404+
if can_afford_buy:
1405+
is_limit_order = (
1406+
False
1407+
if fractional_buy_execution
1408+
else (symbol in limit_order_symbols or symbol == cash_sweep_symbol)
1409+
)
13581410
limit_order_kind = "limit" if is_limit_order else "market"
13591411
limit_ref_price = (
13601412
_limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol)
@@ -1370,11 +1422,16 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13701422
estimate_max_purchase_quantity=estimate_max_purchase_quantity,
13711423
notify_issue=notify_issue,
13721424
dry_run_only=dry_run_only,
1425+
quantity_step=effective_buy_quantity_step,
1426+
estimate_kwargs=estimate_kwargs,
13731427
)
13741428
if limit_candidate is None:
13751429
continue
13761430
limit_candidate_quantity, limit_budget_quantity, limit_cash_limit_quantity = limit_candidate
1377-
limit_quantity = _normalize_trade_quantity(limit_candidate_quantity)
1431+
limit_quantity = _normalize_buy_quantity(
1432+
limit_candidate_quantity,
1433+
quantity_step=effective_buy_quantity_step,
1434+
)
13781435
order_kind = limit_order_kind
13791436
ref_price = limit_ref_price
13801437
quantity = limit_quantity

application/longbridge_execution.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def submit_order(
4444
side: str,
4545
quantity: float,
4646
submitted_price: float | None = None,
47+
allow_fractional_shares: bool = False,
48+
quantity_step: float = 1.0,
4749
) -> ExecutionReport:
4850
last_error: Exception | None = None
4951
for attempt in range(2):
@@ -55,6 +57,8 @@ def submit_order(
5557
side=side,
5658
quantity=quantity,
5759
submitted_price=submitted_price,
60+
allow_fractional_shares=allow_fractional_shares,
61+
quantity_step=quantity_step,
5862
)
5963
except Exception as exc:
6064
last_error = exc

application/rebalance_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ def fetch_replanned_state():
338338
min_order_notional_usd=config.min_order_notional_usd,
339339
safe_haven_cash_substitute_threshold_usd=config.safe_haven_cash_substitute_threshold_usd,
340340
cash_only_execution=config.cash_only_execution,
341+
fractional_buy_execution=config.fractional_buy_execution,
342+
buy_quantity_step=config.buy_quantity_step,
341343
)
342344
if _should_record_execution_marker(result=execution_result, config=config):
343345
_record_execution_marker(

application/runtime_composer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from quant_platform_kit.common.runtime_target import build_runtime_context_fields
2020
from quant_platform_kit.common.runtime_target import RuntimeTarget
2121
from notifications.telegram import build_prefixer, build_sender
22+
from runtime_execution_policy import FRACTIONAL_BUY_QUANTITY_STEP, fractional_buy_execution_enabled
2223

2324

2425
@dataclass(frozen=True)
@@ -218,6 +219,7 @@ def build_rebalance_config(
218219
lambda _error: (),
219220
)
220221
plugin_error_lines = tuple(build_plugin_error_lines(strategy_plugin_error))
222+
fractional_buy_execution = fractional_buy_execution_enabled(self.strategy_profile)
221223
return LongBridgeRebalanceConfig(
222224
limit_sell_discount=self.limit_sell_discount,
223225
limit_buy_premium=self.limit_buy_premium,
@@ -234,6 +236,8 @@ def build_rebalance_config(
234236
min_order_notional_usd=self.min_order_notional_usd,
235237
safe_haven_cash_substitute_threshold_usd=self.safe_haven_cash_substitute_threshold_usd,
236238
cash_only_execution=bool(cash_only_execution),
239+
fractional_buy_execution=fractional_buy_execution,
240+
buy_quantity_step=FRACTIONAL_BUY_QUANTITY_STEP if fractional_buy_execution else 1.0,
237241
sleeper=self.sleeper,
238242
extra_notification_lines=(market_scope_line, *plugin_lines, *plugin_error_lines),
239243
notification_title_key=notification_title_key,

application/runtime_dependencies.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class LongBridgeRebalanceConfig:
2626
min_order_notional_usd: float = 100.0
2727
safe_haven_cash_substitute_threshold_usd: float = 1000.0
2828
cash_only_execution: bool = True
29+
fractional_buy_execution: bool = False
30+
buy_quantity_step: float = 1.0
2931
sleeper: Callable[[float], None] | None = None
3032
extra_notification_lines: tuple[str, ...] = ()
3133
notification_title_key: str = ""

main.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
from application.rebalance_service import run_strategy as run_rebalance_cycle
2020
from application.runtime_strategy_adapters import build_runtime_strategy_adapters
2121
from application.longbridge_execution import submit_order
22+
from runtime_execution_policy import fractional_buy_execution_enabled, FRACTIONAL_BUY_QUANTITY_STEP
2223
from application.longbridge_portfolio import fetch_strategy_account_state
2324
from entrypoints.cloud_run import is_market_open_now
25+
from runtime_execution_policy import dca_execution_unsupported_reason
2426
from runtime_config_support import load_platform_runtime_settings
2527
from notifications.telegram import build_signal_text, build_strategy_display_name, build_translator
2628
from quant_platform_kit.common.runtime_reports import (
@@ -110,6 +112,27 @@ def get_project_id():
110112
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
111113
DEFAULT_MIN_ORDER_NOTIONAL_USD = 100.0
112114

115+
_FRACTIONAL_BUY_EXECUTION = fractional_buy_execution_enabled(STRATEGY_PROFILE)
116+
117+
118+
def _profile_submit_order(t_ctx, symbol, **kwargs):
119+
return submit_order(
120+
t_ctx,
121+
symbol,
122+
allow_fractional_shares=_FRACTIONAL_BUY_EXECUTION,
123+
quantity_step=FRACTIONAL_BUY_QUANTITY_STEP if _FRACTIONAL_BUY_EXECUTION else 1.0,
124+
**kwargs,
125+
)
126+
127+
128+
def _profile_estimate_max_purchase_quantity(t_ctx, symbol, **kwargs):
129+
return estimate_max_purchase_quantity(
130+
t_ctx,
131+
symbol,
132+
fractional_shares=_FRACTIONAL_BUY_EXECUTION,
133+
**kwargs,
134+
)
135+
113136
SEPARATOR = "━━━━━━━━━━━━━━━━━━"
114137

115138

@@ -254,7 +277,7 @@ def log_runtime_warning(message):
254277
),
255278
warning_log_fn=log_runtime_warning,
256279
),
257-
submit_order_fn=submit_order,
280+
submit_order_fn=_profile_submit_order,
258281
symbol_suffix=SYMBOL_SUFFIX,
259282
currency=TRADING_CURRENCY,
260283
cash_only_execution=CASH_ONLY_EXECUTION,
@@ -328,7 +351,7 @@ def build_composer(*, dry_run_only_override: bool | None = None):
328351
dry_run_only_override=dry_run_only_override,
329352
broker_adapters=BROKER_ADAPTERS,
330353
strategy_adapters=STRATEGY_ADAPTERS,
331-
estimate_max_purchase_quantity_fn=estimate_max_purchase_quantity,
354+
estimate_max_purchase_quantity_fn=_profile_estimate_max_purchase_quantity,
332355
fetch_order_status_fn=fetch_order_status,
333356
fetch_token_from_secret_fn=fetch_token_from_secret,
334357
refresh_token_if_needed_fn=refresh_token_if_needed,
@@ -542,6 +565,30 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
542565
),
543566
flush=True,
544567
)
568+
unsupported_reason = dca_execution_unsupported_reason(STRATEGY_PROFILE)
569+
if unsupported_reason is not None:
570+
reporting_adapters.log_event(
571+
log_context,
572+
"strategy_execution_unsupported",
573+
message="Strategy requires fractional-share execution; skip",
574+
skip_reason=unsupported_reason,
575+
strategy_profile=STRATEGY_PROFILE,
576+
market=MARKET,
577+
market_calendar=MARKET_CALENDAR,
578+
market_timezone=MARKET_TIMEZONE,
579+
)
580+
finalize_runtime_report(
581+
report,
582+
status="skipped",
583+
diagnostics={"skip_reason": unsupported_reason},
584+
)
585+
print(
586+
composer.with_prefix(
587+
f"Strategy {STRATEGY_PROFILE} requires fractional-share execution; skip."
588+
),
589+
flush=True,
590+
)
591+
return True
545592
if not validation_only:
546593
publish_strategy_plugin_alerts(strategy_plugin_signals, report=report)
547594
notification_delivery_events: list[dict] = []

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@7b6e3ce33e6563db4794fa7b865db9ec428dc478
4-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@608f491f4ef083c752ec29ea2669665d5de4a219
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@dfdbef6b58ab46f357d67800510bb9e8c4a01182
4+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@5537c0ad2ce0d34113381d4af19656b0bcdd82ec
55
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@9775fea22a0f397422d27b6a3340934b1ea7f064
66
pandas
77
requests

0 commit comments

Comments
 (0)