Skip to content

Commit c2aeea1

Browse files
committed
Audit timing contracts in IBKR notifications and reports
1 parent a45be31 commit c2aeea1

10 files changed

Lines changed: 142 additions & 12 deletions

application/rebalance_service.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,16 +314,45 @@ def _format_dashboard_text(text) -> str:
314314
return "\n".join(lines)
315315

316316

317-
def _strategy_dashboard_text(signal_metadata) -> str:
317+
def _build_timing_audit_lines(signal_metadata, *, translator) -> list[str]:
318318
metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {}
319319
raw_annotations = metadata.get("execution_annotations")
320320
annotations = raw_annotations if isinstance(raw_annotations, Mapping) else {}
321-
return _format_dashboard_text(
321+
signal_date = str(annotations.get("signal_date") or metadata.get("signal_date") or "").strip()
322+
effective_date = str(annotations.get("effective_date") or metadata.get("effective_date") or "").strip()
323+
contract = str(
324+
annotations.get("execution_timing_contract")
325+
or metadata.get("execution_timing_contract")
326+
or ""
327+
).strip()
328+
if not signal_date and not effective_date and not contract:
329+
return []
330+
label = "⏱ 执行时点" if _translator_uses_zh(translator) else "⏱ Timing"
331+
if signal_date and effective_date:
332+
value = f"{signal_date} -> {effective_date}"
333+
else:
334+
value = signal_date or effective_date or contract
335+
if contract and contract not in value:
336+
value = f"{value} ({contract})" if value else contract
337+
return [f"{label}: {value}"]
338+
339+
340+
def _strategy_dashboard_text(signal_metadata, *, translator) -> str:
341+
metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {}
342+
raw_annotations = metadata.get("execution_annotations")
343+
annotations = raw_annotations if isinstance(raw_annotations, Mapping) else {}
344+
dashboard_text = _format_dashboard_text(
322345
annotations.get("dashboard_text")
323346
or metadata.get("dashboard_text")
324347
or metadata.get("dashboard")
325348
or ""
326349
)
350+
timing_lines = _build_timing_audit_lines(metadata, translator=translator)
351+
if not timing_lines:
352+
return dashboard_text
353+
if not dashboard_text:
354+
return "\n".join(timing_lines)
355+
return f"{dashboard_text}\n" + "\n".join(timing_lines)
327356

328357

329358
def build_dashboard(
@@ -341,7 +370,7 @@ def build_dashboard(
341370
status_icon="🐤",
342371
):
343372
signal_metadata = signal_metadata or {}
344-
strategy_dashboard = _strategy_dashboard_text(signal_metadata)
373+
strategy_dashboard = _strategy_dashboard_text(signal_metadata, translator=translator)
345374
if strategy_dashboard:
346375
return strategy_dashboard
347376
equity = account_values.get("equity", 0)
@@ -550,7 +579,7 @@ def run_strategy_core(
550579
separator=config.separator,
551580
status_icon=signal_metadata.get("status_icon", "🐤"),
552581
)
553-
strategy_dashboard = _strategy_dashboard_text(signal_metadata)
582+
strategy_dashboard = _strategy_dashboard_text(signal_metadata, translator=config.translator)
554583

555584
if target_weights is None:
556585
decision = signal_metadata.get("snapshot_guard_decision")

application/runtime_composer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class IBKRRuntimeComposer:
2626
strategy_display_name: str
2727
strategy_display_name_localized: str
2828
managed_symbols: tuple[str, ...]
29+
signal_effective_after_trading_days: int | None
2930
signal_source: str
3031
status_icon: str
3132
safe_haven: str
@@ -89,6 +90,7 @@ def build_reporting_adapters(self):
8990
strategy_display_name=self.strategy_display_name,
9091
strategy_display_name_localized=self.strategy_display_name_localized,
9192
dry_run=self.dry_run_only,
93+
signal_effective_after_trading_days=self.signal_effective_after_trading_days,
9294
strategy_config_source=self.strategy_config_source,
9395
ib_gateway_host_resolver=self.ib_gateway_host_resolver,
9496
ib_gateway_port=self.ib_gateway_port,
@@ -145,6 +147,7 @@ def build_runtime_composer(
145147
strategy_display_name: str,
146148
strategy_display_name_localized: str,
147149
managed_symbols: tuple[str, ...],
150+
signal_effective_after_trading_days: int | None,
148151
signal_source: str,
149152
status_icon: str,
150153
safe_haven: str,
@@ -188,6 +191,7 @@ def build_runtime_composer(
188191
strategy_display_name=str(strategy_display_name or ""),
189192
strategy_display_name_localized=str(strategy_display_name_localized or ""),
190193
managed_symbols=tuple(managed_symbols),
194+
signal_effective_after_trading_days=signal_effective_after_trading_days,
191195
signal_source=str(signal_source or ""),
192196
status_icon=str(status_icon or ""),
193197
safe_haven=str(safe_haven or ""),

application/runtime_reporting_adapters.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from datetime import datetime, timezone
88
from typing import Any
99

10+
from quant_platform_kit.strategy_contracts import build_execution_timing_metadata
1011
from runtime_logging import RuntimeLogContext
1112

1213

@@ -33,6 +34,7 @@ class IBKRRuntimeReportingAdapters:
3334
strategy_display_name: str = ""
3435
strategy_display_name_localized: str = ""
3536
dry_run: bool = False
37+
signal_effective_after_trading_days: int | None = None
3638
strategy_config_source: str | None = None
3739
ib_gateway_host_resolver: Callable[[], str] | None = None
3840
ib_gateway_port: int = 0
@@ -84,6 +86,11 @@ def build_log_context(self, *, trace_header: str | None = None) -> RuntimeLogCon
8486
)
8587

8688
def build_report(self, log_context: RuntimeLogContext) -> dict[str, Any]:
89+
started_at = self.clock()
90+
timing_summary = build_execution_timing_metadata(
91+
signal_date=started_at,
92+
signal_effective_after_trading_days=self.signal_effective_after_trading_days,
93+
)
8794
return self.report_builder(
8895
platform=log_context.platform,
8996
deploy_target=log_context.deploy_target,
@@ -95,7 +102,7 @@ def build_report(self, log_context: RuntimeLogContext) -> dict[str, Any]:
95102
run_id=log_context.run_id,
96103
run_source="cloud_run",
97104
dry_run=self.dry_run,
98-
started_at=self.clock(),
105+
started_at=started_at,
99106
summary={
100107
"account_ids": list(self.extra_context_fields.get("account_ids") or ()),
101108
"managed_symbols": list(self.managed_symbols),
@@ -104,6 +111,7 @@ def build_report(self, log_context: RuntimeLogContext) -> dict[str, Any]:
104111
"safe_haven": self.safe_haven,
105112
"strategy_display_name": self.strategy_display_name,
106113
"strategy_display_name_localized": self.strategy_display_name_localized,
114+
**timing_summary,
107115
},
108116
diagnostics={
109117
"strategy_config_source": self.strategy_config_source,
@@ -165,6 +173,7 @@ def build_runtime_reporting_adapters(
165173
strategy_display_name: str,
166174
strategy_display_name_localized: str,
167175
dry_run: bool,
176+
signal_effective_after_trading_days: int | None,
168177
strategy_config_source: str | None,
169178
ib_gateway_host_resolver: Callable[[], str],
170179
ib_gateway_port: int,
@@ -204,6 +213,7 @@ def build_runtime_reporting_adapters(
204213
strategy_display_name=str(strategy_display_name or ""),
205214
strategy_display_name_localized=str(strategy_display_name_localized or ""),
206215
dry_run=bool(dry_run),
216+
signal_effective_after_trading_days=signal_effective_after_trading_days,
207217
strategy_config_source=strategy_config_source,
208218
ib_gateway_host_resolver=ib_gateway_host_resolver,
209219
ib_gateway_port=int(ib_gateway_port),

main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ def _env_flag(name: str) -> bool:
196196
else "market_data"
197197
)
198198
STRATEGY_STATUS_ICON = STRATEGY_RUNTIME.status_icon
199+
SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS = getattr(
200+
getattr(STRATEGY_RUNTIME.runtime_adapter, "runtime_policy", None),
201+
"signal_effective_after_trading_days",
202+
None,
203+
)
199204
FEATURE_RUNTIME_PARAMETERS = dict(STRATEGY_RUNTIME.runtime_config)
200205
STRATEGY_RUNTIME_CONFIG = dict(STRATEGY_RUNTIME.merged_runtime_config)
201206
SAFE_HAVEN = str(STRATEGY_RUNTIME_CONFIG.get("safe_haven") or "BIL")
@@ -332,6 +337,7 @@ def build_composer():
332337
strategy_display_name=STRATEGY_DISPLAY_NAME,
333338
strategy_display_name_localized=strategy_display_name,
334339
managed_symbols=resolve_reporting_managed_symbols(),
340+
signal_effective_after_trading_days=SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS,
335341
signal_source=STRATEGY_SIGNAL_SOURCE,
336342
status_icon=STRATEGY_STATUS_ICON,
337343
safe_haven=SAFE_HAVEN,

notifications/renderers.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,16 +303,45 @@ def _format_dashboard_text(text) -> str:
303303
return "\n".join(lines)
304304

305305

306-
def _strategy_dashboard_text(signal_metadata) -> str:
306+
def _build_timing_audit_lines(signal_metadata, *, translator) -> list[str]:
307307
metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {}
308308
raw_annotations = metadata.get("execution_annotations")
309309
annotations = raw_annotations if isinstance(raw_annotations, Mapping) else {}
310-
return _format_dashboard_text(
310+
signal_date = str(annotations.get("signal_date") or metadata.get("signal_date") or "").strip()
311+
effective_date = str(annotations.get("effective_date") or metadata.get("effective_date") or "").strip()
312+
contract = str(
313+
annotations.get("execution_timing_contract")
314+
or metadata.get("execution_timing_contract")
315+
or ""
316+
).strip()
317+
if not signal_date and not effective_date and not contract:
318+
return []
319+
label = "⏱ 执行时点" if _translator_uses_zh(translator) else "⏱ Timing"
320+
if signal_date and effective_date:
321+
value = f"{signal_date} -> {effective_date}"
322+
else:
323+
value = signal_date or effective_date or contract
324+
if contract and contract not in value:
325+
value = f"{value} ({contract})" if value else contract
326+
return [f"{label}: {value}"]
327+
328+
329+
def _strategy_dashboard_text(signal_metadata, *, translator) -> str:
330+
metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {}
331+
raw_annotations = metadata.get("execution_annotations")
332+
annotations = raw_annotations if isinstance(raw_annotations, Mapping) else {}
333+
dashboard_text = _format_dashboard_text(
311334
annotations.get("dashboard_text")
312335
or metadata.get("dashboard_text")
313336
or metadata.get("dashboard")
314337
or ""
315338
)
339+
timing_lines = _build_timing_audit_lines(metadata, translator=translator)
340+
if not timing_lines:
341+
return dashboard_text
342+
if not dashboard_text:
343+
return "\n".join(timing_lines)
344+
return f"{dashboard_text}\n" + "\n".join(timing_lines)
316345

317346

318347
def build_dashboard(
@@ -330,7 +359,7 @@ def build_dashboard(
330359
status_icon="🐤",
331360
):
332361
signal_metadata = signal_metadata or {}
333-
strategy_dashboard = _strategy_dashboard_text(signal_metadata)
362+
strategy_dashboard = _strategy_dashboard_text(signal_metadata, translator=translator)
334363
if strategy_dashboard:
335364
return strategy_dashboard
336365
equity = account_values.get("equity", 0)
@@ -375,6 +404,7 @@ def build_dashboard(
375404
translator("snapshot_as_of_detail", value=_format_text(snapshot_as_of, fallback="<none>")) if snapshot_as_of else None,
376405
]
377406
diagnostics_lines = [f" - {part}" for part in diagnostics if part]
407+
diagnostics_lines.extend(_build_timing_audit_lines(signal_metadata, translator=translator))
378408
diagnostics_text = "\n".join(diagnostics_lines)
379409
localized_status_desc = _localize_notification_text(status_desc, translator=translator)
380410
localized_signal_desc = _localize_notification_text(signal_desc, translator=translator)

strategy_runtime.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
StrategyDecision,
2424
StrategyEntrypoint,
2525
StrategyRuntimeAdapter,
26+
apply_runtime_policy_to_runtime_config,
27+
build_execution_timing_metadata,
2628
build_strategy_context_from_available_inputs,
2729
build_strategy_evaluation_inputs,
2830
)
@@ -193,6 +195,7 @@ def _evaluate_market_data_strategy(
193195
runtime_config = dict(self.runtime_config)
194196
runtime_config.setdefault("translator", translator)
195197
runtime_config.setdefault("pacing_sec", float(pacing_sec))
198+
apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter)
196199
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(ib, required=False)
197200
ctx = self._build_strategy_context(
198201
runtime_adapter=self.runtime_adapter,
@@ -215,6 +218,12 @@ def _evaluate_market_data_strategy(
215218
"managed_symbols": managed_symbols,
216219
"status_icon": self.status_icon,
217220
"dry_run_only": self.runtime_settings.dry_run_only,
221+
**build_execution_timing_metadata(
222+
signal_date=run_as_of,
223+
signal_effective_after_trading_days=(
224+
self.runtime_adapter.runtime_policy.signal_effective_after_trading_days
225+
),
226+
),
218227
}
219228
if portfolio_snapshot is not None:
220229
metadata["portfolio_total_equity"] = float(getattr(portfolio_snapshot, "total_equity", 0.0) or 0.0)
@@ -235,6 +244,7 @@ def _evaluate_value_target_strategy(
235244
) -> StrategyEvaluationResult:
236245
runtime_config = dict(self.runtime_config)
237246
runtime_config.setdefault("translator", translator)
247+
apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter)
238248
portfolio_snapshot = fetch_portfolio_snapshot(ib)
239249
market_inputs = self._build_value_target_market_inputs(
240250
ib=ib,
@@ -265,6 +275,12 @@ def _evaluate_value_target_strategy(
265275
"status_icon": self.status_icon,
266276
"dry_run_only": self.runtime_settings.dry_run_only,
267277
"portfolio_total_equity": float(portfolio_snapshot.total_equity),
278+
**build_execution_timing_metadata(
279+
signal_date=run_as_of,
280+
signal_effective_after_trading_days=(
281+
self.runtime_adapter.runtime_policy.signal_effective_after_trading_days
282+
),
283+
),
268284
}
269285
if safe_haven_symbol:
270286
metadata["safe_haven_symbol"] = str(safe_haven_symbol)

tests/test_rebalance_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ def fake_execute_rebalance(
202202
{
203203
"managed_symbols": ("AAA", "BOXX"),
204204
"status_icon": "📏",
205+
"signal_date": "2026-04-01",
206+
"effective_date": "2026-04-02",
207+
"execution_timing_contract": "next_trading_day",
205208
"execution_annotations": {
206209
"dashboard_text": (
207210
"📌 Strategy portfolio\n"
@@ -237,6 +240,7 @@ def fake_execute_rebalance(
237240
assert "Total assets (strategy symbols + cash): $1,000.00" in observed["messages"][0]
238241
assert "💼 Strategy holdings" in observed["messages"][0]
239242
assert "📏 breadth=60.0%" in observed["messages"][0]
243+
assert "⏱ Timing: 2026-04-01 -> 2026-04-02 (next_trading_day)" in observed["messages"][0]
240244
assert "Target Weights" not in observed["messages"][0]
241245

242246

tests/test_request_handling.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ def test_handle_request_persists_machine_readable_report(strategy_module, monkey
112112
assert observed["report"]["run_source"] == "cloud_run"
113113
assert observed["report"]["account_scope"] == strategy_module.ACCOUNT_GROUP
114114
assert observed["report"]["summary"]["signal_source"] == strategy_module.STRATEGY_SIGNAL_SOURCE
115+
assert observed["report"]["summary"]["execution_timing_contract"] == "next_trading_day"
116+
assert observed["report"]["summary"]["signal_date"]
117+
assert observed["report"]["summary"]["effective_date"]
115118

116119

117120
def test_execution_report_prefers_configured_managed_symbols_without_ranking_pool(strategy_module_factory):
@@ -120,6 +123,7 @@ def test_execution_report_prefers_configured_managed_symbols_without_ranking_poo
120123

121124
assert report["summary"]["managed_symbols"] == ["SOXL", "SOXX", "BOXX", "QQQI", "SPYI"]
122125
assert report["summary"]["safe_haven"] == "BIL"
126+
assert report["summary"]["execution_timing_contract"] == "next_trading_day"
123127

124128

125129
def test_handle_request_enriches_runtime_report_with_cycle_details(strategy_module, monkeypatch):

tests/test_runtime_composer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def fake_reporting_builder(**kwargs):
3434
strategy_display_name="Global ETF Rotation",
3535
strategy_display_name_localized="全球 ETF 轮动",
3636
managed_symbols=("AAA", "BIL"),
37+
signal_effective_after_trading_days=1,
3738
signal_source="market_data",
3839
status_icon="🐤",
3940
safe_haven="BIL",
@@ -79,6 +80,7 @@ def fake_reporting_builder(**kwargs):
7980
assert observed["notification_builder"]["send_message"]
8081
assert observed["reporting_builder"]["account_scope"] == "default"
8182
assert observed["reporting_builder"]["managed_symbols"] == ("AAA", "BIL")
83+
assert observed["reporting_builder"]["signal_effective_after_trading_days"] == 1
8284
assert runtime.connect_ib() == "ib-connection"
8385
assert runtime.portfolio_port_factory("ib").get_portfolio_snapshot() == ("portfolio-snapshot", "ib")
8486
assert runtime.compute_signals == "compute-signals"

0 commit comments

Comments
 (0)