Skip to content

Commit d320071

Browse files
authored
Mark Firstrade execution blockers as retryable
Classify execution blockers and persist them as non-terminal run states.
1 parent 3a208bf commit d320071

5 files changed

Lines changed: 182 additions & 17 deletions

File tree

application/execution_service.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -311,17 +311,28 @@ def execute_value_target_plan(
311311
buy_budget = min(buy_budget, order_notional_cap)
312312
quantity = _floor_quantity(buy_budget / price)
313313
if quantity <= 0:
314-
skipped.append(
315-
{
316-
"symbol": symbol,
317-
"reason": "buy_quantity_zero",
318-
**(
319-
{"max_order_notional_usd": round(order_notional_cap, 2)}
320-
if order_notional_cap is not None
321-
else {}
322-
),
323-
}
324-
)
314+
if order_notional_cap is None and investable_cash < price:
315+
skipped.append(
316+
{
317+
"symbol": symbol,
318+
"reason": "insufficient_cash_for_whole_share",
319+
"price": round(price, 2),
320+
"investable_cash": round(investable_cash, 2),
321+
"required_cash_for_one_share": round(price, 2),
322+
}
323+
)
324+
else:
325+
skipped.append(
326+
{
327+
"symbol": symbol,
328+
"reason": "buy_quantity_zero",
329+
**(
330+
{"max_order_notional_usd": round(order_notional_cap, 2)}
331+
if order_notional_cap is not None
332+
else {}
333+
),
334+
}
335+
)
325336
continue
326337
submitted.append(
327338
_submit_order(

application/rebalance_service.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@
4545

4646
LIMIT_SELL_DISCOUNT = 0.995
4747
LIMIT_BUY_PREMIUM = 1.005
48+
EXECUTION_BLOCKING_SKIP_REASONS = frozenset(
49+
{
50+
"buy_quantity_zero",
51+
"insufficient_cash_for_whole_share",
52+
"quote_unavailable",
53+
"sell_quantity_zero",
54+
}
55+
)
4856

4957

5058
def _utcnow() -> datetime:
@@ -55,6 +63,14 @@ def get_project_id() -> str | None:
5563
return os.getenv("GOOGLE_CLOUD_PROJECT")
5664

5765

66+
def _execution_blocking_skips(skipped_orders: list[dict[str, Any]]) -> list[dict[str, Any]]:
67+
return [
68+
dict(item)
69+
for item in skipped_orders
70+
if str(item.get("reason") or "") in EXECUTION_BLOCKING_SKIP_REASONS
71+
]
72+
73+
5874
def _series_from_price_history(market_data_port, symbol: str) -> pd.Series:
5975
series = market_data_port.get_price_series(symbol)
6076
index = pd.DatetimeIndex([pd.Timestamp(point.as_of) for point in series.points])
@@ -284,8 +300,12 @@ def run_strategy_cycle(
284300
max_order_notional_usd=settings.max_order_notional_usd,
285301
safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd,
286302
)
303+
submitted_orders = list(execution_result.submitted_orders)
304+
skipped_orders = list(execution_result.skipped_orders)
305+
blocking_skips = _execution_blocking_skips(skipped_orders)
306+
execution_blocked = bool(blocking_skips)
287307
result = {
288-
"ok": True,
308+
"ok": not execution_blocked,
289309
"api_kind": "unofficial-reverse-engineered",
290310
"account": mask_account_id(account),
291311
"strategy_profile": strategy_runtime.profile,
@@ -298,16 +318,25 @@ def run_strategy_cycle(
298318
"portfolio": plan.get("portfolio", {}),
299319
"allocation": plan.get("allocation", {}),
300320
"execution": plan.get("execution", {}),
301-
"submitted_orders": list(execution_result.submitted_orders),
302-
"skipped_orders": list(execution_result.skipped_orders),
321+
"submitted_orders": submitted_orders,
322+
"skipped_orders": skipped_orders,
303323
"action_done": execution_result.action_done,
304324
}
325+
if execution_blocked:
326+
result["execution_blocked"] = True
327+
result["execution_blocking_skips"] = blocking_skips
328+
result["error"] = "Strategy execution blocked; see execution_blocking_skips."
305329
if strategy_run_persistence_error:
306330
result["strategy_run_persistence_error"] = strategy_run_persistence_error
307331
if persist_strategy_runs:
308332
stage = "DRY_RUN_COMPLETED"
309333
if not settings.dry_run_only:
310-
stage = "SUBMITTED" if execution_result.action_done else "NO_ACTION"
334+
if execution_blocked and execution_result.action_done:
335+
stage = "PARTIAL_SUBMITTED"
336+
elif execution_blocked:
337+
stage = "EXECUTION_BLOCKED"
338+
else:
339+
stage = "SUBMITTED" if execution_result.action_done else "NO_ACTION"
311340
completed_state = build_strategy_run_state(
312341
stage=stage,
313342
account=masked_account,

notifications/telegram.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"order_price_suffix": " @ ${price}",
5555
"order_id_suffix": "(订单号: {order_id})",
5656
"no_order_submitted": "未下单: 原因={reason}",
57+
"execution_blocked_banner": "⚠️ 执行阻塞: {reason}",
5758
"no_rebalance_needed": "✅ 无需调仓",
5859
"no_trades": "✅ 无需调仓",
5960
"no_executable_orders": "无可执行订单",
@@ -94,6 +95,7 @@
9495
"skip_reason_quote_unavailable": "无法获取报价",
9596
"skip_reason_sell_quantity_zero": "卖出股数为0",
9697
"skip_reason_buy_quantity_zero": "买入股数为0",
98+
"skip_reason_insufficient_cash_for_whole_share": "现金不足以买入一整股",
9799
"skip_reason_unknown": "未知原因",
98100
},
99101
"en": {
@@ -132,6 +134,7 @@
132134
"order_price_suffix": " @ ${price}",
133135
"order_id_suffix": " (ID: {order_id})",
134136
"no_order_submitted": "No order submitted: reason={reason}",
137+
"execution_blocked_banner": "⚠️ Execution blocked: {reason}",
135138
"no_rebalance_needed": "✅ No rebalance needed",
136139
"no_trades": "✅ No rebalance needed",
137140
"no_executable_orders": "no executable orders",
@@ -172,6 +175,7 @@
172175
"skip_reason_quote_unavailable": "quote unavailable",
173176
"skip_reason_sell_quantity_zero": "sell quantity rounds to 0",
174177
"skip_reason_buy_quantity_zero": "buy quantity rounds to 0",
178+
"skip_reason_insufficient_cash_for_whole_share": "insufficient cash for one whole share",
175179
"skip_reason_unknown": "unknown reason",
176180
},
177181
}
@@ -550,6 +554,10 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
550554
lines.append(translator("account_label", account=account))
551555
if dry_run_only:
552556
lines.append(translator("dry_run_banner"))
557+
if bool(result.get("execution_blocked")):
558+
blocked = list(result.get("execution_blocking_skips") or skipped)
559+
reason = _format_skipped_reason(blocked, translator=translator)
560+
lines.append(translator("execution_blocked_banner", reason=reason))
553561

554562
dashboard_lines = _format_dashboard_lines(portfolio, execution, translator=translator)
555563
if dashboard_lines:

tests/test_execution_service.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,36 @@ def test_execute_value_target_plan_has_no_default_order_notional_cap():
142142
assert execution_port.orders[0].metadata == {}
143143

144144

145+
def test_execute_value_target_plan_reports_insufficient_cash_for_whole_share():
146+
execution_port = FakeExecutionPort()
147+
result = execute_value_target_plan(
148+
plan={
149+
"allocation": {"targets": {"SPY": 500.0}},
150+
"portfolio": {
151+
"market_values": {"SPY": 0.0},
152+
"sellable_quantities": {},
153+
"liquid_cash": 50.0,
154+
},
155+
"execution": {"current_min_trade": 1.0, "investable_cash": 50.0},
156+
},
157+
market_data_port=FakeMarketDataPort({"SPY": 100.0}),
158+
execution_port=execution_port,
159+
dry_run_only=True,
160+
)
161+
162+
assert result.action_done is False
163+
assert execution_port.orders == []
164+
assert result.skipped_orders == (
165+
{
166+
"symbol": "SPY",
167+
"reason": "insufficient_cash_for_whole_share",
168+
"price": 100.0,
169+
"investable_cash": 50.0,
170+
"required_cash_for_one_share": 100.0,
171+
},
172+
)
173+
174+
145175
def test_execute_value_target_plan_leaves_small_safe_haven_target_as_cash():
146176
execution_port = FakeExecutionPort()
147177
plan = {

tests/test_rebalance_service.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ def fake_client_factory(*args, **kwargs):
235235
assert store.writes == []
236236

237237

238-
def test_run_strategy_cycle_persists_live_no_action_without_duplicate_terminal_stage(monkeypatch):
238+
def test_run_strategy_cycle_persists_live_execution_blocked_without_terminal_stage(monkeypatch):
239239
store = FakeStateStore()
240240
settings = _runtime_settings_with_persistence(
241241
dry_run_only=False,
@@ -260,7 +260,63 @@ def test_run_strategy_cycle_persists_live_no_action_without_duplicate_terminal_s
260260

261261
latest_payload = store.writes[-2][1]
262262
assert result["action_done"] is False
263-
assert latest_payload["stage"] == "NO_ACTION"
263+
assert result["ok"] is False
264+
assert result["execution_blocked"] is True
265+
assert latest_payload["stage"] == "EXECUTION_BLOCKED"
266+
267+
268+
def test_run_strategy_cycle_persists_live_partial_submission_as_non_terminal(monkeypatch):
269+
store = FakeStateStore()
270+
settings = _runtime_settings_with_persistence(
271+
dry_run_only=False,
272+
live_trading_enabled=True,
273+
live_order_ack=True,
274+
persist_strategy_runs=True,
275+
max_order_notional_usd=1000.0,
276+
)
277+
278+
class PartialRuntime(FakeStrategyRuntime):
279+
managed_symbols = ("AAA", "BBB")
280+
281+
def evaluate(self, **inputs):
282+
assert "portfolio_snapshot" in inputs
283+
return SimpleNamespace(
284+
decision=StrategyDecision(
285+
positions=(
286+
PositionTarget(symbol="AAA", target_value=50.0, role="risk"),
287+
PositionTarget(symbol="BBB", target_value=150.0, role="risk"),
288+
),
289+
diagnostics={"execution_annotations": {"trade_threshold_value": 1.0}},
290+
),
291+
metadata={"strategy_profile": self.profile},
292+
)
293+
294+
class PartialClient(FakeFirstradeClient):
295+
def get_balances(self, _account):
296+
return {"total_value": "100.00", "cash": "60.00", "buying_power": "60.00"}
297+
298+
def get_quote(self, _account, symbol):
299+
prices = {"AAA": "10.00", "BBB": "100.00"}
300+
return {"symbol": symbol, "last": prices[symbol], "bid": "9.90", "ask": "10.10"}
301+
302+
monkeypatch.setattr(
303+
"application.rebalance_service.load_strategy_runtime",
304+
lambda *_args, **_kwargs: PartialRuntime(),
305+
)
306+
307+
result = run_strategy_cycle(
308+
runtime_settings=settings,
309+
credentials=FirstradeCredentials(username="user", password="pass"),
310+
client_factory=PartialClient,
311+
state_store=store,
312+
env_reader=lambda _name, default=None: default,
313+
)
314+
315+
latest_payload = store.writes[-2][1]
316+
assert result["action_done"] is True
317+
assert result["ok"] is False
318+
assert result["execution_blocked"] is True
319+
assert latest_payload["stage"] == "PARTIAL_SUBMITTED"
264320

265321

266322
def test_render_cycle_summary_formats_skipped_orders_in_unified_chinese_template():
@@ -400,3 +456,34 @@ def test_render_cycle_summary_formats_skipped_orders_in_unified_english_template
400456
assert "信号" not in message
401457
assert "profile:" not in message
402458
assert "targets:" not in message
459+
460+
461+
def test_render_cycle_summary_shows_execution_blocked_banner():
462+
message = render_cycle_summary(
463+
{
464+
"account": "****1234",
465+
"strategy_profile": "mega_cap_leader_rotation_top50_balanced",
466+
"strategy_display_name": "Mega Cap Leader Rotation Top50 Balanced",
467+
"dry_run_only": False,
468+
"execution_blocked": True,
469+
"execution_blocking_skips": [
470+
{"symbol": "NVDA", "reason": "insufficient_cash_for_whole_share"}
471+
],
472+
"portfolio": {
473+
"total_equity": 50.0,
474+
"liquid_cash": 50.0,
475+
"portfolio_rows": (("NVDA",),),
476+
"market_values": {"NVDA": 0.0},
477+
"quantities": {"NVDA": 0},
478+
},
479+
"allocation": {"targets": {"NVDA": 500.0}},
480+
"execution": {},
481+
"submitted_orders": [],
482+
"skipped_orders": [
483+
{"symbol": "NVDA", "reason": "insufficient_cash_for_whole_share"}
484+
],
485+
},
486+
lang="zh",
487+
)
488+
489+
assert "⚠️ 执行阻塞: 现金不足以买入一整股:NVDA" in message

0 commit comments

Comments
 (0)