Skip to content

Commit 14b5f5c

Browse files
committed
Read allocation intent in IBKR execution path
1 parent f4496da commit 14b5f5c

4 files changed

Lines changed: 172 additions & 45 deletions

File tree

application/execution_service.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,27 @@ def _display_text(value: Any, *, fallback: str) -> str:
181181
return text or fallback
182182

183183

184+
def _resolve_weight_allocation(signal_metadata: dict[str, Any] | None) -> dict[str, Any]:
185+
metadata = dict(signal_metadata or {})
186+
allocation = dict(metadata.get("allocation") or {})
187+
if not allocation:
188+
raise ValueError("IBKR execution requires signal_metadata.allocation")
189+
if allocation.get("target_mode") != "weight":
190+
raise ValueError("IBKR execution requires allocation.target_mode=weight")
191+
return {
192+
"strategy_symbols": tuple(str(symbol).strip().upper() for symbol in allocation.get("strategy_symbols", ())),
193+
"risk_symbols": tuple(str(symbol).strip().upper() for symbol in allocation.get("risk_symbols", ())),
194+
"income_symbols": tuple(str(symbol).strip().upper() for symbol in allocation.get("income_symbols", ())),
195+
"safe_haven_symbols": tuple(
196+
str(symbol).strip().upper() for symbol in allocation.get("safe_haven_symbols", ())
197+
),
198+
"targets": {
199+
str(symbol).strip().upper(): float(weight)
200+
for symbol, weight in dict(allocation.get("targets") or {}).items()
201+
},
202+
}
203+
204+
184205
def _apply_snapshot_price_fallbacks(
185206
prices: dict[str, float],
186207
symbols,
@@ -358,10 +379,15 @@ def execute_rebalance(
358379
return_summary=False,
359380
):
360381
"""Execute trades to reach target weights."""
382+
del target_weights
361383
signal_metadata = signal_metadata or {}
384+
allocation = _resolve_weight_allocation(signal_metadata)
385+
target_weights = dict(allocation["targets"])
386+
strategy_symbols = tuple(allocation["strategy_symbols"])
362387
trade_date = str(signal_metadata.get("trade_date") or "").strip() or None
363388
snapshot_date = _normalize_date_like(signal_metadata.get("snapshot_as_of"))
364-
safe_haven_symbol = str(signal_metadata.get("safe_haven_symbol") or "").strip().upper() or None
389+
safe_haven_symbols = tuple(allocation["safe_haven_symbols"])
390+
safe_haven_symbol = safe_haven_symbols[0] if safe_haven_symbols else None
365391
equity = account_values.get("equity", 0)
366392
execution_summary = {
367393
"mode": "dry_run" if dry_run_only else "paper",

application/rebalance_service.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,28 @@ def _build_notification_trade_lines(
150150
return lines
151151

152152

153+
def _resolve_weight_allocation(signal_metadata, *, required: bool) -> dict:
154+
metadata = dict(signal_metadata or {})
155+
allocation = dict(metadata.get("allocation") or {})
156+
if not allocation:
157+
if required:
158+
raise ValueError("IBKR execution requires signal_metadata.allocation")
159+
return {}
160+
if allocation.get("target_mode") != "weight":
161+
raise ValueError("IBKR execution requires allocation.target_mode=weight")
162+
targets = {
163+
str(symbol).strip().upper(): float(weight)
164+
for symbol, weight in dict(allocation.get("targets") or {}).items()
165+
}
166+
return {
167+
"strategy_symbols": tuple(str(symbol) for symbol in allocation.get("strategy_symbols", ())),
168+
"risk_symbols": tuple(str(symbol) for symbol in allocation.get("risk_symbols", ())),
169+
"income_symbols": tuple(str(symbol) for symbol in allocation.get("income_symbols", ())),
170+
"safe_haven_symbols": tuple(str(symbol) for symbol in allocation.get("safe_haven_symbols", ())),
171+
"targets": targets,
172+
}
173+
174+
153175
def build_dashboard(
154176
positions,
155177
account_values,
@@ -173,10 +195,10 @@ def build_dashboard(
173195
position_lines.append(f" {symbol}: {qty}股 ${market_value:,.2f}")
174196
position_text = "\n".join(position_lines) if position_lines else translator("empty_positions")
175197
signal_metadata = signal_metadata or {}
198+
allocation = _resolve_weight_allocation(signal_metadata, required=False)
176199
target_lines = []
177-
if target_weights:
178-
for symbol, weight in sorted(target_weights.items(), key=lambda item: (-item[1], item[0])):
179-
target_lines.append(f" {symbol}: {weight:.1%}")
200+
for symbol, weight in sorted(allocation.get("targets", {}).items(), key=lambda item: (-item[1], item[0])):
201+
target_lines.append(f" {symbol}: {weight:.1%}")
180202
target_text = "\n".join(target_lines) if target_lines else translator("empty_target_weights")
181203
regime = signal_metadata.get("regime")
182204
breadth_ratio = signal_metadata.get("breadth_ratio")
@@ -239,14 +261,16 @@ def run_strategy_core(
239261
else:
240262
target_weights, signal_desc, _is_emergency, status_desc = signal_result
241263
signal_metadata = {}
264+
allocation = _resolve_weight_allocation(signal_metadata, required=target_weights is not None)
265+
resolved_target_weights = dict(allocation.get("targets") or {}) if target_weights is not None else None
242266

243267
dashboard = build_dashboard(
244268
positions,
245269
account_values,
246270
signal_desc,
247271
status_desc,
248272
strategy_profile=signal_metadata.get("strategy_profile"),
249-
target_weights=target_weights,
273+
target_weights=resolved_target_weights,
250274
signal_metadata=signal_metadata,
251275
translator=translator,
252276
separator=separator,
@@ -298,10 +322,10 @@ def run_strategy_core(
298322

299323
execution_result = execute_rebalance(
300324
ib,
301-
target_weights,
325+
resolved_target_weights,
302326
positions,
303327
account_values,
304-
strategy_symbols=signal_metadata.get("managed_symbols"),
328+
strategy_symbols=allocation.get("strategy_symbols"),
305329
signal_metadata=signal_metadata,
306330
)
307331
if isinstance(execution_result, tuple) and len(execution_result) == 2:
@@ -315,7 +339,7 @@ def run_strategy_core(
315339
trade_date=signal_metadata.get("trade_date"),
316340
snapshot_as_of=signal_metadata.get("snapshot_as_of"),
317341
signal_metadata=signal_metadata,
318-
target_weights=target_weights,
342+
target_weights=resolved_target_weights,
319343
execution_summary=execution_summary,
320344
)
321345
record_path = write_reconciliation_record(record, output_path=reconciliation_output_path)
@@ -356,7 +380,7 @@ def run_strategy_core(
356380
{
357381
"result": "OK - executed",
358382
"signal_metadata": dict(signal_metadata or {}),
359-
"target_weights": dict(target_weights or {}),
383+
"target_weights": dict(resolved_target_weights or {}),
360384
"execution_summary": dict(execution_summary or {}),
361385
"reconciliation_record": dict(record),
362386
"reconciliation_record_path": str(record_path),

tests/test_execution_service.py

Lines changed: 87 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,36 @@
44
from quant_platform_kit.common.models import OrderIntent
55

66

7+
def _weight_allocation(targets, *, risk_symbols=(), income_symbols=(), safe_haven_symbols=()):
8+
ordered_symbols = tuple(targets.keys())
9+
return {
10+
"target_mode": "weight",
11+
"strategy_symbols": ordered_symbols,
12+
"risk_symbols": tuple(risk_symbols),
13+
"income_symbols": tuple(income_symbols),
14+
"safe_haven_symbols": tuple(safe_haven_symbols),
15+
"targets": dict(targets),
16+
}
17+
18+
19+
def _signal_metadata(
20+
targets,
21+
*,
22+
risk_symbols=(),
23+
income_symbols=(),
24+
safe_haven_symbols=(),
25+
**extra,
26+
):
27+
payload = dict(extra)
28+
payload["allocation"] = _weight_allocation(
29+
targets,
30+
risk_symbols=risk_symbols,
31+
income_symbols=income_symbols,
32+
safe_haven_symbols=safe_haven_symbols,
33+
)
34+
return payload
35+
36+
737
def translate(key, **kwargs):
838
templates = {
939
"submitted": "submitted {order_id}",
@@ -66,14 +96,17 @@ def fake_fetch_quote_snapshots(_ib, symbols):
6696
translator=translate,
6797
strategy_symbols=["VOO", "BIL"],
6898
strategy_profile="tech_pullback_cash_buffer",
69-
signal_metadata={
70-
"regime": "risk_on",
71-
"breadth_ratio": 0.6,
72-
"target_stock_weight": 0.8,
73-
"realized_stock_weight": 0.8,
74-
"trade_date": "2026-04-01",
75-
"snapshot_as_of": "2026-03-31",
76-
},
99+
signal_metadata=_signal_metadata(
100+
{"VOO": 1.0},
101+
risk_symbols=("VOO",),
102+
safe_haven_symbols=("BIL",),
103+
regime="risk_on",
104+
breadth_ratio=0.6,
105+
target_stock_weight=0.8,
106+
realized_stock_weight=0.8,
107+
trade_date="2026-04-01",
108+
snapshot_as_of="2026-03-31",
109+
),
77110
dry_run_only=False,
78111
cash_reserve_ratio=0.03,
79112
rebalance_threshold_ratio=0.02,
@@ -113,7 +146,7 @@ def accountValues(self):
113146
translator=translate,
114147
strategy_symbols=["VOO"],
115148
strategy_profile="tech_pullback_cash_buffer",
116-
signal_metadata={},
149+
signal_metadata=_signal_metadata({"VOO": 1.0}, risk_symbols=("VOO",)),
117150
dry_run_only=False,
118151
cash_reserve_ratio=0.03,
119152
rebalance_threshold_ratio=0.02,
@@ -150,14 +183,17 @@ def fake_fetch_quote_snapshots(_ib, symbols):
150183
account_group="default",
151184
service_name="ibkr-paper",
152185
account_ids=("DU123",),
153-
signal_metadata={
154-
"regime": "risk_on",
155-
"breadth_ratio": 0.6,
156-
"target_stock_weight": 0.8,
157-
"realized_stock_weight": 0.8,
158-
"trade_date": "2026-04-01",
159-
"snapshot_as_of": "2026-03-31",
160-
},
186+
signal_metadata=_signal_metadata(
187+
{"VOO": 0.8, "BOXX": 0.2},
188+
risk_symbols=("VOO",),
189+
safe_haven_symbols=("BOXX",),
190+
regime="risk_on",
191+
breadth_ratio=0.6,
192+
target_stock_weight=0.8,
193+
realized_stock_weight=0.8,
194+
trade_date="2026-04-01",
195+
snapshot_as_of="2026-03-31",
196+
),
161197
cash_reserve_ratio=0.0,
162198
rebalance_threshold_ratio=0.02,
163199
limit_buy_premium=1.005,
@@ -225,7 +261,7 @@ def accountValues(self):
225261
account_group="default",
226262
service_name="ibkr-paper",
227263
account_ids=("DU123",),
228-
signal_metadata={"trade_date": "2026-04-01"},
264+
signal_metadata=_signal_metadata({"VOO": 1.0}, risk_symbols=("VOO",), trade_date="2026-04-01"),
229265
dry_run_only=False,
230266
cash_reserve_ratio=0.0,
231267
rebalance_threshold_ratio=0.02,
@@ -266,16 +302,19 @@ def fake_fetch_quote_snapshots(_ib, symbols):
266302
account_group="default",
267303
service_name="ibkr-paper",
268304
account_ids=("DU123",),
269-
signal_metadata={
270-
"regime": "risk_on",
271-
"breadth_ratio": 0.6,
272-
"target_stock_weight": 0.8,
273-
"realized_stock_weight": 0.8,
274-
"safe_haven_weight": 0.2,
275-
"safe_haven_symbol": "BOXX",
276-
"trade_date": "2026-04-01",
277-
"snapshot_as_of": "2026-03-31",
278-
},
305+
signal_metadata=_signal_metadata(
306+
{"VOO": 0.8, "BOXX": 0.2},
307+
risk_symbols=("VOO",),
308+
safe_haven_symbols=("BOXX",),
309+
regime="risk_on",
310+
breadth_ratio=0.6,
311+
target_stock_weight=0.8,
312+
realized_stock_weight=0.8,
313+
safe_haven_weight=0.2,
314+
safe_haven_symbol="BOXX",
315+
trade_date="2026-04-01",
316+
snapshot_as_of="2026-03-31",
317+
),
279318
dry_run_only=True,
280319
cash_reserve_ratio=0.0,
281320
rebalance_threshold_ratio=0.02,
@@ -315,7 +354,12 @@ def accountValues(self):
315354
translator=translate,
316355
strategy_symbols=["VOO"],
317356
strategy_profile="tech_pullback_cash_buffer",
318-
signal_metadata={"trade_date": "2026-04-01", "snapshot_as_of": "2026-03-31"},
357+
signal_metadata=_signal_metadata(
358+
{"VOO": 1.0},
359+
risk_symbols=("VOO",),
360+
trade_date="2026-04-01",
361+
snapshot_as_of="2026-03-31",
362+
),
319363
dry_run_only=True,
320364
cash_reserve_ratio=0.0,
321365
rebalance_threshold_ratio=0.02,
@@ -352,7 +396,12 @@ def accountValues(self):
352396
translator=translate,
353397
strategy_symbols=["VOO"],
354398
strategy_profile="tech_pullback_cash_buffer",
355-
signal_metadata={"trade_date": "2026-04-01", "snapshot_as_of": "2026-03-31"},
399+
signal_metadata=_signal_metadata(
400+
{"VOO": 1.0},
401+
risk_symbols=("VOO",),
402+
trade_date="2026-04-01",
403+
snapshot_as_of="2026-03-31",
404+
),
356405
dry_run_only=True,
357406
cash_reserve_ratio=0.0,
358407
rebalance_threshold_ratio=0.02,
@@ -389,11 +438,14 @@ def accountValues(self):
389438
translator=translate,
390439
strategy_symbols=["VOO", "BOXX"],
391440
strategy_profile="tech_pullback_cash_buffer",
392-
signal_metadata={
393-
"trade_date": "2026-04-01",
394-
"snapshot_as_of": "2026-03-31",
395-
"dry_run_price_fallbacks": {"VOO": 100.0, "BOXX": 100.0},
396-
},
441+
signal_metadata=_signal_metadata(
442+
{"VOO": 0.6, "BOXX": 0.4},
443+
risk_symbols=("VOO",),
444+
safe_haven_symbols=("BOXX",),
445+
trade_date="2026-04-01",
446+
snapshot_as_of="2026-03-31",
447+
dry_run_price_fallbacks={"VOO": 100.0, "BOXX": 100.0},
448+
),
397449
dry_run_only=True,
398450
cash_reserve_ratio=0.0,
399451
rebalance_threshold_ratio=0.02,

tests/test_rebalance_service.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
from notifications.telegram import build_translator
55

66

7+
def _weight_allocation(targets, *, risk_symbols=(), income_symbols=(), safe_haven_symbols=()):
8+
return {
9+
"target_mode": "weight",
10+
"strategy_symbols": tuple(targets.keys()),
11+
"risk_symbols": tuple(risk_symbols),
12+
"income_symbols": tuple(income_symbols),
13+
"safe_haven_symbols": tuple(safe_haven_symbols),
14+
"targets": dict(targets),
15+
}
16+
17+
718
def _build_test_translator():
819
templates = {
920
"heartbeat_title": "heartbeat",
@@ -64,6 +75,7 @@ def test_build_dashboard_localizes_strategy_details():
6475
"snapshot_age_days": 8,
6576
"feature_snapshot_path": "gs://bucket/snapshot.csv",
6677
"strategy_config_source": "external_config",
78+
"allocation": _weight_allocation({}, safe_haven_symbols=("BOXX",)),
6779
},
6880
translator=build_translator("zh"),
6981
separator="---",
@@ -108,7 +120,15 @@ def fake_execute_rebalance(
108120
"signal",
109121
False,
110122
"breadth=60.0%",
111-
{"managed_symbols": ("AAA", "BOXX"), "status_icon": "📏"},
123+
{
124+
"managed_symbols": ("AAA", "BOXX"),
125+
"status_icon": "📏",
126+
"allocation": _weight_allocation(
127+
{"AAA": 0.9, "BOXX": 0.1},
128+
risk_symbols=("AAA",),
129+
safe_haven_symbols=("BOXX",),
130+
),
131+
},
112132
),
113133
execute_rebalance=fake_execute_rebalance,
114134
send_tg_message=lambda message: observed["messages"].append(message),
@@ -158,6 +178,11 @@ def disconnect(self):
158178
"safe_haven_weight": 0.4,
159179
"safe_haven_symbol": "BOXX",
160180
"dry_run_only": True,
181+
"allocation": _weight_allocation(
182+
{"AAA": 0.6, "BOXX": 0.4},
183+
risk_symbols=("AAA",),
184+
safe_haven_symbols=("BOXX",),
185+
),
161186
},
162187
),
163188
execute_rebalance=lambda *_args, **_kwargs: (

0 commit comments

Comments
 (0)