Skip to content

Commit 6fdc28f

Browse files
committed
Annotate snapshot-priced IB dry runs
1 parent c505a5b commit 6fdc28f

7 files changed

Lines changed: 168 additions & 14 deletions

File tree

application/execution_service.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,10 @@ def get_market_prices(
1818
symbols,
1919
*,
2020
fetch_quote_snapshots,
21-
dry_run_only: bool = False,
22-
snapshot_price_fallbacks: dict[str, float] | None = None,
2321
):
2422
"""Fetch market prices for multiple symbols in one pass."""
2523
quotes = fetch_quote_snapshots(ib, symbols)
26-
prices = {symbol: quote.last_price for symbol, quote in quotes.items()}
27-
if dry_run_only and snapshot_price_fallbacks:
28-
for symbol in symbols:
29-
normalized = str(symbol).strip().upper()
30-
if normalized in prices:
31-
continue
32-
fallback_price = snapshot_price_fallbacks.get(normalized)
33-
if fallback_price and float(fallback_price) > 0:
34-
prices[normalized] = float(fallback_price)
35-
return prices
24+
return {symbol: quote.last_price for symbol, quote in quotes.items()}
3625

3726

3827
def check_order_submitted(report, *, translator):
@@ -192,6 +181,38 @@ def _display_text(value: Any, *, fallback: str) -> str:
192181
return text or fallback
193182

194183

184+
def _apply_snapshot_price_fallbacks(
185+
prices: dict[str, float],
186+
symbols,
187+
*,
188+
dry_run_only: bool,
189+
snapshot_price_fallbacks: dict[str, float] | None,
190+
) -> tuple[dict[str, float], tuple[str, ...]]:
191+
if not dry_run_only or not snapshot_price_fallbacks:
192+
return dict(prices), ()
193+
resolved = dict(prices)
194+
fallback_symbols: list[str] = []
195+
for symbol in symbols:
196+
normalized = str(symbol).strip().upper()
197+
if normalized in resolved:
198+
continue
199+
fallback_price = snapshot_price_fallbacks.get(normalized)
200+
if fallback_price and float(fallback_price) > 0:
201+
resolved[normalized] = float(fallback_price)
202+
fallback_symbols.append(normalized)
203+
return resolved, tuple(fallback_symbols)
204+
205+
206+
def _format_symbol_preview(symbols: tuple[str, ...], *, limit: int = 3) -> str:
207+
if not symbols:
208+
return ""
209+
shown = [str(symbol).strip().upper() for symbol in symbols[:limit]]
210+
remaining = len(symbols) - len(shown)
211+
if remaining > 0:
212+
shown.append(f"+{remaining}")
213+
return ",".join(shown)
214+
215+
195216
def _resolve_execution_lock_path(
196217
*,
197218
strategy_profile: str | None,
@@ -364,6 +385,10 @@ def execute_rebalance(
364385
"residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0),
365386
"current_stock_weight": 0.0,
366387
"current_safe_haven_weight": 0.0,
388+
"price_source_mode": "market_quote",
389+
"snapshot_price_fallback_used": False,
390+
"snapshot_price_fallback_symbols": [],
391+
"snapshot_price_fallback_count": 0,
367392
"lock_path": None,
368393
}
369394
if equity <= 0:
@@ -389,9 +414,18 @@ def execute_rebalance(
389414
ib,
390415
all_symbols,
391416
fetch_quote_snapshots=fetch_quote_snapshots,
417+
)
418+
prices, snapshot_price_fallback_symbols = _apply_snapshot_price_fallbacks(
419+
prices,
420+
all_symbols,
392421
dry_run_only=dry_run_only,
393422
snapshot_price_fallbacks=snapshot_price_fallbacks,
394423
)
424+
execution_summary["snapshot_price_fallback_used"] = bool(snapshot_price_fallback_symbols)
425+
execution_summary["snapshot_price_fallback_symbols"] = list(snapshot_price_fallback_symbols)
426+
execution_summary["snapshot_price_fallback_count"] = len(snapshot_price_fallback_symbols)
427+
if snapshot_price_fallback_symbols:
428+
execution_summary["price_source_mode"] = "mixed_market_quote_snapshot_close"
395429

396430
current_mv = {}
397431
for symbol in all_symbols:
@@ -459,6 +493,14 @@ def execute_rebalance(
459493
]
460494
)
461495
)
496+
if snapshot_price_fallback_symbols:
497+
trade_logs.append(
498+
translator(
499+
"dry_run_snapshot_prices",
500+
count=len(snapshot_price_fallback_symbols),
501+
symbols=_format_symbol_preview(snapshot_price_fallback_symbols),
502+
)
503+
)
462504
trade_logs.extend(_format_target_lines(target_weights, current_mv, equity, translator=translator))
463505

464506
missing_price_symbols: list[str] = []

application/rebalance_service.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def run_strategy_core(
105105
translator,
106106
separator,
107107
reconciliation_output_path=None,
108+
result_hook=None,
108109
):
109110
ib = None
110111
try:
@@ -161,6 +162,17 @@ def run_strategy_core(
161162
message = f"{translator('heartbeat_title')}\n{dashboard}\n{separator}\n{no_op_text}"
162163
send_tg_message(message)
163164
print(message, flush=True)
165+
if callable(result_hook):
166+
result_hook(
167+
{
168+
"result": "OK - heartbeat",
169+
"signal_metadata": dict(signal_metadata or {}),
170+
"target_weights": None,
171+
"execution_summary": None,
172+
"reconciliation_record": dict(record),
173+
"reconciliation_record_path": str(record_path),
174+
}
175+
)
164176
return "OK - heartbeat"
165177

166178
execution_result = execute_rebalance(
@@ -213,6 +225,17 @@ def run_strategy_core(
213225

214226
send_tg_message(message)
215227
print(message, flush=True)
228+
if callable(result_hook):
229+
result_hook(
230+
{
231+
"result": "OK - executed",
232+
"signal_metadata": dict(signal_metadata or {}),
233+
"target_weights": dict(target_weights or {}),
234+
"execution_summary": dict(execution_summary or {}),
235+
"reconciliation_record": dict(record),
236+
"reconciliation_record_path": str(record_path),
237+
}
238+
)
216239
return "OK - executed"
217240
finally:
218241
if ib is not None and ib.isConnected():

application/reconciliation_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def build_reconciliation_record(
7070
"cash_reserve_dollars": execution_summary.get("cash_reserve_dollars"),
7171
"current_stock_weight": execution_summary.get("current_stock_weight"),
7272
"current_safe_haven_weight": execution_summary.get("current_safe_haven_weight"),
73+
"price_source_mode": execution_summary.get("price_source_mode"),
74+
"snapshot_price_fallback_used": execution_summary.get("snapshot_price_fallback_used"),
75+
"snapshot_price_fallback_count": execution_summary.get("snapshot_price_fallback_count"),
76+
"snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols") or [],
7377
"execution_status": execution_summary.get("execution_status") or ("no_op" if no_op_reason else "executed"),
7478
"lock_path": execution_summary.get("lock_path"),
7579
"no_op_reason": no_op_reason or execution_summary.get("no_op_reason"),

main.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def get_ib_port():
204204
instance_name=RUNTIME_SETTINGS.ib_gateway_instance_name,
205205
extra_fields={"account_ids": list(ACCOUNT_IDS)},
206206
)
207+
LAST_CYCLE_DETAILS: dict[str, object] = {}
207208

208209
def t(key, **kwargs):
209210
return build_translator(NOTIFY_LANG)(key, **kwargs)
@@ -412,7 +413,9 @@ def execute_rebalance(
412413
# Main strategy runner
413414
# ---------------------------------------------------------------------------
414415
def run_strategy_core():
415-
return run_rebalance_cycle(
416+
global LAST_CYCLE_DETAILS
417+
cycle_details: dict[str, object] = {}
418+
result = run_rebalance_cycle(
416419
connect_ib=connect_ib,
417420
get_current_portfolio=get_current_portfolio,
418421
compute_signals=compute_signals,
@@ -421,7 +424,10 @@ def run_strategy_core():
421424
translator=t,
422425
separator=SEPARATOR,
423426
reconciliation_output_path=RECONCILIATION_OUTPUT_PATH,
427+
result_hook=lambda payload: cycle_details.update(payload or {}),
424428
)
429+
LAST_CYCLE_DETAILS = cycle_details
430+
return result
425431

426432

427433
# ---------------------------------------------------------------------------
@@ -432,6 +438,8 @@ def handle_request():
432438
if request.method == "GET":
433439
return "OK - use POST to execute strategy", 200
434440

441+
global LAST_CYCLE_DETAILS
442+
LAST_CYCLE_DETAILS = {}
435443
log_context = build_request_log_context()
436444
report = build_execution_report(log_context)
437445
try:
@@ -459,10 +467,38 @@ def handle_request():
459467
message="Starting strategy execution",
460468
)
461469
result = run_strategy_core()
470+
cycle_details = dict(LAST_CYCLE_DETAILS or {})
471+
execution_summary = dict(cycle_details.get("execution_summary") or {})
472+
reconciliation_record = dict(cycle_details.get("reconciliation_record") or {})
462473
finalize_runtime_report(
463474
report,
464475
status="ok",
465-
diagnostics={"result": result},
476+
summary={
477+
"result": result,
478+
"execution_status": execution_summary.get("execution_status") or reconciliation_record.get("execution_status"),
479+
"no_op_reason": execution_summary.get("no_op_reason") or reconciliation_record.get("no_op_reason"),
480+
"orders_submitted_count": len(execution_summary.get("orders_submitted") or reconciliation_record.get("orders_submitted") or ()),
481+
"orders_skipped_count": len(execution_summary.get("orders_skipped") or reconciliation_record.get("orders_skipped") or ()),
482+
"snapshot_price_fallback_used": bool(
483+
execution_summary.get("snapshot_price_fallback_used")
484+
or reconciliation_record.get("snapshot_price_fallback_used")
485+
),
486+
"snapshot_price_fallback_count": int(
487+
execution_summary.get("snapshot_price_fallback_count")
488+
or reconciliation_record.get("snapshot_price_fallback_count")
489+
or 0
490+
),
491+
},
492+
diagnostics={
493+
"result": result,
494+
"price_source_mode": execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"),
495+
"snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols")
496+
or reconciliation_record.get("snapshot_price_fallback_symbols")
497+
or [],
498+
},
499+
artifacts={
500+
"reconciliation_record_path": cycle_details.get("reconciliation_record_path"),
501+
},
466502
)
467503
log_runtime_event(
468504
log_context,

notifications/telegram.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"snapshot_file_ts_detail": "快照文件时间={value}",
2828
"snapshot_path_detail": "快照路径={value}",
2929
"config_source_detail": "配置来源={value}",
30+
"dry_run_snapshot_prices": "🧪 dry-run估价: 使用快照收盘价 {count}个标的 ({symbols})",
3031
"trade_date_detail": "交易日={value}",
3132
"target_diff": "目标差异 {symbol}: 当前={current} 目标={target} 变化={delta}",
3233
"pending_orders_detected": "检测到未完成订单: profile={profile} symbols={symbols}",
@@ -71,6 +72,7 @@
7172
"snapshot_file_ts_detail": "snapshot_file_ts={value}",
7273
"snapshot_path_detail": "snapshot_path={value}",
7374
"config_source_detail": "config_source={value}",
75+
"dry_run_snapshot_prices": "🧪 dry-run pricing: snapshot close for {count} symbols ({symbols})",
7476
"trade_date_detail": "trade_date={value}",
7577
"target_diff": "target_diff {symbol}: current={current} target={target} delta={delta}",
7678
"pending_orders_detected": "pending_orders_detected profile={profile} symbols={symbols}",

tests/test_execution_service.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def translate(key, **kwargs):
2222
"same_day_fills_detected": "same_day_fills_detected profile={profile} mode={mode} symbols={symbols} trade_date={trade_date}",
2323
"same_day_execution_locked": "same_day_execution_locked profile={profile} mode={mode} trade_date={trade_date} snapshot_date={snapshot_date} target_hash={target_hash} lock_path={lock_path}",
2424
"execution_lock_acquired": "execution_lock_acquired mode={mode} trade_date={trade_date} snapshot_date={snapshot_date} lock_path={lock_path}",
25+
"dry_run_snapshot_prices": "dry_run_snapshot_prices count={count} symbols={symbols}",
2526
"no_equity": "❌ No equity",
2627
}
2728
template = templates[key]
@@ -404,5 +405,10 @@ def accountValues(self):
404405

405406
assert summary["execution_status"] == "executed"
406407
assert len(summary["orders_submitted"]) == 2
408+
assert summary["snapshot_price_fallback_used"] is True
409+
assert summary["snapshot_price_fallback_count"] == 2
410+
assert set(summary["snapshot_price_fallback_symbols"]) == {"VOO", "BOXX"}
411+
assert summary["price_source_mode"] == "mixed_market_quote_snapshot_close"
412+
assert any(log.startswith("dry_run_snapshot_prices count=2") for log in trade_logs)
407413
assert any(log.startswith("DRY_RUN buy VOO") for log in trade_logs)
408414
assert any(log.startswith("DRY_RUN buy BOXX") for log in trade_logs)

tests/test_request_handling.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,47 @@ def test_handle_request_persists_machine_readable_report(strategy_module, monkey
8383
assert observed["report"]["summary"]["signal_source"] == strategy_module.STRATEGY_SIGNAL_SOURCE
8484

8585

86+
def test_handle_request_enriches_runtime_report_with_cycle_details(strategy_module, monkeypatch):
87+
observed = {}
88+
89+
monkeypatch.setattr(strategy_module, "build_run_id", lambda: "run-001")
90+
monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True)
91+
92+
def fake_run_strategy_core():
93+
strategy_module.LAST_CYCLE_DETAILS = {
94+
"execution_summary": {
95+
"execution_status": "executed",
96+
"orders_submitted": [{"symbol": "AAA"}],
97+
"orders_skipped": [],
98+
"price_source_mode": "mixed_market_quote_snapshot_close",
99+
"snapshot_price_fallback_used": True,
100+
"snapshot_price_fallback_count": 1,
101+
"snapshot_price_fallback_symbols": ["AAA"],
102+
},
103+
"reconciliation_record_path": "/tmp/reconciliation.json",
104+
}
105+
return "OK - executed"
106+
107+
monkeypatch.setattr(strategy_module, "run_strategy_core", fake_run_strategy_core)
108+
monkeypatch.setattr(
109+
strategy_module,
110+
"persist_execution_report",
111+
lambda report: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json",
112+
)
113+
114+
with strategy_module.app.test_request_context("/", method="POST"):
115+
body, status = strategy_module.handle_request()
116+
117+
assert status == 200
118+
assert body == "OK - executed"
119+
assert observed["report"]["summary"]["execution_status"] == "executed"
120+
assert observed["report"]["summary"]["snapshot_price_fallback_used"] is True
121+
assert observed["report"]["summary"]["snapshot_price_fallback_count"] == 1
122+
assert observed["report"]["diagnostics"]["price_source_mode"] == "mixed_market_quote_snapshot_close"
123+
assert observed["report"]["diagnostics"]["snapshot_price_fallback_symbols"] == ["AAA"]
124+
assert observed["report"]["artifacts"]["reconciliation_record_path"] == "/tmp/reconciliation.json"
125+
126+
86127
def test_handle_request_post_returns_market_closed_when_schedule_empty(strategy_module, monkeypatch):
87128
observed = {}
88129

0 commit comments

Comments
 (0)