Skip to content

Commit b915a49

Browse files
authored
Align Firstrade execution block messaging (#20)
1 parent 16fb300 commit b915a49

4 files changed

Lines changed: 85 additions & 16 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,15 @@ When `FIRSTRADE_PERSIST_STRATEGY_RUNS=true` and a GCS state bucket is configured
202202
`strategy-runs/<masked-account>/<strategy-profile>/<yyyy-mm>/latest.json` plus a
203203
timestamped history path. The record includes the planned targets, compact
204204
portfolio snapshot, evaluation metadata, submitted orders, skipped orders, and
205-
stage (`ORDERS_PLANNED`, `DRY_RUN_COMPLETED`, or `SUBMITTED`). For live runs,
205+
stage (`ORDERS_PLANNED`, `DRY_RUN_COMPLETED`, `NO_ACTION`, `SUBMITTED`,
206+
`EXECUTION_BLOCKED`, `PARTIAL_SUBMITTED`, or `FUNDING_BLOCKED`). For live runs,
206207
an existing terminal record in the same account/profile/month blocks duplicate
207-
order submission.
208+
order submission. Terminal records include `SUBMITTED`, `FUNDING_BLOCKED`,
209+
`RECONCILED`, and `COMPLETED`; transient execution blockers such as unavailable
210+
quotes remain non-terminal so the scheduler can retry while the strategy's
211+
trading-day execution window is still open. A pure insufficient-cash block is
212+
recorded as `FUNDING_BLOCKED` with the skipped-order reason and is not retried
213+
automatically for that period.
208214

209215
## Cloud Run Shape
210216

application/rebalance_service.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ def _is_terminal_funding_block(blocking_skips: list[dict[str, Any]]) -> bool:
8181
)
8282

8383

84+
def _resolve_strategy_run_stage(
85+
*,
86+
dry_run_only: bool,
87+
execution_blocked: bool,
88+
terminal_funding_block: bool,
89+
action_done: bool,
90+
) -> str:
91+
if dry_run_only:
92+
return "DRY_RUN_COMPLETED"
93+
if terminal_funding_block and not action_done:
94+
return "FUNDING_BLOCKED"
95+
if execution_blocked and action_done:
96+
return "PARTIAL_SUBMITTED"
97+
if execution_blocked:
98+
return "EXECUTION_BLOCKED"
99+
return "SUBMITTED" if action_done else "NO_ACTION"
100+
101+
84102
def _series_from_price_history(market_data_port, symbol: str) -> pd.Series:
85103
series = market_data_port.get_price_series(symbol)
86104
index = pd.DatetimeIndex([pd.Timestamp(point.as_of) for point in series.points])
@@ -316,6 +334,12 @@ def run_strategy_cycle(
316334
execution_blocked = bool(blocking_skips)
317335
funding_blocked = _is_terminal_funding_block(blocking_skips)
318336
terminal_funding_block = funding_blocked and not execution_result.action_done
337+
strategy_run_stage = _resolve_strategy_run_stage(
338+
dry_run_only=settings.dry_run_only,
339+
execution_blocked=execution_blocked,
340+
terminal_funding_block=terminal_funding_block,
341+
action_done=execution_result.action_done,
342+
)
319343
result = {
320344
"ok": not execution_blocked,
321345
"api_kind": "unofficial-reverse-engineered",
@@ -326,6 +350,7 @@ def run_strategy_cycle(
326350
"live_trading_enabled": settings.live_trading_enabled,
327351
"session_reused": bool(getattr(client, "session_reused", False)),
328352
"strategy_run_period": run_period,
353+
"strategy_run_stage": strategy_run_stage,
329354
"strategy_run_persisted": strategy_run_persisted,
330355
"portfolio": plan.get("portfolio", {}),
331356
"allocation": plan.get("allocation", {}),
@@ -344,18 +369,8 @@ def run_strategy_cycle(
344369
if strategy_run_persistence_error:
345370
result["strategy_run_persistence_error"] = strategy_run_persistence_error
346371
if persist_strategy_runs:
347-
stage = "DRY_RUN_COMPLETED"
348-
if not settings.dry_run_only:
349-
if terminal_funding_block and not execution_result.action_done:
350-
stage = "FUNDING_BLOCKED"
351-
elif execution_blocked and execution_result.action_done:
352-
stage = "PARTIAL_SUBMITTED"
353-
elif execution_blocked:
354-
stage = "EXECUTION_BLOCKED"
355-
else:
356-
stage = "SUBMITTED" if execution_result.action_done else "NO_ACTION"
357372
completed_state = build_strategy_run_state(
358-
stage=stage,
373+
stage=strategy_run_stage,
359374
account=masked_account,
360375
strategy_profile=strategy_runtime.profile,
361376
strategy_display_name=strategy_runtime.display_name,

notifications/telegram.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
"order_id_suffix": "(订单号: {order_id})",
5656
"no_order_submitted": "未下单: 原因={reason}",
5757
"execution_blocked_banner": "⚠️ 执行阻塞: {reason}",
58+
"execution_blocked_retryable_banner": "⚠️ 执行阻塞,可在窗口内自动重试: {reason}",
59+
"funding_blocked_banner": "⚠️ 资金不足,本周期不再自动重试: {reason}",
5860
"no_rebalance_needed": "✅ 无需调仓",
5961
"no_trades": "✅ 无需调仓",
6062
"no_executable_orders": "无可执行订单",
@@ -135,6 +137,8 @@
135137
"order_id_suffix": " (ID: {order_id})",
136138
"no_order_submitted": "No order submitted: reason={reason}",
137139
"execution_blocked_banner": "⚠️ Execution blocked: {reason}",
140+
"execution_blocked_retryable_banner": "⚠️ Execution blocked; retryable within window: {reason}",
141+
"funding_blocked_banner": "⚠️ Funding blocked; no more automatic retries for this period: {reason}",
138142
"no_rebalance_needed": "✅ No rebalance needed",
139143
"no_trades": "✅ No rebalance needed",
140144
"no_executable_orders": "no executable orders",
@@ -557,7 +561,13 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
557561
if bool(result.get("execution_blocked")):
558562
blocked = list(result.get("execution_blocking_skips") or skipped)
559563
reason = _format_skipped_reason(blocked, translator=translator)
560-
lines.append(translator("execution_blocked_banner", reason=reason))
564+
if bool(result.get("funding_blocked")):
565+
banner_key = "funding_blocked_banner"
566+
elif result.get("execution_block_retryable") is True:
567+
banner_key = "execution_blocked_retryable_banner"
568+
else:
569+
banner_key = "execution_blocked_banner"
570+
lines.append(translator(banner_key, reason=reason))
561571

562572
dashboard_lines = _format_dashboard_lines(portfolio, execution, translator=translator)
563573
if dashboard_lines:

tests/test_rebalance_service.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ def test_run_strategy_cycle_persists_strategy_run_state(monkeypatch):
185185
assert stages == ["ORDERS_PLANNED", "DRY_RUN_COMPLETED"]
186186
assert result["strategy_run_persisted"] is True
187187
assert result["strategy_run_period"]
188+
assert result["strategy_run_stage"] == "DRY_RUN_COMPLETED"
188189
latest_payload = store.writes[-2][1]
189190
assert latest_payload["stage"] == "DRY_RUN_COMPLETED"
190191
assert latest_payload["submitted_orders"][0]["symbol"] == "AAA"
@@ -263,6 +264,7 @@ def test_run_strategy_cycle_persists_live_execution_blocked_without_terminal_sta
263264
assert result["ok"] is False
264265
assert result["execution_blocked"] is True
265266
assert result["execution_block_retryable"] is True
267+
assert result["strategy_run_stage"] == "EXECUTION_BLOCKED"
266268
assert latest_payload["stage"] == "EXECUTION_BLOCKED"
267269

268270

@@ -315,6 +317,7 @@ def evaluate(self, **inputs):
315317
assert result["execution_blocked"] is True
316318
assert result["execution_block_retryable"] is False
317319
assert result["funding_blocked"] is True
320+
assert result["strategy_run_stage"] == "FUNDING_BLOCKED"
318321
assert result["skipped_orders"][0]["reason"] == "insufficient_cash_for_whole_share"
319322
assert latest_payload["stage"] == "FUNDING_BLOCKED"
320323

@@ -383,6 +386,7 @@ def get_quote(self, _account, symbol):
383386
assert result["action_done"] is True
384387
assert result["ok"] is False
385388
assert result["execution_blocked"] is True
389+
assert result["strategy_run_stage"] == "PARTIAL_SUBMITTED"
386390
assert latest_payload["stage"] == "PARTIAL_SUBMITTED"
387391

388392

@@ -525,14 +529,16 @@ def test_render_cycle_summary_formats_skipped_orders_in_unified_english_template
525529
assert "targets:" not in message
526530

527531

528-
def test_render_cycle_summary_shows_execution_blocked_banner():
532+
def test_render_cycle_summary_shows_funding_blocked_banner():
529533
message = render_cycle_summary(
530534
{
531535
"account": "****1234",
532536
"strategy_profile": "mega_cap_leader_rotation_top50_balanced",
533537
"strategy_display_name": "Mega Cap Leader Rotation Top50 Balanced",
534538
"dry_run_only": False,
535539
"execution_blocked": True,
540+
"execution_block_retryable": False,
541+
"funding_blocked": True,
536542
"execution_blocking_skips": [
537543
{"symbol": "NVDA", "reason": "insufficient_cash_for_whole_share"}
538544
],
@@ -553,4 +559,36 @@ def test_render_cycle_summary_shows_execution_blocked_banner():
553559
lang="zh",
554560
)
555561

556-
assert "⚠️ 执行阻塞: 现金不足以买入一整股:NVDA" in message
562+
assert "⚠️ 资金不足,本周期不再自动重试: 现金不足以买入一整股:NVDA" in message
563+
564+
565+
def test_render_cycle_summary_shows_retryable_execution_blocked_banner():
566+
message = render_cycle_summary(
567+
{
568+
"account": "****1234",
569+
"strategy_profile": "mega_cap_leader_rotation_top50_balanced",
570+
"strategy_display_name": "Mega Cap Leader Rotation Top50 Balanced",
571+
"dry_run_only": False,
572+
"execution_blocked": True,
573+
"execution_block_retryable": True,
574+
"execution_blocking_skips": [
575+
{"symbol": "NVDA", "reason": "quote_unavailable"}
576+
],
577+
"portfolio": {
578+
"total_equity": 500.0,
579+
"liquid_cash": 500.0,
580+
"portfolio_rows": (("NVDA",),),
581+
"market_values": {"NVDA": 0.0},
582+
"quantities": {"NVDA": 0},
583+
},
584+
"allocation": {"targets": {"NVDA": 500.0}},
585+
"execution": {},
586+
"submitted_orders": [],
587+
"skipped_orders": [
588+
{"symbol": "NVDA", "reason": "quote_unavailable"}
589+
],
590+
},
591+
lang="en",
592+
)
593+
594+
assert "⚠️ Execution blocked; retryable within window: quote unavailable:NVDA" in message

0 commit comments

Comments
 (0)