Skip to content

Commit 723550b

Browse files
Pigbibicodex
andcommitted
fix: bridge filled sell proceeds for follow-up buy
Co-Authored-By: Codex <noreply@openai.com>
1 parent 1feeceb commit 723550b

6 files changed

Lines changed: 235 additions & 21 deletions

File tree

application/execution_service.py

Lines changed: 157 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,100 @@ def _should_bootstrap_whole_share_buy(symbol, *, target_value, limit_price) -> b
423423
return max(0.0, float(target_value or 0.0)) >= effective_limit_price * float(min_target_share_ratio)
424424

425425

426+
def _should_top_up_existing_whole_share_buy(
427+
symbol,
428+
*,
429+
target_value,
430+
current_value,
431+
quantity=0.0,
432+
limit_price,
433+
quantity_step: float = 1.0,
434+
) -> bool:
435+
del symbol
436+
if abs(float(quantity_step or 0.0) - 1.0) > 1e-9:
437+
return False
438+
held_quantity = max(0.0, float(quantity or 0.0))
439+
effective_limit_price = max(0.0, float(limit_price or 0.0))
440+
if held_quantity < 1.0 or effective_limit_price <= 0.0:
441+
return False
442+
remaining_value = max(0.0, float(target_value or 0.0) - float(current_value or 0.0))
443+
if remaining_value <= 0.0 or remaining_value >= effective_limit_price:
444+
return False
445+
held_whole_shares = int(held_quantity)
446+
if held_whole_shares <= 0:
447+
return False
448+
target_quantity = max(0.0, float(target_value or 0.0)) / effective_limit_price
449+
return target_quantity >= held_whole_shares + 0.5
450+
451+
452+
def _planned_whole_share_buy_quantity(
453+
symbol,
454+
*,
455+
target_value,
456+
current_value,
457+
quantity=0.0,
458+
buy_budget,
459+
available_buying_power,
460+
ref_price,
461+
quantity_step: float = 1.0,
462+
allow_top_up=False,
463+
):
464+
effective_ref_price = max(0.0, float(ref_price or 0.0))
465+
if effective_ref_price <= 0.0:
466+
return 0
467+
planned_quantity = _normalize_buy_quantity(
468+
max(0.0, float(buy_budget or 0.0)) / effective_ref_price,
469+
quantity_step=quantity_step,
470+
)
471+
if planned_quantity > 0:
472+
return planned_quantity
473+
if not allow_top_up:
474+
return 0
475+
if max(0.0, float(available_buying_power or 0.0)) < effective_ref_price:
476+
return 0
477+
if _should_top_up_existing_whole_share_buy(
478+
symbol,
479+
target_value=target_value,
480+
current_value=current_value,
481+
quantity=quantity,
482+
limit_price=effective_ref_price,
483+
quantity_step=quantity_step,
484+
):
485+
return _normalize_buy_quantity(1.0, quantity_step=quantity_step)
486+
return 0
487+
488+
489+
def _filled_sell_release_value(
490+
*,
491+
trade_context,
492+
submitted_sell_orders,
493+
fetch_order_status,
494+
) -> float:
495+
if fetch_order_status is None:
496+
return 0.0
497+
released_value = 0.0
498+
for order in tuple(submitted_sell_orders or ()):
499+
broker_order_id = str((order or {}).get("broker_order_id") or "").strip()
500+
if not broker_order_id:
501+
continue
502+
try:
503+
status_payload = fetch_order_status(trade_context, broker_order_id)
504+
except Exception:
505+
continue
506+
if not isinstance(status_payload, Mapping):
507+
continue
508+
status = str(status_payload.get("status") or "").strip()
509+
try:
510+
executed_qty = max(0.0, float(status_payload.get("executed_qty") or 0.0))
511+
executed_price = max(0.0, float(status_payload.get("executed_price") or 0.0))
512+
except (TypeError, ValueError):
513+
continue
514+
if status not in {"Filled", "PartiallyFilled", "Partial"} and executed_qty <= 0.0:
515+
continue
516+
released_value += executed_qty * executed_price
517+
return released_value
518+
519+
426520
def _normalize_cash_by_currency(raw_cash) -> dict[str, float]:
427521
if not isinstance(raw_cash, Mapping):
428522
return {}
@@ -864,6 +958,7 @@ def execute_rebalance_cycle(
864958
market_data_port,
865959
estimate_max_purchase_quantity,
866960
execution_port,
961+
fetch_order_status=None,
867962
post_submit_order=None,
868963
notify_issue,
869964
translator,
@@ -889,6 +984,7 @@ def execute_rebalance_cycle(
889984
note_logs: list[str] = []
890985
submitted_orders: list[dict] = []
891986
dry_run_orders: list[dict] = []
987+
submitted_sell_orders: list[dict[str, Any]] = []
892988
quote_snapshots_by_symbol: dict[str, dict] = {}
893989
small_account_cash_note_keys: set[str] = set()
894990
small_account_bootstrap_note_keys: set[str] = set()
@@ -1080,6 +1176,8 @@ def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, su
10801176
if report.broker_order_id:
10811177
order_payload["broker_order_id"] = report.broker_order_id
10821178
submitted_orders.append(order_payload)
1179+
if str(side or "").strip().lower() == "sell":
1180+
submitted_sell_orders.append(order_payload)
10831181
if post_submit_order is not None:
10841182
try:
10851183
post_submit_order(trade_context, order_intent, report)
@@ -1309,12 +1407,21 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13091407
refresh_interval = max(0.0, float(post_sell_refresh_interval_sec or 0.0))
13101408
best_refreshed_state = None
13111409
best_investable_cash = previous_investable_cash
1410+
projected_sell_release_value = 0.0
13121411
for attempt in range(refresh_attempts):
13131412
if attempt > 0:
13141413
sleeper(refresh_interval)
13151414
refreshed_state = fetch_replanned_state()
13161415
refreshed_execution = refreshed_state[2]
13171416
refreshed_investable_cash = float(refreshed_execution["investable_cash"])
1417+
projected_sell_release_value = max(
1418+
projected_sell_release_value,
1419+
_filled_sell_release_value(
1420+
trade_context=trade_context,
1421+
submitted_sell_orders=submitted_sell_orders,
1422+
fetch_order_status=fetch_order_status,
1423+
),
1424+
)
13181425
if best_refreshed_state is None or refreshed_investable_cash > best_investable_cash:
13191426
best_refreshed_state = refreshed_state
13201427
best_investable_cash = refreshed_investable_cash
@@ -1370,7 +1477,10 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13701477
target_values = dict(allocation["targets"])
13711478
available_cash = float(portfolio["liquid_cash"])
13721479
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
1373-
investable_cash = float(execution["investable_cash"])
1480+
investable_cash = max(
1481+
float(execution["investable_cash"]),
1482+
previous_investable_cash + projected_sell_release_value,
1483+
)
13741484
if fractional_buy_execution:
13751485
current_min_trade = max(float(execution["current_min_trade"]), MIN_FRACTIONAL_BUY_NOTIONAL_USD)
13761486
else:
@@ -1415,14 +1525,25 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
14151525
if can_buy_value >= MIN_FRACTIONAL_BUY_NOTIONAL_USD:
14161526
estimated_buy_cost += can_buy_value
14171527
continue
1418-
if can_buy_value <= price:
1419-
continue
1420-
limit_price = _limit_buy_price(
1421-
symbol, price, limit_buy_premium, limit_buy_premium_by_symbol
1528+
is_limit_order = symbol in limit_order_symbols or symbol == cash_sweep_symbol
1529+
ref_price = (
1530+
_limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol)
1531+
if is_limit_order
1532+
else round(price, 2)
1533+
)
1534+
quantity = _planned_whole_share_buy_quantity(
1535+
symbol,
1536+
target_value=target_values[symbol],
1537+
current_value=market_values[symbol],
1538+
quantity=quantities.get(symbol, 0.0),
1539+
buy_budget=can_buy_value,
1540+
available_buying_power=investable_cash,
1541+
ref_price=ref_price,
1542+
quantity_step=_buy_step_for(market_symbol(symbol)),
1543+
allow_top_up=sell_submitted,
14221544
)
1423-
quantity = int(can_buy_value // limit_price) if limit_price > 0 else 0
14241545
if quantity > 0:
1425-
estimated_buy_cost += quantity * limit_price
1546+
estimated_buy_cost += quantity * ref_price
14261547
if estimated_buy_cost > investable_cash:
14271548
buys_blocked_reason = "pending_sell_release"
14281549
message = translator(
@@ -1455,29 +1576,45 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
14551576
if price is None:
14561577
continue
14571578
can_buy_value = min(diff, investable_cash)
1579+
planned_quantity = 0
1580+
effective_can_buy_value = can_buy_value
1581+
is_limit_order = (
1582+
False
1583+
if fractional_buy_execution
1584+
else (symbol in limit_order_symbols or symbol == cash_sweep_symbol)
1585+
)
1586+
ref_price = (
1587+
_limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol)
1588+
if is_limit_order
1589+
else round(price, 2)
1590+
)
1591+
if not fractional_buy_execution:
1592+
planned_quantity = _planned_whole_share_buy_quantity(
1593+
symbol,
1594+
target_value=target_values[symbol],
1595+
current_value=market_values[symbol],
1596+
quantity=quantities.get(symbol, 0.0),
1597+
buy_budget=can_buy_value,
1598+
available_buying_power=investable_cash,
1599+
ref_price=ref_price,
1600+
quantity_step=_buy_step_for(market_symbol(symbol)),
1601+
allow_top_up=sell_submitted,
1602+
)
1603+
if planned_quantity > 0:
1604+
effective_can_buy_value = max(can_buy_value, planned_quantity * ref_price)
14581605
can_afford_buy = (
14591606
can_buy_value >= MIN_FRACTIONAL_BUY_NOTIONAL_USD
14601607
if fractional_buy_execution
1461-
else can_buy_value > price
1608+
else planned_quantity > 0
14621609
)
14631610
if can_afford_buy:
1464-
is_limit_order = (
1465-
False
1466-
if fractional_buy_execution
1467-
else (symbol in limit_order_symbols or symbol == cash_sweep_symbol)
1468-
)
14691611
limit_order_kind = "limit" if is_limit_order else "market"
1470-
limit_ref_price = (
1471-
_limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol)
1472-
if is_limit_order
1473-
else round(price, 2)
1474-
)
14751612
limit_candidate = _estimate_buy_quantity_candidate(
14761613
trade_context,
14771614
market_symbol(symbol),
14781615
limit_order_kind,
1479-
limit_ref_price,
1480-
can_buy_value=can_buy_value,
1616+
ref_price,
1617+
can_buy_value=effective_can_buy_value,
14811618
estimate_max_purchase_quantity=estimate_max_purchase_quantity,
14821619
notify_issue=notify_issue,
14831620
dry_run_only=dry_run_only,
@@ -1492,7 +1629,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
14921629
quantity_step=_buy_step_for(market_symbol(symbol)),
14931630
)
14941631
order_kind = limit_order_kind
1495-
ref_price = limit_ref_price
14961632
quantity = limit_quantity
14971633
cost_estimate = 0.0
14981634
if quantity <= 0:

application/rebalance_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ def fetch_replanned_state():
361361
market_data_port=market_data_port,
362362
estimate_max_purchase_quantity=runtime.estimate_max_purchase_quantity,
363363
execution_port=execution_port,
364+
fetch_order_status=runtime.fetch_order_status,
364365
post_submit_order=runtime.post_submit_order,
365366
notify_issue=runtime.notify_issue,
366367
translator=config.translator,

application/runtime_composer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def build_rebalance_runtime(
199199
portfolio_port_factory=self.broker_adapters.build_portfolio_port,
200200
execution_port_factory=self.broker_adapters.build_execution_port,
201201
post_submit_order=notification_adapters.post_submit_order,
202+
fetch_order_status=self.fetch_order_status_fn,
202203
)
203204

204205
def build_rebalance_config(

application/runtime_dependencies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,4 @@ class LongBridgeRebalanceRuntime:
5050
portfolio_port_factory: Callable[[Any, Any], PortfolioPort]
5151
execution_port_factory: Callable[[Any], ExecutionPort]
5252
post_submit_order: Callable[[Any, Any, Any], None] | None = None
53+
fetch_order_status: Callable[..., Any] | None = None

tests/test_rebalance_service.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,80 @@ def test_min_order_notional_does_not_block_zero_target_risk_sell(self):
425425
[("SOXL.US", "sell", 1)],
426426
)
427427

428+
def test_sell_fill_bridge_can_top_up_existing_position_after_stale_refresh(self):
429+
submitted_orders = []
430+
stale_plan = _build_plan(
431+
strategy_symbols=("SOXL", "SOXX"),
432+
risk_symbols=("SOXL", "SOXX"),
433+
targets={"SOXL": 0.0, "SOXX": 260.0},
434+
market_values={"SOXL": 120.0, "SOXX": 200.0},
435+
sellable_quantities={"SOXL": 3, "SOXX": 0},
436+
quantities={"SOXL": 3, "SOXX": 2},
437+
current_min_trade=10.0,
438+
trade_threshold_value=10.0,
439+
investable_cash=10.0,
440+
market_status="rotation",
441+
deploy_ratio_text="",
442+
income_ratio_text="",
443+
income_locked_ratio_text="",
444+
signal_message="sell then top up",
445+
available_cash=10.0,
446+
total_strategy_equity=330.0,
447+
portfolio_rows=(("SOXL", "SOXX"),),
448+
)
449+
450+
result = execute_rebalance_cycle(
451+
trade_context=object(),
452+
plan=stale_plan,
453+
portfolio=stale_plan["portfolio"],
454+
execution=stale_plan["execution"],
455+
allocation=stale_plan["allocation"],
456+
fetch_replanned_state=lambda: (
457+
stale_plan,
458+
stale_plan["portfolio"],
459+
stale_plan["execution"],
460+
stale_plan["allocation"],
461+
),
462+
market_data_port=CallableMarketDataPort(
463+
quote_loader=lambda symbol: QuoteSnapshot(
464+
symbol=symbol,
465+
as_of="2026-07-10",
466+
last_price={"SOXL.US": 40.0, "SOXX.US": 100.0}[symbol],
467+
)
468+
),
469+
estimate_max_purchase_quantity=lambda *_args, **_kwargs: 10,
470+
execution_port=CallableExecutionPort(
471+
lambda order_intent: (
472+
submitted_orders.append(order_intent),
473+
ExecutionReport(
474+
symbol=order_intent.symbol,
475+
side=order_intent.side,
476+
quantity=order_intent.quantity,
477+
status="accepted",
478+
broker_order_id=f"order-{len(submitted_orders)}",
479+
),
480+
)[-1]
481+
),
482+
fetch_order_status=lambda _ctx, order_id: {
483+
"status": "Filled",
484+
"executed_qty": "3",
485+
"executed_price": "40",
486+
}
487+
if order_id == "order-1"
488+
else None,
489+
notify_issue=lambda _title, _detail: None,
490+
translator=build_translator("zh"),
491+
with_prefix=lambda message: message,
492+
limit_sell_discount=0.995,
493+
limit_buy_premium=1.0,
494+
)
495+
496+
self.assertTrue(result.action_done)
497+
self.assertEqual(
498+
[(order.symbol, order.side, order.quantity) for order in submitted_orders],
499+
[("SOXL.US", "sell", 3), ("SOXX.US", "buy", 1)],
500+
)
501+
428502
def test_small_account_whole_share_layer_sells_unbuyable_soxx_sleeve(self):
429503
submitted_orders = []
430504
prices = {"SOXL": 191.15, "SOXX": 536.88, "BOXX": 100.0}

tests/test_runtime_composer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def fake_cycle_sender(**kwargs):
137137
assert runtime.resolve_rebalance_plan == "resolve-plan"
138138
assert runtime.market_data_port_factory == "market-data-port-factory"
139139
assert runtime.notifications == "notification-port"
140+
assert runtime.fetch_order_status == "fetch-order-status"
140141
silent_runtime.notifications.send_text("precheck heartbeat")
141142
assert observed["sent_message"] == ("tg-token", "chat-id", "[HK] hello")
142143
assert runtime.post_submit_order == "post-submit-order"

0 commit comments

Comments
 (0)