Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ class ExecutionCycleResult:
note_logs: tuple[str, ...]
action_done: bool
dry_run_orders: tuple[dict, ...] = ()
pending_orders: tuple[dict, ...] = ()
quote_snapshots: tuple[dict, ...] = ()


Expand All @@ -277,6 +278,19 @@ class ExecutionCycleResult:
}


def _is_truthy(value) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}


def _format_optional_equity(value) -> str:
try:
return f"${float(value):,.0f}"
except (TypeError, ValueError):
return "unknown"


def _limit_buy_premium_for_symbol(symbol, default_premium, premium_by_symbol=None) -> float:
normalized_symbol = str(symbol or "").strip().upper()
try:
Expand Down Expand Up @@ -984,6 +998,7 @@ def execute_rebalance_cycle(
note_logs: list[str] = []
submitted_orders: list[dict] = []
dry_run_orders: list[dict] = []
pending_orders: list[dict] = []
submitted_sell_orders: list[dict[str, Any]] = []
quote_snapshots_by_symbol: dict[str, dict] = {}
small_account_cash_note_keys: set[str] = set()
Expand Down Expand Up @@ -1087,6 +1102,9 @@ def _buy_step_for(symbol: str) -> float:
symbol_suffix=symbol_suffix,
)
target_values = dict(allocation["targets"])
small_account_buy_blocked = _is_truthy(execution.get("small_account_warning"))
small_account_portfolio_equity = execution.get("portfolio_total_equity")
small_account_min_equity = execution.get("min_recommended_equity_usd")
available_cash = float(portfolio["liquid_cash"])
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
investable_cash = float(execution["investable_cash"])
Expand Down Expand Up @@ -1160,14 +1178,16 @@ def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, su
return False

log_with_order_id = append_order_id_suffix(log_message, report.broker_order_id)
print(with_prefix(f"OK {log_with_order_id}"), flush=True)
logs.append(log_with_order_id)
pending_log = translator("order_pending_confirmation", detail=log_with_order_id)
print(with_prefix(pending_log), flush=True)
logs.append(pending_log)
order_payload = {
"symbol": str(symbol or "").strip().upper(),
"side": str(side or "").strip().lower(),
"quantity": float(order_intent.quantity or 0.0),
"order_type": str(order_type or "").strip().lower(),
"status": report.status,
"status": "pending_reconciliation",
"submission_status": report.status,
}
if submitted_price is not None:
order_payload["price"] = round(float(submitted_price), 4)
Expand All @@ -1176,6 +1196,7 @@ def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, su
if report.broker_order_id:
order_payload["broker_order_id"] = report.broker_order_id
submitted_orders.append(order_payload)
pending_orders.append(order_payload)
if str(side or "").strip().lower() == "sell":
submitted_sell_orders.append(order_payload)
if post_submit_order is not None:
Expand Down Expand Up @@ -1323,6 +1344,8 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
for symbol in buy_candidates
if symbol != cash_sweep_symbol
]
if small_account_buy_blocked:
funding_buy_candidates = []
if (
not sell_submitted
and funding_buy_candidates
Expand Down Expand Up @@ -1508,7 +1531,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
and abs(target_values[symbol] - market_values[symbol]) > current_min_trade
]
buys_blocked_reason: str | None = None
if cash_only_execution and buy_candidates and pending_sell_release_symbols:
if small_account_buy_blocked and buy_candidates:
buys_blocked_reason = "small_account_below_recommended_equity"
message = translator(
"buy_deferred_small_account",
portfolio_equity=_format_optional_equity(small_account_portfolio_equity),
min_recommended_equity=_format_optional_equity(small_account_min_equity),
)
note_logs.append(message)
print(with_prefix(message), flush=True)
buy_candidates = []
elif cash_only_execution and buy_candidates and pending_sell_release_symbols:
estimated_buy_cost = 0.0
for symbol in buy_candidates:
diff = target_values[symbol] - market_values[symbol]
Expand Down Expand Up @@ -1716,6 +1749,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
not cash_sweep_sold_this_cycle
and cash_sweep_symbol
and cash_sweep_symbol in strategy_assets
and not small_account_buy_blocked
and (
float(target_values.get(cash_sweep_symbol, 0.0) or 0.0) > 0.0
or not cash_sweep_substituted_to_cash
Expand Down Expand Up @@ -1836,5 +1870,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
note_logs=tuple(note_logs),
action_done=action_done,
dry_run_orders=tuple(dry_run_orders),
pending_orders=tuple(pending_orders),
quote_snapshots=tuple(quote_snapshots_by_symbol.values()),
)
24 changes: 22 additions & 2 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ def _record_platform_execution_telemetry(
return
execution = dict(execution_result.execution or {})
portfolio = dict(execution_result.portfolio or {})
pending_orders = tuple(getattr(execution_result, "pending_orders", ()) or ())
try_record_platform_execution(
profile,
{
"platform": "longbridge",
"action_done": bool(execution_result.action_done),
"action_done": bool(execution_result.action_done) and not pending_orders,
"broker_submission_done": bool(execution_result.action_done),
"orders_pending_count": len(pending_orders),
"effective_date": execution.get("effective_date"),
"signal_date": execution.get("signal_date"),
"dry_run_only": bool(getattr(config, "dry_run_only", False)),
Expand Down Expand Up @@ -239,6 +242,7 @@ def _record_execution_marker(
"dry_run_only": bool(getattr(config, "dry_run_only", False)),
"action_done": bool(getattr(result, "action_done", False)),
"dry_run_orders_count": len(tuple(getattr(result, "dry_run_orders", ()) or ())),
"pending_orders_count": len(tuple(getattr(result, "pending_orders", ()) or ())),
"signal_date": str(dict(getattr(result, "execution", {}) or {}).get("signal_date") or ""),
"effective_date": str(dict(getattr(result, "execution", {}) or {}).get("effective_date") or ""),
},
Expand Down Expand Up @@ -423,8 +427,24 @@ def fetch_replanned_state():
skip_logs = list(execution_result.skip_logs)
note_logs = list(execution_result.note_logs)
action_done = execution_result.action_done
pending_orders = tuple(getattr(execution_result, "pending_orders", ()) or ())

if action_done:
if pending_orders:
notification_publisher.publish(
notification_renderers.render_rebalance_notification(
execution=execution,
logs=logs,
skip_logs=skip_logs,
note_logs=note_logs,
translator=config.translator,
separator=config.separator,
strategy_display_name=config.strategy_display_name,
dry_run_only=config.dry_run_only,
extra_notification_lines=config.extra_notification_lines,
title_key="pending_order_title",
)
)
elif action_done:
notification_publisher.publish(
notification_renderers.render_rebalance_notification(
execution=execution,
Expand Down
4 changes: 4 additions & 0 deletions decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"snapshot_manifest_source_input_manifest_path",
"snapshot_manifest_source_refresh_run_id",
"snapshot_manifest_source_refresh_generated_at",
"small_account_warning",
"small_account_warning_reason",
"portfolio_total_equity",
"min_recommended_equity_usd",
)
_TQQQ_RISK_CONTROL_EXECUTION_FIELDS = (
"dual_drive_volatility_delever_enabled",
Expand Down
15 changes: 13 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,19 +278,30 @@ def _summarize_cycle_result_for_report(cycle_result, *, dry_run: bool) -> dict:
skip_logs = tuple(getattr(cycle_result, "skip_logs", ()) or ())
note_logs = tuple(getattr(cycle_result, "note_logs", ()) or ())
dry_run_orders = tuple(getattr(cycle_result, "dry_run_orders", ()) or ())
pending_orders = tuple(getattr(cycle_result, "pending_orders", ()) or ())
quote_snapshots = tuple(getattr(cycle_result, "quote_snapshots", ()) or ())
order_events_count = len(logs)
order_events_count = 0 if pending_orders else len(logs)
orders_previewed_count = len(dry_run_orders) if dry_run_orders else (order_events_count if dry_run else 0)
broker_submission_done = bool(getattr(cycle_result, "action_done", False))
summary = {
"action_done": bool(getattr(cycle_result, "action_done", False)),
"action_done": broker_submission_done and not pending_orders,
"broker_submission_done": broker_submission_done,
"execution_status": (
"pending_reconciliation"
if pending_orders
else ("previewed" if dry_run and broker_submission_done else "no_action")
),
"order_events_count": order_events_count,
"orders_pending_count": len(pending_orders),
"orders_previewed_count": orders_previewed_count,
"orders_skipped_count": len(skip_logs),
"notes_count": len(note_logs),
"dry_run_order_preview_available": bool(dry_run and orders_previewed_count > 0),
}
if dry_run_orders:
summary["orders_previewed"] = [dict(order) for order in dry_run_orders]
if pending_orders:
summary["orders_pending"] = [dict(order) for order in pending_orders]
if quote_snapshots:
summary["quote_snapshot"] = {
"quotes": [dict(snapshot) for snapshot in quote_snapshots],
Expand Down
6 changes: 6 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
I18N = {
"zh": {
"rebalance_title": "🔔 【调仓指令】",
"pending_order_title": "⏳ 【订单待券商最终确认】",
"dry_run_banner": "🧪 模拟运行模式,本次不会真实下单",
"strategy_label": "🧭 策略: {name}",
"market_scope_detail": "🌏 市场: {market} | 交易币种: {currency} | 标的后缀: {symbol_suffix}",
Expand Down Expand Up @@ -93,6 +94,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
"buy_skip_whole_share_detail": "{symbol} 需增 ${diff},整数股不足 1 股,无需下单",
"sell_skip_no_sellable_detail": "{symbol} 调仓差额 ${diff},持仓 {held} 股但无可卖数量(可卖 {sellable})",
"buy_deferred": "ℹ️ [买入说明] {detail}",
"buy_deferred_small_account": "⛔ [买入保护] 净值 {portfolio_equity} 低于策略建议 {min_recommended_equity};本轮禁止新增买入或加仓,允许策略减仓卖出",
"buy_deferred_pending_sell_release": "ℹ️ [买入跳过] 需先卖出 {symbols} 但整数股不足 1 股未成交;为避免融资本轮跳过对应买入",
"buy_deferred_negative_cash": "ℹ️ [买入跳过] 账户现金已为负(${cash}),为避免额外融资本轮跳过买入",
"buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单",
Expand Down Expand Up @@ -120,6 +122,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
"buy_deferred_cash_sweep_cash_limit": "{symbol} 剩余可投资现金 ${investable},预算可回补 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
"dca_notional_to_whole_share_compat": "平台不支持碎股(API quantity ≥1),DCA 定投金额已转换为最小整股/整手订单",
"execution_already_recorded": "已跳过重复执行:信号日 {signal_date} / 执行日 {effective_date} 已记录,本轮不再生成订单",
"order_pending_confirmation": "⏳ 已提交给券商,等待最终成交/拒绝确认:{detail}",
"cash_sweep_rebuy": "🏦 [尾部回补] 剩余可投资现金回补 {symbol}: {qty}股 @ ${price}",
"limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}(已提交,等待成交确认)",
"market_buy": "📈 [市价买入] {symbol}: {qty}股 @ ${price}",
Expand Down Expand Up @@ -231,6 +234,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
},
"en": {
"rebalance_title": "🔔 【Trade Execution Report】",
"pending_order_title": "⏳ 【Broker Order Pending Confirmation】",
"dry_run_banner": "🧪 Dry run mode, no real orders will be submitted",
"strategy_label": "🧭 Strategy: {name}",
"market_scope_detail": "🌏 Market: {market} | trading currency: {currency} | symbol suffix: {symbol_suffix}",
Expand Down Expand Up @@ -282,6 +286,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
"buy_skip_whole_share_detail": "{symbol} needs ${diff} added; whole-share quantity rounds to 0; no order needed",
"sell_skip_no_sellable_detail": "{symbol} rebalance gap ${diff}; held {held} shares but no sellable quantity (sellable {sellable})",
"buy_deferred": "ℹ️ [Buy note] {detail}",
"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",
"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",
"buy_deferred_negative_cash": "ℹ️ [Buy skipped] account cash is already negative (${cash}); skipping buys this cycle to avoid additional margin",
"buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle",
Expand Down Expand Up @@ -309,6 +314,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
"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",
"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",
"execution_already_recorded": "Duplicate execution skipped: signal date {signal_date} / effective date {effective_date} is already recorded; no orders will be generated this cycle",
"order_pending_confirmation": "⏳ Submitted to the broker; waiting for final fill or rejection confirmation: {detail}",
"cash_sweep_rebuy": "🏦 [tail rebuy] residual investable cash rebought {symbol}: {qty} shares @ ${price}",
"limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price} (submitted; awaiting fill confirmation)",
"market_buy": "📈 [Market buy] {symbol}: {qty} shares @ ${price}",
Expand Down
7 changes: 7 additions & 0 deletions tests/test_decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,10 @@ def test_carries_snapshot_manifest_diagnostics_to_execution(self):
"snapshot_manifest_source_input_fallback_used": True,
"snapshot_manifest_source_input_fallback_streak": 1,
"snapshot_manifest_source_refresh_run_id": "26785047433",
"small_account_warning": True,
"small_account_warning_reason": "integer_share_rounding",
"portfolio_total_equity": 500.0,
"min_recommended_equity_usd": 1000.0,
},
)

Expand All @@ -372,6 +376,9 @@ def test_carries_snapshot_manifest_diagnostics_to_execution(self):
self.assertIs(plan["execution"]["snapshot_manifest_source_input_fallback_used"], True)
self.assertEqual(plan["execution"]["snapshot_manifest_source_input_fallback_streak"], 1)
self.assertEqual(plan["execution"]["snapshot_manifest_source_refresh_run_id"], "26785047433")
self.assertIs(plan["execution"]["small_account_warning"], True)
self.assertEqual(plan["execution"]["portfolio_total_equity"], 500.0)
self.assertEqual(plan["execution"]["min_recommended_equity_usd"], 1000.0)

def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve(self):
decision = StrategyDecision(
Expand Down
Loading