Skip to content

Commit a2de3ff

Browse files
committed
Render strategy dashboard in IBKR notifications
1 parent 9ab7065 commit a2de3ff

7 files changed

Lines changed: 188 additions & 17 deletions

application/rebalance_service.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
56
import json
67
import re
78

@@ -297,6 +298,27 @@ def _resolve_weight_allocation(signal_metadata, *, required: bool) -> dict:
297298
}
298299

299300

301+
def _format_dashboard_text(text) -> str:
302+
lines = [line.rstrip() for line in str(text or "").strip().splitlines()]
303+
while lines and not lines[0].strip():
304+
lines.pop(0)
305+
while lines and not lines[-1].strip():
306+
lines.pop()
307+
return "\n".join(lines)
308+
309+
310+
def _strategy_dashboard_text(signal_metadata) -> str:
311+
metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {}
312+
raw_annotations = metadata.get("execution_annotations")
313+
annotations = raw_annotations if isinstance(raw_annotations, Mapping) else {}
314+
return _format_dashboard_text(
315+
annotations.get("dashboard_text")
316+
or metadata.get("dashboard_text")
317+
or metadata.get("dashboard")
318+
or ""
319+
)
320+
321+
300322
def build_dashboard(
301323
positions,
302324
account_values,
@@ -311,6 +333,10 @@ def build_dashboard(
311333
separator,
312334
status_icon="🐤",
313335
):
336+
signal_metadata = signal_metadata or {}
337+
strategy_dashboard = _strategy_dashboard_text(signal_metadata)
338+
if strategy_dashboard:
339+
return strategy_dashboard
314340
equity = account_values.get("equity", 0)
315341
buying_power = account_values.get("buying_power", 0)
316342
position_lines = []
@@ -320,7 +346,6 @@ def build_dashboard(
320346
market_value = qty * avg
321347
position_lines.append(f" - {symbol}: {qty}股 | ${market_value:,.2f}")
322348
position_text = "\n".join(position_lines) if position_lines else translator("empty_positions")
323-
signal_metadata = signal_metadata or {}
324349
allocation = _resolve_weight_allocation(signal_metadata, required=False)
325350
target_lines = []
326351
for symbol, weight in sorted(allocation.get("targets", {}).items(), key=lambda item: (-item[1], item[0])):
@@ -395,10 +420,15 @@ def _build_compact_message(
395420
translator,
396421
separator: str,
397422
body_lines,
423+
dashboard_text: str = "",
398424
) -> str:
399425
lines = [title]
400426
strategy_name = _format_text(strategy_display_name, fallback="<unknown>")
401427
lines.append(translator("strategy_label", name=strategy_name))
428+
dashboard = _format_dashboard_text(dashboard_text)
429+
if dashboard:
430+
lines.append(separator)
431+
lines.extend(dashboard.splitlines())
402432
status_line = _first_prefixed_line(status_icon, status_desc, translator=translator)
403433
if status_line:
404434
lines.append(status_line)
@@ -452,6 +482,7 @@ def run_strategy_core(
452482
separator=separator,
453483
status_icon=signal_metadata.get("status_icon", "🐤"),
454484
)
485+
strategy_dashboard = _strategy_dashboard_text(signal_metadata)
455486

456487
if target_weights is None:
457488
decision = signal_metadata.get("snapshot_guard_decision")
@@ -491,6 +522,7 @@ def run_strategy_core(
491522
translator=translator,
492523
separator=separator,
493524
body_lines=[no_op_text],
525+
dashboard_text=strategy_dashboard,
494526
)
495527
print(detailed_message, flush=True)
496528
send_tg_message(compact_message)
@@ -566,6 +598,7 @@ def run_strategy_core(
566598
translator=translator,
567599
separator=separator,
568600
body_lines=notification_trade_lines,
601+
dashboard_text=strategy_dashboard,
569602
)
570603
else:
571604
detailed_message = f"{translator('heartbeat_title')}\n{dashboard}\n{separator}\n{translator('no_trades')}"
@@ -578,6 +611,7 @@ def run_strategy_core(
578611
translator=translator,
579612
separator=separator,
580613
body_lines=[translator("no_trades")],
614+
dashboard_text=strategy_dashboard,
581615
)
582616

583617
print(detailed_message, flush=True)

decision_mapper.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ def _derive_status_description(
112112
return _derive_signal_description(decision, runtime_metadata)
113113

114114

115+
def _derive_execution_annotations(
116+
diagnostics: Mapping[str, Any],
117+
runtime_metadata: Mapping[str, Any],
118+
) -> dict[str, Any]:
119+
annotations: dict[str, Any] = {}
120+
raw_runtime_annotations = runtime_metadata.get("execution_annotations")
121+
if isinstance(raw_runtime_annotations, Mapping):
122+
annotations.update(raw_runtime_annotations)
123+
raw_diagnostic_annotations = diagnostics.get("execution_annotations")
124+
if isinstance(raw_diagnostic_annotations, Mapping):
125+
annotations.update(raw_diagnostic_annotations)
126+
return annotations
127+
128+
115129
def map_strategy_decision(
116130
decision: StrategyDecision,
117131
*,
@@ -152,5 +166,16 @@ def map_strategy_decision(
152166
metadata.setdefault("actionable", not no_execute)
153167
if allocation_payload:
154168
metadata.setdefault("allocation", allocation_payload)
169+
execution_annotations = _derive_execution_annotations(diagnostics, runtime_metadata)
170+
if execution_annotations:
171+
metadata.setdefault("execution_annotations", execution_annotations)
172+
dashboard_text = str(
173+
execution_annotations.get("dashboard_text")
174+
or diagnostics.get("dashboard")
175+
or metadata.get("dashboard_text")
176+
or ""
177+
).strip()
178+
if dashboard_text:
179+
metadata.setdefault("dashboard_text", dashboard_text)
155180

156181
return target_weights, signal_desc, is_emergency, status_desc, metadata

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
flask
22
gunicorn
33
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.19
4-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@main
4+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.31
55
pandas
66
numpy
77
requests

strategy_runtime.py

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable, Mapping
4-
from dataclasses import dataclass, field
4+
from dataclasses import dataclass, field, replace
55
from typing import Any
66

77
import pandas as pd
@@ -23,6 +23,8 @@
2323
StrategyDecision,
2424
StrategyEntrypoint,
2525
StrategyRuntimeAdapter,
26+
build_strategy_context_from_available_inputs,
27+
build_strategy_evaluation_inputs,
2628
)
2729
from runtime_config_support import PlatformRuntimeSettings
2830
from strategy_loader import (
@@ -65,6 +67,68 @@ def profile(self) -> str:
6567
def required_inputs(self) -> frozenset[str]:
6668
return frozenset(self.entrypoint.manifest.required_inputs)
6769

70+
def _runtime_adapter_with_portfolio(
71+
self,
72+
runtime_adapter: StrategyRuntimeAdapter,
73+
portfolio_snapshot: Any | None,
74+
) -> StrategyRuntimeAdapter:
75+
if portfolio_snapshot is None or runtime_adapter.portfolio_input_name:
76+
return runtime_adapter
77+
available_inputs = set(runtime_adapter.available_inputs or self.required_inputs)
78+
available_inputs.update(self.required_inputs)
79+
available_inputs.add(_PORTFOLIO_SNAPSHOT_INPUT)
80+
return replace(
81+
runtime_adapter,
82+
available_inputs=frozenset(available_inputs),
83+
portfolio_input_name=_PORTFOLIO_SNAPSHOT_INPUT,
84+
)
85+
86+
def _fetch_portfolio_snapshot_for_context(self, ib, *, required: bool) -> Any | None:
87+
if ib is None and not required:
88+
return None
89+
if required:
90+
return fetch_portfolio_snapshot(ib)
91+
try:
92+
return fetch_portfolio_snapshot(ib)
93+
except Exception as exc:
94+
self.logger(
95+
"strategy_dashboard_portfolio_snapshot_failed | "
96+
f"profile={self.profile} error_type={type(exc).__name__} error={exc}"
97+
)
98+
return None
99+
100+
def _build_strategy_context(
101+
self,
102+
*,
103+
runtime_adapter: StrategyRuntimeAdapter,
104+
as_of: pd.Timestamp,
105+
market_inputs: Mapping[str, Any],
106+
portfolio_snapshot: Any | None,
107+
runtime_config: Mapping[str, Any],
108+
current_holdings,
109+
ib,
110+
):
111+
context_adapter = self._runtime_adapter_with_portfolio(runtime_adapter, portfolio_snapshot)
112+
available_inputs = set(context_adapter.available_inputs or self.required_inputs)
113+
available_inputs.update(self.required_inputs)
114+
evaluation_inputs = build_strategy_evaluation_inputs(
115+
available_inputs=available_inputs,
116+
market_inputs=market_inputs,
117+
portfolio_snapshot=portfolio_snapshot,
118+
)
119+
capabilities = {}
120+
if ib is not None:
121+
capabilities["broker_client"] = ib
122+
return build_strategy_context_from_available_inputs(
123+
entrypoint=self.entrypoint,
124+
runtime_adapter=context_adapter,
125+
as_of=as_of,
126+
available_inputs=evaluation_inputs,
127+
runtime_config=runtime_config,
128+
state={"current_holdings": tuple(current_holdings)},
129+
capabilities=capabilities,
130+
)
131+
68132
def evaluate(
69133
self,
70134
*,
@@ -129,11 +193,12 @@ def _evaluate_market_data_strategy(
129193
runtime_config = dict(self.runtime_config)
130194
runtime_config.setdefault("translator", translator)
131195
runtime_config.setdefault("pacing_sec", float(pacing_sec))
132-
ctx = build_ibkr_strategy_context(
133-
entrypoint=self.entrypoint,
196+
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(ib, required=False)
197+
ctx = self._build_strategy_context(
134198
runtime_adapter=self.runtime_adapter,
135199
as_of=run_as_of,
136200
market_inputs=build_market_history_inputs(historical_close_loader),
201+
portfolio_snapshot=portfolio_snapshot,
137202
runtime_config=runtime_config,
138203
current_holdings=current_holdings,
139204
ib=ib,
@@ -151,6 +216,8 @@ def _evaluate_market_data_strategy(
151216
"status_icon": self.status_icon,
152217
"dry_run_only": self.runtime_settings.dry_run_only,
153218
}
219+
if portfolio_snapshot is not None:
220+
metadata["portfolio_total_equity"] = float(getattr(portfolio_snapshot, "total_equity", 0.0) or 0.0)
154221
if safe_haven_symbol:
155222
metadata["safe_haven_symbol"] = safe_haven_symbol
156223
return StrategyEvaluationResult(decision=decision, metadata=metadata)
@@ -248,14 +315,24 @@ def _evaluate_feature_snapshot_strategy(
248315
translator: Callable[[str], str],
249316
pacing_sec: float,
250317
) -> StrategyEvaluationResult:
251-
del translator, pacing_sec
318+
del pacing_sec
252319
runtime_config_path = self.merged_runtime_config.get("runtime_config_path") or self.runtime_settings.strategy_config_path
253320
benchmark_symbol = str(self.merged_runtime_config.get("benchmark_symbol") or "SPY").strip().upper()
254321
portfolio_snapshot_holder: dict[str, Any] = {}
322+
runtime_config = dict(self.runtime_config)
323+
runtime_config.setdefault("translator", translator)
255324

256325
def build_available_inputs(feature_snapshot) -> Mapping[str, Any]:
257-
if _PORTFOLIO_SNAPSHOT_INPUT in self.required_inputs:
258-
portfolio_snapshot_holder["portfolio_snapshot"] = fetch_portfolio_snapshot(ib)
326+
requires_portfolio = (
327+
_PORTFOLIO_SNAPSHOT_INPUT in self.required_inputs
328+
or self.runtime_adapter.portfolio_input_name == _PORTFOLIO_SNAPSHOT_INPUT
329+
)
330+
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(
331+
ib,
332+
required=requires_portfolio,
333+
)
334+
if portfolio_snapshot is not None:
335+
portfolio_snapshot_holder["portfolio_snapshot"] = portfolio_snapshot
259336
market_inputs: dict[str, Any] = {_FEATURE_SNAPSHOT_INPUT: feature_snapshot}
260337
if _MARKET_HISTORY_INPUT in self.required_inputs:
261338
market_inputs.update(build_market_history_inputs(historical_close_loader))
@@ -274,15 +351,25 @@ def build_available_inputs(feature_snapshot) -> Mapping[str, Any]:
274351
return market_inputs
275352

276353
def build_context(request: FeatureSnapshotContextRequest):
277-
return build_ibkr_strategy_context(
354+
portfolio_snapshot = portfolio_snapshot_holder.get("portfolio_snapshot")
355+
runtime_adapter = self._runtime_adapter_with_portfolio(
356+
request.runtime_adapter,
357+
portfolio_snapshot,
358+
)
359+
available_inputs = dict(request.available_inputs)
360+
if portfolio_snapshot is not None:
361+
available_inputs[_PORTFOLIO_SNAPSHOT_INPUT] = portfolio_snapshot
362+
capabilities = {}
363+
if ib is not None:
364+
capabilities["broker_client"] = ib
365+
return build_strategy_context_from_available_inputs(
278366
entrypoint=request.entrypoint,
279-
runtime_adapter=request.runtime_adapter,
367+
runtime_adapter=runtime_adapter,
280368
as_of=request.as_of,
281-
market_inputs=request.available_inputs,
282-
portfolio_snapshot=portfolio_snapshot_holder.get("portfolio_snapshot"),
369+
available_inputs=available_inputs,
283370
runtime_config=request.runtime_config,
284-
current_holdings=current_holdings,
285-
ib=ib,
371+
state={"current_holdings": tuple(current_holdings)},
372+
capabilities=capabilities,
286373
)
287374

288375
def log_guard_metadata(guard_metadata: Mapping[str, Any]) -> None:
@@ -323,7 +410,7 @@ def build_extra_metadata(
323410
strategy_config_source=self.runtime_settings.strategy_config_source,
324411
dry_run_only=self.runtime_settings.dry_run_only,
325412
),
326-
runtime_config=dict(self.runtime_config),
413+
runtime_config=runtime_config,
327414
merged_runtime_config=self.merged_runtime_config,
328415
as_of=run_as_of,
329416
base_managed_symbols=(),

tests/test_decision_mapper.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def test_map_strategy_decision_maps_weight_positions_and_safe_haven():
1212
diagnostics={
1313
"signal_description": "risk on",
1414
"status_description": "breadth=60.0%",
15+
"execution_annotations": {"dashboard_text": "strategy dashboard"},
1516
},
1617
)
1718

@@ -32,6 +33,8 @@ def test_map_strategy_decision_maps_weight_positions_and_safe_haven():
3233
assert metadata["allocation"]["strategy_symbols"] == ("AAA", "BOXX")
3334
assert metadata["allocation"]["targets"] == {"AAA": 0.6, "BOXX": 0.4}
3435
assert metadata["allocation"]["positions"][1]["role"] == "safe_haven"
36+
assert metadata["execution_annotations"]["dashboard_text"] == "strategy dashboard"
37+
assert metadata["dashboard_text"] == "strategy dashboard"
3538
assert "target_mode" not in metadata
3639

3740

tests/test_rebalance_service.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,16 @@ def fake_execute_rebalance(
202202
{
203203
"managed_symbols": ("AAA", "BOXX"),
204204
"status_icon": "📏",
205+
"execution_annotations": {
206+
"dashboard_text": (
207+
"📌 Strategy portfolio\n"
208+
" - Total assets (strategy symbols + cash): $1,000.00\n"
209+
" - Buying power: $500.00\n"
210+
"💼 Strategy holdings\n"
211+
" - AAA: $0.00 / 0 shares\n"
212+
" - BOXX: $0.00 / 0 shares"
213+
)
214+
},
205215
"allocation": _weight_allocation(
206216
{"AAA": 0.9, "BOXX": 0.1},
207217
risk_symbols=("AAA",),
@@ -223,6 +233,9 @@ def fake_execute_rebalance(
223233
assert "Account Summary" not in observed["messages"][0]
224234
assert "Current Positions" not in observed["messages"][0]
225235
assert "Execution Summary" not in observed["messages"][0]
236+
assert "📌 Strategy portfolio" in observed["messages"][0]
237+
assert "Total assets (strategy symbols + cash): $1,000.00" in observed["messages"][0]
238+
assert "💼 Strategy holdings" in observed["messages"][0]
226239
assert "📏 breadth=60.0%" in observed["messages"][0]
227240
assert "Target Weights" not in observed["messages"][0]
228241

0 commit comments

Comments
 (0)