Skip to content

Commit 23d3472

Browse files
Pigbibicodex
andauthored
fix: reconcile Longbridge order outcomes (#358)
Co-authored-by: Codex <noreply@openai.com>
1 parent 6bd8863 commit 23d3472

8 files changed

Lines changed: 256 additions & 15 deletions

File tree

application/execution_service.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ class ExecutionCycleResult:
256256
note_logs: tuple[str, ...]
257257
action_done: bool
258258
dry_run_orders: tuple[dict, ...] = ()
259+
pending_orders: tuple[dict, ...] = ()
259260
quote_snapshots: tuple[dict, ...] = ()
260261

261262

@@ -277,6 +278,19 @@ class ExecutionCycleResult:
277278
}
278279

279280

281+
def _is_truthy(value) -> bool:
282+
if isinstance(value, bool):
283+
return value
284+
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
285+
286+
287+
def _format_optional_equity(value) -> str:
288+
try:
289+
return f"${float(value):,.0f}"
290+
except (TypeError, ValueError):
291+
return "unknown"
292+
293+
280294
def _limit_buy_premium_for_symbol(symbol, default_premium, premium_by_symbol=None) -> float:
281295
normalized_symbol = str(symbol or "").strip().upper()
282296
try:
@@ -984,6 +998,7 @@ def execute_rebalance_cycle(
984998
note_logs: list[str] = []
985999
submitted_orders: list[dict] = []
9861000
dry_run_orders: list[dict] = []
1001+
pending_orders: list[dict] = []
9871002
submitted_sell_orders: list[dict[str, Any]] = []
9881003
quote_snapshots_by_symbol: dict[str, dict] = {}
9891004
small_account_cash_note_keys: set[str] = set()
@@ -1087,6 +1102,9 @@ def _buy_step_for(symbol: str) -> float:
10871102
symbol_suffix=symbol_suffix,
10881103
)
10891104
target_values = dict(allocation["targets"])
1105+
small_account_buy_blocked = _is_truthy(execution.get("small_account_warning"))
1106+
small_account_portfolio_equity = execution.get("portfolio_total_equity")
1107+
small_account_min_equity = execution.get("min_recommended_equity_usd")
10901108
available_cash = float(portfolio["liquid_cash"])
10911109
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
10921110
investable_cash = float(execution["investable_cash"])
@@ -1160,14 +1178,16 @@ def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, su
11601178
return False
11611179

11621180
log_with_order_id = append_order_id_suffix(log_message, report.broker_order_id)
1163-
print(with_prefix(f"OK {log_with_order_id}"), flush=True)
1164-
logs.append(log_with_order_id)
1181+
pending_log = translator("order_pending_confirmation", detail=log_with_order_id)
1182+
print(with_prefix(pending_log), flush=True)
1183+
logs.append(pending_log)
11651184
order_payload = {
11661185
"symbol": str(symbol or "").strip().upper(),
11671186
"side": str(side or "").strip().lower(),
11681187
"quantity": float(order_intent.quantity or 0.0),
11691188
"order_type": str(order_type or "").strip().lower(),
1170-
"status": report.status,
1189+
"status": "pending_reconciliation",
1190+
"submission_status": report.status,
11711191
}
11721192
if submitted_price is not None:
11731193
order_payload["price"] = round(float(submitted_price), 4)
@@ -1176,6 +1196,7 @@ def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, su
11761196
if report.broker_order_id:
11771197
order_payload["broker_order_id"] = report.broker_order_id
11781198
submitted_orders.append(order_payload)
1199+
pending_orders.append(order_payload)
11791200
if str(side or "").strip().lower() == "sell":
11801201
submitted_sell_orders.append(order_payload)
11811202
if post_submit_order is not None:
@@ -1323,6 +1344,8 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
13231344
for symbol in buy_candidates
13241345
if symbol != cash_sweep_symbol
13251346
]
1347+
if small_account_buy_blocked:
1348+
funding_buy_candidates = []
13261349
if (
13271350
not sell_submitted
13281351
and funding_buy_candidates
@@ -1508,7 +1531,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
15081531
and abs(target_values[symbol] - market_values[symbol]) > current_min_trade
15091532
]
15101533
buys_blocked_reason: str | None = None
1511-
if cash_only_execution and buy_candidates and pending_sell_release_symbols:
1534+
if small_account_buy_blocked and buy_candidates:
1535+
buys_blocked_reason = "small_account_below_recommended_equity"
1536+
message = translator(
1537+
"buy_deferred_small_account",
1538+
portfolio_equity=_format_optional_equity(small_account_portfolio_equity),
1539+
min_recommended_equity=_format_optional_equity(small_account_min_equity),
1540+
)
1541+
note_logs.append(message)
1542+
print(with_prefix(message), flush=True)
1543+
buy_candidates = []
1544+
elif cash_only_execution and buy_candidates and pending_sell_release_symbols:
15121545
estimated_buy_cost = 0.0
15131546
for symbol in buy_candidates:
15141547
diff = target_values[symbol] - market_values[symbol]
@@ -1716,6 +1749,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
17161749
not cash_sweep_sold_this_cycle
17171750
and cash_sweep_symbol
17181751
and cash_sweep_symbol in strategy_assets
1752+
and not small_account_buy_blocked
17191753
and (
17201754
float(target_values.get(cash_sweep_symbol, 0.0) or 0.0) > 0.0
17211755
or not cash_sweep_substituted_to_cash
@@ -1836,5 +1870,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
18361870
note_logs=tuple(note_logs),
18371871
action_done=action_done,
18381872
dry_run_orders=tuple(dry_run_orders),
1873+
pending_orders=tuple(pending_orders),
18391874
quote_snapshots=tuple(quote_snapshots_by_symbol.values()),
18401875
)

application/rebalance_service.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@ def _record_platform_execution_telemetry(
3131
return
3232
execution = dict(execution_result.execution or {})
3333
portfolio = dict(execution_result.portfolio or {})
34+
pending_orders = tuple(getattr(execution_result, "pending_orders", ()) or ())
3435
try_record_platform_execution(
3536
profile,
3637
{
3738
"platform": "longbridge",
38-
"action_done": bool(execution_result.action_done),
39+
"action_done": bool(execution_result.action_done) and not pending_orders,
40+
"broker_submission_done": bool(execution_result.action_done),
41+
"orders_pending_count": len(pending_orders),
3942
"effective_date": execution.get("effective_date"),
4043
"signal_date": execution.get("signal_date"),
4144
"dry_run_only": bool(getattr(config, "dry_run_only", False)),
@@ -239,6 +242,7 @@ def _record_execution_marker(
239242
"dry_run_only": bool(getattr(config, "dry_run_only", False)),
240243
"action_done": bool(getattr(result, "action_done", False)),
241244
"dry_run_orders_count": len(tuple(getattr(result, "dry_run_orders", ()) or ())),
245+
"pending_orders_count": len(tuple(getattr(result, "pending_orders", ()) or ())),
242246
"signal_date": str(dict(getattr(result, "execution", {}) or {}).get("signal_date") or ""),
243247
"effective_date": str(dict(getattr(result, "execution", {}) or {}).get("effective_date") or ""),
244248
},
@@ -423,8 +427,24 @@ def fetch_replanned_state():
423427
skip_logs = list(execution_result.skip_logs)
424428
note_logs = list(execution_result.note_logs)
425429
action_done = execution_result.action_done
430+
pending_orders = tuple(getattr(execution_result, "pending_orders", ()) or ())
426431

427-
if action_done:
432+
if pending_orders:
433+
notification_publisher.publish(
434+
notification_renderers.render_rebalance_notification(
435+
execution=execution,
436+
logs=logs,
437+
skip_logs=skip_logs,
438+
note_logs=note_logs,
439+
translator=config.translator,
440+
separator=config.separator,
441+
strategy_display_name=config.strategy_display_name,
442+
dry_run_only=config.dry_run_only,
443+
extra_notification_lines=config.extra_notification_lines,
444+
title_key="pending_order_title",
445+
)
446+
)
447+
elif action_done:
428448
notification_publisher.publish(
429449
notification_renderers.render_rebalance_notification(
430450
execution=execution,

decision_mapper.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
"snapshot_manifest_source_input_manifest_path",
3333
"snapshot_manifest_source_refresh_run_id",
3434
"snapshot_manifest_source_refresh_generated_at",
35+
"small_account_warning",
36+
"small_account_warning_reason",
37+
"portfolio_total_equity",
38+
"min_recommended_equity_usd",
3539
)
3640
_TQQQ_RISK_CONTROL_EXECUTION_FIELDS = (
3741
"dual_drive_volatility_delever_enabled",

main.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,19 +278,30 @@ def _summarize_cycle_result_for_report(cycle_result, *, dry_run: bool) -> dict:
278278
skip_logs = tuple(getattr(cycle_result, "skip_logs", ()) or ())
279279
note_logs = tuple(getattr(cycle_result, "note_logs", ()) or ())
280280
dry_run_orders = tuple(getattr(cycle_result, "dry_run_orders", ()) or ())
281+
pending_orders = tuple(getattr(cycle_result, "pending_orders", ()) or ())
281282
quote_snapshots = tuple(getattr(cycle_result, "quote_snapshots", ()) or ())
282-
order_events_count = len(logs)
283+
order_events_count = 0 if pending_orders else len(logs)
283284
orders_previewed_count = len(dry_run_orders) if dry_run_orders else (order_events_count if dry_run else 0)
285+
broker_submission_done = bool(getattr(cycle_result, "action_done", False))
284286
summary = {
285-
"action_done": bool(getattr(cycle_result, "action_done", False)),
287+
"action_done": broker_submission_done and not pending_orders,
288+
"broker_submission_done": broker_submission_done,
289+
"execution_status": (
290+
"pending_reconciliation"
291+
if pending_orders
292+
else ("previewed" if dry_run and broker_submission_done else "no_action")
293+
),
286294
"order_events_count": order_events_count,
295+
"orders_pending_count": len(pending_orders),
287296
"orders_previewed_count": orders_previewed_count,
288297
"orders_skipped_count": len(skip_logs),
289298
"notes_count": len(note_logs),
290299
"dry_run_order_preview_available": bool(dry_run and orders_previewed_count > 0),
291300
}
292301
if dry_run_orders:
293302
summary["orders_previewed"] = [dict(order) for order in dry_run_orders]
303+
if pending_orders:
304+
summary["orders_pending"] = [dict(order) for order in pending_orders]
294305
if quote_snapshots:
295306
summary["quote_snapshot"] = {
296307
"quotes": [dict(snapshot) for snapshot in quote_snapshots],

notifications/telegram.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
4242
I18N = {
4343
"zh": {
4444
"rebalance_title": "🔔 【调仓指令】",
45+
"pending_order_title": "⏳ 【订单待券商最终确认】",
4546
"dry_run_banner": "🧪 模拟运行模式,本次不会真实下单",
4647
"strategy_label": "🧭 策略: {name}",
4748
"market_scope_detail": "🌏 市场: {market} | 交易币种: {currency} | 标的后缀: {symbol_suffix}",
@@ -93,6 +94,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
9394
"buy_skip_whole_share_detail": "{symbol} 需增 ${diff},整数股不足 1 股,无需下单",
9495
"sell_skip_no_sellable_detail": "{symbol} 调仓差额 ${diff},持仓 {held} 股但无可卖数量(可卖 {sellable})",
9596
"buy_deferred": "ℹ️ [买入说明] {detail}",
97+
"buy_deferred_small_account": "⛔ [买入保护] 净值 {portfolio_equity} 低于策略建议 {min_recommended_equity};本轮禁止新增买入或加仓,允许策略减仓卖出",
9698
"buy_deferred_pending_sell_release": "ℹ️ [买入跳过] 需先卖出 {symbols} 但整数股不足 1 股未成交;为避免融资本轮跳过对应买入",
9799
"buy_deferred_negative_cash": "ℹ️ [买入跳过] 账户现金已为负(${cash}),为避免额外融资本轮跳过买入",
98100
"buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单",
@@ -120,6 +122,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
120122
"buy_deferred_cash_sweep_cash_limit": "{symbol} 剩余可投资现金 ${investable},预算可回补 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
121123
"dca_notional_to_whole_share_compat": "平台不支持碎股(API quantity ≥1),DCA 定投金额已转换为最小整股/整手订单",
122124
"execution_already_recorded": "已跳过重复执行:信号日 {signal_date} / 执行日 {effective_date} 已记录,本轮不再生成订单",
125+
"order_pending_confirmation": "⏳ 已提交给券商,等待最终成交/拒绝确认:{detail}",
123126
"cash_sweep_rebuy": "🏦 [尾部回补] 剩余可投资现金回补 {symbol}: {qty}股 @ ${price}",
124127
"limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}(已提交,等待成交确认)",
125128
"market_buy": "📈 [市价买入] {symbol}: {qty}股 @ ${price}",
@@ -231,6 +234,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
231234
},
232235
"en": {
233236
"rebalance_title": "🔔 【Trade Execution Report】",
237+
"pending_order_title": "⏳ 【Broker Order Pending Confirmation】",
234238
"dry_run_banner": "🧪 Dry run mode, no real orders will be submitted",
235239
"strategy_label": "🧭 Strategy: {name}",
236240
"market_scope_detail": "🌏 Market: {market} | trading currency: {currency} | symbol suffix: {symbol_suffix}",
@@ -282,6 +286,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
282286
"buy_skip_whole_share_detail": "{symbol} needs ${diff} added; whole-share quantity rounds to 0; no order needed",
283287
"sell_skip_no_sellable_detail": "{symbol} rebalance gap ${diff}; held {held} shares but no sellable quantity (sellable {sellable})",
284288
"buy_deferred": "ℹ️ [Buy note] {detail}",
289+
"buy_deferred_small_account": "⛔ [Buy guard] equity {portfolio_equity} is below the recommended {min_recommended_equity}; new buys and top-ups are blocked while strategy-driven sells remain allowed",
285290
"buy_deferred_pending_sell_release": "ℹ️ [Buy skipped] {symbols} still needs trimming but whole-share sell rounded to 0; skipping paired buys this cycle to avoid margin",
286291
"buy_deferred_negative_cash": "ℹ️ [Buy skipped] account cash is already negative (${cash}); skipping buys this cycle to avoid additional margin",
287292
"buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle",
@@ -309,6 +314,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
309314
"buy_deferred_cash_sweep_cash_limit": "{symbol} residual investable cash ${investable}, budget supports {budget_qty} tail-rebuy shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds",
310315
"dca_notional_to_whole_share_compat": "Platform does not support fractional shares (API quantity >=1); DCA notional amounts converted to minimum whole-share / whole-lot orders",
311316
"execution_already_recorded": "Duplicate execution skipped: signal date {signal_date} / effective date {effective_date} is already recorded; no orders will be generated this cycle",
317+
"order_pending_confirmation": "⏳ Submitted to the broker; waiting for final fill or rejection confirmation: {detail}",
312318
"cash_sweep_rebuy": "🏦 [tail rebuy] residual investable cash rebought {symbol}: {qty} shares @ ${price}",
313319
"limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price} (submitted; awaiting fill confirmation)",
314320
"market_buy": "📈 [Market buy] {symbol}: {qty} shares @ ${price}",

tests/test_decision_mapper.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ def test_carries_snapshot_manifest_diagnostics_to_execution(self):
363363
"snapshot_manifest_source_input_fallback_used": True,
364364
"snapshot_manifest_source_input_fallback_streak": 1,
365365
"snapshot_manifest_source_refresh_run_id": "26785047433",
366+
"small_account_warning": True,
367+
"small_account_warning_reason": "integer_share_rounding",
368+
"portfolio_total_equity": 500.0,
369+
"min_recommended_equity_usd": 1000.0,
366370
},
367371
)
368372

@@ -372,6 +376,9 @@ def test_carries_snapshot_manifest_diagnostics_to_execution(self):
372376
self.assertIs(plan["execution"]["snapshot_manifest_source_input_fallback_used"], True)
373377
self.assertEqual(plan["execution"]["snapshot_manifest_source_input_fallback_streak"], 1)
374378
self.assertEqual(plan["execution"]["snapshot_manifest_source_refresh_run_id"], "26785047433")
379+
self.assertIs(plan["execution"]["small_account_warning"], True)
380+
self.assertEqual(plan["execution"]["portfolio_total_equity"], 500.0)
381+
self.assertEqual(plan["execution"]["min_recommended_equity_usd"], 1000.0)
375382

376383
def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve(self):
377384
decision = StrategyDecision(

0 commit comments

Comments
 (0)