Skip to content

Commit 95ea3eb

Browse files
authored
Add IBKR SOXL plugin support (#53)
1 parent 3dae275 commit 95ea3eb

17 files changed

Lines changed: 293 additions & 14 deletions

.github/workflows/sync-cloud-run-env.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }}
2828
IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }}
2929
IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }}
30+
IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }}
3031
IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }}
3132
IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }}
3233
# Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs.
@@ -295,6 +296,12 @@ jobs:
295296
remove_env_vars+=("IBKR_STRATEGY_CONFIG_PATH")
296297
fi
297298
299+
if [ -n "${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON:-}" ]; then
300+
env_pairs+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON=${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON}")
301+
else
302+
remove_env_vars+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON")
303+
fi
304+
298305
if [ -n "${IBKR_RECONCILIATION_OUTPUT_PATH:-}" ]; then
299306
env_pairs+=("IBKR_RECONCILIATION_OUTPUT_PATH=${IBKR_RECONCILIATION_OUTPUT_PATH}")
300307
else

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac
102102
| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced` |
103103
| `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. |
104104
| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). |
105+
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
105106
| `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. |
106107
| `IBKR_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.0001` for fractional sizing. Takes precedence over `IBKR_FRACTIONAL_SHARES_ENABLED`. |
107108
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `50.0`. |
@@ -221,6 +222,7 @@ Recommended setup:
221222
- `STRATEGY_PROFILE` (set explicitly to one enabled profile, such as `soxl_soxx_trend_income`)
222223
- `ACCOUNT_GROUP` (recommended: `paper`)
223224
- `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`
225+
- Optional: `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`
224226
- `GLOBAL_TELEGRAM_CHAT_ID`
225227
- `NOTIFY_LANG`
226228
- **Repository Secrets**
@@ -340,6 +342,7 @@ IBKR 账户
340342
| `STRATEGY_PROFILE` || 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation``russell_1000_multi_factor_defensive``tqqq_growth_income``soxl_soxx_trend_income``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced` |
341343
| `ACCOUNT_GROUP` || 账号组选择器,每个部署都要显式设置。 |
342344
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv``.json``.jsonl``.parquet`)。 |
345+
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` || 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`|
343346
| `IBKR_FRACTIONAL_SHARES_ENABLED` || 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`|
344347
| `IBKR_ORDER_QUANTITY_STEP` || 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`|
345348
| `IBKR_MIN_ORDER_NOTIONAL_USD` || 碎股买入的最小名义金额;默认 `50.0`|
@@ -421,6 +424,7 @@ IB_GATEWAY_IP_MODE=internal
421424
- `STRATEGY_PROFILE`(显式设置为任一已启用 profile,例如 `soxl_soxx_trend_income`
422425
- `ACCOUNT_GROUP`(建议设为 `paper`
423426
- `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`
427+
- 可选:`IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`
424428
- `GLOBAL_TELEGRAM_CHAT_ID`
425429
- `NOTIFY_LANG`
426430
- **仓库级 Secrets**

application/rebalance_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,7 @@ def run_strategy_core(
643643
translator=config.translator,
644644
separator=config.separator,
645645
strategy_display_name=config.strategy_display_name,
646+
extra_notification_lines=config.extra_notification_lines,
646647
)
647648
)
648649
return StrategyCycleResult(
@@ -703,6 +704,7 @@ def run_strategy_core(
703704
translator=config.translator,
704705
separator=config.separator,
705706
strategy_display_name=config.strategy_display_name,
707+
extra_notification_lines=config.extra_notification_lines,
706708
)
707709
)
708710
return StrategyCycleResult(

application/runtime_composer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,13 @@ def build_rebalance_runtime(self):
136136
notifications=notification_adapters.notification_port,
137137
)
138138

139-
def build_rebalance_config(self):
139+
def build_rebalance_config(self, *, extra_notification_lines=()):
140140
return IBKRRebalanceConfig(
141141
translator=self.translator,
142142
separator=self.separator,
143143
strategy_display_name=self.strategy_display_name_localized,
144144
reconciliation_output_path=self.reconciliation_output_path,
145+
extra_notification_lines=tuple(extra_notification_lines or ()),
145146
)
146147

147148

application/runtime_dependencies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class IBKRRebalanceConfig:
1616
separator: str
1717
strategy_display_name: str | None = None
1818
reconciliation_output_path: str | Path | None = None
19+
extra_notification_lines: tuple[str, ...] = ()
1920

2021

2122
@dataclass(frozen=True)

application/runtime_strategy_adapters.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,54 @@ class IBKRRuntimeStrategyAdapters:
1818
fetch_historical_price_series_fn: Any
1919
fetch_historical_price_candles_fn: Any
2020
map_strategy_decision_fn: Any
21+
build_strategy_plugin_report_payload_fn: Any = None
22+
load_configured_strategy_plugin_signals_fn: Any = None
23+
parse_strategy_plugin_mounts_fn: Any = None
24+
25+
def load_strategy_plugin_signals(self, raw_mounts):
26+
if not raw_mounts or self.parse_strategy_plugin_mounts_fn is None or self.load_configured_strategy_plugin_signals_fn is None:
27+
return (), None
28+
try:
29+
mounts = self.parse_strategy_plugin_mounts_fn(raw_mounts)
30+
if not mounts:
31+
return (), None
32+
return (
33+
self.load_configured_strategy_plugin_signals_fn(
34+
mounts,
35+
strategy_profile=self.strategy_profile,
36+
),
37+
None,
38+
)
39+
except Exception as exc:
40+
return (), f"{type(exc).__name__}: {exc}"
41+
42+
def attach_strategy_plugin_report(self, report, *, signals, error: str | None = None):
43+
if signals and self.build_strategy_plugin_report_payload_fn is not None:
44+
report.setdefault("summary", {}).update(self.build_strategy_plugin_report_payload_fn(signals))
45+
if error:
46+
report.setdefault("diagnostics", {})["strategy_plugin_error"] = error
47+
48+
def translate_strategy_plugin_value(self, category: str, raw_value: str | None) -> str:
49+
value = str(raw_value or "").strip() or "unknown"
50+
key = f"strategy_plugin_{category}_{value}"
51+
translated = self.translator(key)
52+
return translated if translated != key else value
53+
54+
def build_strategy_plugin_notification_lines(self, signals) -> tuple[str, ...]:
55+
lines = []
56+
for signal in signals:
57+
route = signal.canonical_route or "unknown_route"
58+
action = signal.suggested_action or "unknown_action"
59+
lines.append(
60+
self.translator(
61+
"strategy_plugin_line",
62+
plugin=self.translate_strategy_plugin_value("name", signal.plugin),
63+
mode=self.translate_strategy_plugin_value("mode", signal.effective_mode),
64+
route=self.translate_strategy_plugin_value("route", route),
65+
action=self.translate_strategy_plugin_value("action", action),
66+
)
67+
)
68+
return tuple(lines)
2169

2270
def get_historical_close(self, ib, symbol, duration="2 Y", bar_size="1 day"):
2371
series = self.fetch_historical_price_series_fn(
@@ -68,6 +116,9 @@ def build_runtime_strategy_adapters(
68116
fetch_historical_price_series_fn,
69117
fetch_historical_price_candles_fn,
70118
map_strategy_decision_fn,
119+
build_strategy_plugin_report_payload_fn=None,
120+
load_configured_strategy_plugin_signals_fn=None,
121+
parse_strategy_plugin_mounts_fn=None,
71122
) -> IBKRRuntimeStrategyAdapters:
72123
return IBKRRuntimeStrategyAdapters(
73124
strategy_runtime=strategy_runtime,
@@ -78,4 +129,7 @@ def build_runtime_strategy_adapters(
78129
fetch_historical_price_series_fn=fetch_historical_price_series_fn,
79130
fetch_historical_price_candles_fn=fetch_historical_price_candles_fn,
80131
map_strategy_decision_fn=map_strategy_decision_fn,
132+
build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload_fn,
133+
load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals_fn,
134+
parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts_fn,
81135
)

main.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
finalize_runtime_report,
3333
persist_runtime_report,
3434
)
35+
from quant_platform_kit.common.strategy_plugins import (
36+
build_strategy_plugin_report_payload,
37+
load_configured_strategy_plugin_signals,
38+
parse_strategy_plugin_mounts,
39+
)
3540
from quant_platform_kit.ibkr import (
3641
connect_ib as ibkr_connect_ib,
3742
ensure_event_loop as ibkr_ensure_event_loop,
@@ -287,6 +292,9 @@ def build_strategy_adapters():
287292
fetch_historical_price_series_fn=fetch_historical_price_series,
288293
fetch_historical_price_candles_fn=fetch_historical_price_candles,
289294
map_strategy_decision_fn=map_strategy_decision,
295+
build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload,
296+
load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals,
297+
parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts,
290298
)
291299

292300

@@ -442,6 +450,20 @@ def compute_signals(ib, current_holdings):
442450
return build_strategy_adapters().compute_signals(ib, current_holdings)
443451

444452

453+
def load_strategy_plugin_signals():
454+
return build_strategy_adapters().load_strategy_plugin_signals(
455+
getattr(RUNTIME_SETTINGS, "strategy_plugin_mounts_json", None)
456+
)
457+
458+
459+
def attach_strategy_plugin_report(report, *, signals, error: str | None = None):
460+
build_strategy_adapters().attach_strategy_plugin_report(report, signals=signals, error=error)
461+
462+
463+
def build_strategy_plugin_notification_lines(signals) -> tuple[str, ...]:
464+
return build_strategy_adapters().build_strategy_plugin_notification_lines(signals)
465+
466+
445467
def get_current_portfolio(ib):
446468
return build_broker_adapters().get_current_portfolio(ib)
447469

@@ -494,13 +516,15 @@ def run_paper_liquidation_cycle():
494516
)
495517

496518

497-
def run_strategy_core():
519+
def run_strategy_core(*, strategy_plugin_signals=()):
498520
if PAPER_LIQUIDATE_ONLY:
499521
return run_paper_liquidation_cycle()
500522
composer = build_composer()
501523
return run_rebalance_cycle(
502524
runtime=composer.build_rebalance_runtime(),
503-
config=composer.build_rebalance_config(),
525+
config=composer.build_rebalance_config(
526+
extra_notification_lines=build_strategy_plugin_notification_lines(strategy_plugin_signals)
527+
),
504528
)
505529

506530

@@ -511,6 +535,12 @@ def handle_request():
511535

512536
log_context = build_request_log_context()
513537
report = build_execution_report(log_context)
538+
strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals()
539+
attach_strategy_plugin_report(
540+
report,
541+
signals=strategy_plugin_signals,
542+
error=strategy_plugin_error,
543+
)
514544
lock_acquired = STRATEGY_RUN_LOCK.acquire(blocking=False)
515545
try:
516546
log_runtime_event(
@@ -549,7 +579,9 @@ def handle_request():
549579
"strategy_cycle_started",
550580
message="Starting strategy execution",
551581
)
552-
cycle_result = coerce_strategy_cycle_result(run_strategy_core())
582+
cycle_result = coerce_strategy_cycle_result(
583+
run_strategy_core(strategy_plugin_signals=strategy_plugin_signals)
584+
)
553585
execution_summary = dict(cycle_result.execution_summary or {})
554586
reconciliation_record = dict(cycle_result.reconciliation_record or {})
555587
finalize_runtime_report(

notifications/renderers.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def _translator_uses_zh(translator) -> bool:
4242
return _base_translator_uses_zh(translator)
4343

4444

45+
def _extra_notification_lines(extra_notification_lines) -> list[str]:
46+
return [str(line).strip() for line in extra_notification_lines or () if str(line).strip()]
47+
48+
4549
def _localize_notification_text(text: str, *, translator) -> str:
4650
return _base_localize_notification_text(
4751
text,
@@ -395,10 +399,12 @@ def _build_compact_message(
395399
separator: str,
396400
body_lines,
397401
dashboard_text: str = "",
402+
extra_notification_lines=(),
398403
) -> str:
399404
lines = [title]
400405
strategy_name = _format_text(strategy_display_name, fallback="<unknown>")
401406
lines.append(translator("strategy_label", name=strategy_name))
407+
lines.extend(_extra_notification_lines(extra_notification_lines))
402408
dashboard = _format_dashboard_text(dashboard_text)
403409
if dashboard:
404410
lines.append(separator)
@@ -427,8 +433,11 @@ def render_heartbeat_notification(
427433
translator,
428434
separator,
429435
strategy_display_name,
436+
extra_notification_lines=(),
430437
) -> RenderedNotification:
431-
detailed_text = f"{translator('heartbeat_title')}\n{dashboard}\n{separator}\n{no_op_text}"
438+
extra_lines = _extra_notification_lines(extra_notification_lines)
439+
detailed_parts = [translator("heartbeat_title"), *extra_lines, dashboard, separator, no_op_text]
440+
detailed_text = "\n".join(str(part) for part in detailed_parts if str(part).strip())
432441
compact_text = _build_compact_message(
433442
title=translator("heartbeat_title"),
434443
strategy_display_name=strategy_display_name,
@@ -439,6 +448,7 @@ def render_heartbeat_notification(
439448
separator=separator,
440449
body_lines=[no_op_text],
441450
dashboard_text=strategy_dashboard,
451+
extra_notification_lines=extra_lines,
442452
)
443453
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)
444454

@@ -455,7 +465,9 @@ def render_trade_notification(
455465
translator,
456466
separator,
457467
strategy_display_name,
468+
extra_notification_lines=(),
458469
) -> RenderedNotification:
470+
extra_lines = _extra_notification_lines(extra_notification_lines)
459471
if trade_logs:
460472
notification_trade_lines = _build_notification_trade_lines(
461473
trade_logs,
@@ -464,6 +476,7 @@ def render_trade_notification(
464476
)
465477
detailed_text = (
466478
f"{translator('rebalance_title')}\n"
479+
f"{chr(10).join(extra_lines) + chr(10) if extra_lines else ''}"
467480
f"{dashboard}\n"
468481
f"{separator}\n"
469482
f"{chr(10).join(notification_trade_lines)}"
@@ -478,10 +491,12 @@ def render_trade_notification(
478491
separator=separator,
479492
body_lines=notification_trade_lines,
480493
dashboard_text=strategy_dashboard,
494+
extra_notification_lines=extra_lines,
481495
)
482496
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)
483497

484-
detailed_text = f"{translator('heartbeat_title')}\n{dashboard}\n{separator}\n{translator('no_trades')}"
498+
detailed_parts = [translator("heartbeat_title"), *extra_lines, dashboard, separator, translator("no_trades")]
499+
detailed_text = "\n".join(str(part) for part in detailed_parts if str(part).strip())
485500
compact_text = _build_compact_message(
486501
title=translator("heartbeat_title"),
487502
strategy_display_name=strategy_display_name,
@@ -492,5 +507,6 @@ def render_trade_notification(
492507
separator=separator,
493508
body_lines=[translator("no_trades")],
494509
dashboard_text=strategy_dashboard,
510+
extra_notification_lines=extra_lines,
495511
)
496512
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)

0 commit comments

Comments
 (0)