Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
IBKR_FEATURE_SNAPSHOT_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_PATH }}
IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH }}
IBKR_STRATEGY_CONFIG_PATH: ${{ vars.IBKR_STRATEGY_CONFIG_PATH }}
IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS_JSON }}
IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }}
IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }}
# Strategy-owned defaults continue to come from UsEquityStrategies; this workflow only syncs platform/runtime inputs.
Expand Down Expand Up @@ -295,6 +296,12 @@ jobs:
remove_env_vars+=("IBKR_STRATEGY_CONFIG_PATH")
fi

if [ -n "${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON:-}" ]; then
env_pairs+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON=${IBKR_STRATEGY_PLUGIN_MOUNTS_JSON}")
else
remove_env_vars+=("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON")
fi

if [ -n "${IBKR_RECONCILIATION_OUTPUT_PATH:-}" ]; then
env_pairs+=("IBKR_RECONCILIATION_OUTPUT_PATH=${IBKR_RECONCILIATION_OUTPUT_PATH}")
else
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac
| `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` |
| `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. |
| `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`). |
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
| `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. |
| `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`. |
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `50.0`. |
Expand Down Expand Up @@ -221,6 +222,7 @@ Recommended setup:
- `STRATEGY_PROFILE` (set explicitly to one enabled profile, such as `soxl_soxx_trend_income`)
- `ACCOUNT_GROUP` (recommended: `paper`)
- `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`
- Optional: `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`
- `GLOBAL_TELEGRAM_CHAT_ID`
- `NOTIFY_LANG`
- **Repository Secrets**
Expand Down Expand Up @@ -340,6 +342,7 @@ IBKR 账户
| `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` |
| `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 |
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 |
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 |
| `IBKR_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。 |
| `IBKR_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`。 |
| `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `50.0`。 |
Expand Down Expand Up @@ -421,6 +424,7 @@ IB_GATEWAY_IP_MODE=internal
- `STRATEGY_PROFILE`(显式设置为任一已启用 profile,例如 `soxl_soxx_trend_income`)
- `ACCOUNT_GROUP`(建议设为 `paper`)
- `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`
- 可选:`IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`
- `GLOBAL_TELEGRAM_CHAT_ID`
- `NOTIFY_LANG`
- **仓库级 Secrets**
Expand Down
2 changes: 2 additions & 0 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ def run_strategy_core(
translator=config.translator,
separator=config.separator,
strategy_display_name=config.strategy_display_name,
extra_notification_lines=config.extra_notification_lines,
)
)
return StrategyCycleResult(
Expand Down Expand Up @@ -703,6 +704,7 @@ def run_strategy_core(
translator=config.translator,
separator=config.separator,
strategy_display_name=config.strategy_display_name,
extra_notification_lines=config.extra_notification_lines,
)
)
return StrategyCycleResult(
Expand Down
3 changes: 2 additions & 1 deletion application/runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,13 @@ def build_rebalance_runtime(self):
notifications=notification_adapters.notification_port,
)

def build_rebalance_config(self):
def build_rebalance_config(self, *, extra_notification_lines=()):
return IBKRRebalanceConfig(
translator=self.translator,
separator=self.separator,
strategy_display_name=self.strategy_display_name_localized,
reconciliation_output_path=self.reconciliation_output_path,
extra_notification_lines=tuple(extra_notification_lines or ()),
)


Expand Down
1 change: 1 addition & 0 deletions application/runtime_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class IBKRRebalanceConfig:
separator: str
strategy_display_name: str | None = None
reconciliation_output_path: str | Path | None = None
extra_notification_lines: tuple[str, ...] = ()


@dataclass(frozen=True)
Expand Down
54 changes: 54 additions & 0 deletions application/runtime_strategy_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,54 @@ class IBKRRuntimeStrategyAdapters:
fetch_historical_price_series_fn: Any
fetch_historical_price_candles_fn: Any
map_strategy_decision_fn: Any
build_strategy_plugin_report_payload_fn: Any = None
load_configured_strategy_plugin_signals_fn: Any = None
parse_strategy_plugin_mounts_fn: Any = None

def load_strategy_plugin_signals(self, raw_mounts):
if not raw_mounts or self.parse_strategy_plugin_mounts_fn is None or self.load_configured_strategy_plugin_signals_fn is None:
return (), None
try:
mounts = self.parse_strategy_plugin_mounts_fn(raw_mounts)
if not mounts:
return (), None
return (
self.load_configured_strategy_plugin_signals_fn(
mounts,
strategy_profile=self.strategy_profile,
),
None,
)
except Exception as exc:
return (), f"{type(exc).__name__}: {exc}"

def attach_strategy_plugin_report(self, report, *, signals, error: str | None = None):
if signals and self.build_strategy_plugin_report_payload_fn is not None:
report.setdefault("summary", {}).update(self.build_strategy_plugin_report_payload_fn(signals))
if error:
report.setdefault("diagnostics", {})["strategy_plugin_error"] = error

def translate_strategy_plugin_value(self, category: str, raw_value: str | None) -> str:
value = str(raw_value or "").strip() or "unknown"
key = f"strategy_plugin_{category}_{value}"
translated = self.translator(key)
return translated if translated != key else value

def build_strategy_plugin_notification_lines(self, signals) -> tuple[str, ...]:
lines = []
for signal in signals:
route = signal.canonical_route or "unknown_route"
action = signal.suggested_action or "unknown_action"
lines.append(
self.translator(
"strategy_plugin_line",
plugin=self.translate_strategy_plugin_value("name", signal.plugin),
mode=self.translate_strategy_plugin_value("mode", signal.effective_mode),
route=self.translate_strategy_plugin_value("route", route),
action=self.translate_strategy_plugin_value("action", action),
)
)
return tuple(lines)

def get_historical_close(self, ib, symbol, duration="2 Y", bar_size="1 day"):
series = self.fetch_historical_price_series_fn(
Expand Down Expand Up @@ -68,6 +116,9 @@ def build_runtime_strategy_adapters(
fetch_historical_price_series_fn,
fetch_historical_price_candles_fn,
map_strategy_decision_fn,
build_strategy_plugin_report_payload_fn=None,
load_configured_strategy_plugin_signals_fn=None,
parse_strategy_plugin_mounts_fn=None,
) -> IBKRRuntimeStrategyAdapters:
return IBKRRuntimeStrategyAdapters(
strategy_runtime=strategy_runtime,
Expand All @@ -78,4 +129,7 @@ def build_runtime_strategy_adapters(
fetch_historical_price_series_fn=fetch_historical_price_series_fn,
fetch_historical_price_candles_fn=fetch_historical_price_candles_fn,
map_strategy_decision_fn=map_strategy_decision_fn,
build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload_fn,
load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals_fn,
parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts_fn,
)
38 changes: 35 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
finalize_runtime_report,
persist_runtime_report,
)
from quant_platform_kit.common.strategy_plugins import (
build_strategy_plugin_report_payload,
load_configured_strategy_plugin_signals,
parse_strategy_plugin_mounts,
)
from quant_platform_kit.ibkr import (
connect_ib as ibkr_connect_ib,
ensure_event_loop as ibkr_ensure_event_loop,
Expand Down Expand Up @@ -287,6 +292,9 @@ def build_strategy_adapters():
fetch_historical_price_series_fn=fetch_historical_price_series,
fetch_historical_price_candles_fn=fetch_historical_price_candles,
map_strategy_decision_fn=map_strategy_decision,
build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload,
load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals,
parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts,
)


Expand Down Expand Up @@ -442,6 +450,20 @@ def compute_signals(ib, current_holdings):
return build_strategy_adapters().compute_signals(ib, current_holdings)


def load_strategy_plugin_signals():
return build_strategy_adapters().load_strategy_plugin_signals(
getattr(RUNTIME_SETTINGS, "strategy_plugin_mounts_json", None)
)


def attach_strategy_plugin_report(report, *, signals, error: str | None = None):
build_strategy_adapters().attach_strategy_plugin_report(report, signals=signals, error=error)


def build_strategy_plugin_notification_lines(signals) -> tuple[str, ...]:
return build_strategy_adapters().build_strategy_plugin_notification_lines(signals)


def get_current_portfolio(ib):
return build_broker_adapters().get_current_portfolio(ib)

Expand Down Expand Up @@ -494,13 +516,15 @@ def run_paper_liquidation_cycle():
)


def run_strategy_core():
def run_strategy_core(*, strategy_plugin_signals=()):
if PAPER_LIQUIDATE_ONLY:
return run_paper_liquidation_cycle()
composer = build_composer()
return run_rebalance_cycle(
runtime=composer.build_rebalance_runtime(),
config=composer.build_rebalance_config(),
config=composer.build_rebalance_config(
extra_notification_lines=build_strategy_plugin_notification_lines(strategy_plugin_signals)
),
)


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

log_context = build_request_log_context()
report = build_execution_report(log_context)
strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer plugin signal loading until execution is allowed

handle_request now loads strategy plugin signals before both the run lock check and the market-open check, so overlapping or market-closed POSTs still execute plugin loading work. When IBKR_STRATEGY_PLUGIN_MOUNTS_JSON points to remote-backed plugin signals (for example GCS-based signal paths), skipped requests can still incur unnecessary I/O/latency and emit plugin-load diagnostics even though no strategy cycle will run. Move plugin loading inside the branch that has acquired the lock and passed the market-open gate.

Useful? React with 👍 / 👎.

attach_strategy_plugin_report(
report,
signals=strategy_plugin_signals,
error=strategy_plugin_error,
)
lock_acquired = STRATEGY_RUN_LOCK.acquire(blocking=False)
try:
log_runtime_event(
Expand Down Expand Up @@ -549,7 +579,9 @@ def handle_request():
"strategy_cycle_started",
message="Starting strategy execution",
)
cycle_result = coerce_strategy_cycle_result(run_strategy_core())
cycle_result = coerce_strategy_cycle_result(
run_strategy_core(strategy_plugin_signals=strategy_plugin_signals)
)
execution_summary = dict(cycle_result.execution_summary or {})
reconciliation_record = dict(cycle_result.reconciliation_record or {})
finalize_runtime_report(
Expand Down
20 changes: 18 additions & 2 deletions notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ def _translator_uses_zh(translator) -> bool:
return _base_translator_uses_zh(translator)


def _extra_notification_lines(extra_notification_lines) -> list[str]:
return [str(line).strip() for line in extra_notification_lines or () if str(line).strip()]


def _localize_notification_text(text: str, *, translator) -> str:
return _base_localize_notification_text(
text,
Expand Down Expand Up @@ -395,10 +399,12 @@ def _build_compact_message(
separator: str,
body_lines,
dashboard_text: str = "",
extra_notification_lines=(),
) -> str:
lines = [title]
strategy_name = _format_text(strategy_display_name, fallback="<unknown>")
lines.append(translator("strategy_label", name=strategy_name))
lines.extend(_extra_notification_lines(extra_notification_lines))
dashboard = _format_dashboard_text(dashboard_text)
if dashboard:
lines.append(separator)
Expand Down Expand Up @@ -427,8 +433,11 @@ def render_heartbeat_notification(
translator,
separator,
strategy_display_name,
extra_notification_lines=(),
) -> RenderedNotification:
detailed_text = f"{translator('heartbeat_title')}\n{dashboard}\n{separator}\n{no_op_text}"
extra_lines = _extra_notification_lines(extra_notification_lines)
detailed_parts = [translator("heartbeat_title"), *extra_lines, dashboard, separator, no_op_text]
detailed_text = "\n".join(str(part) for part in detailed_parts if str(part).strip())
compact_text = _build_compact_message(
title=translator("heartbeat_title"),
strategy_display_name=strategy_display_name,
Expand All @@ -439,6 +448,7 @@ def render_heartbeat_notification(
separator=separator,
body_lines=[no_op_text],
dashboard_text=strategy_dashboard,
extra_notification_lines=extra_lines,
)
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)

Expand All @@ -455,7 +465,9 @@ def render_trade_notification(
translator,
separator,
strategy_display_name,
extra_notification_lines=(),
) -> RenderedNotification:
extra_lines = _extra_notification_lines(extra_notification_lines)
if trade_logs:
notification_trade_lines = _build_notification_trade_lines(
trade_logs,
Expand All @@ -464,6 +476,7 @@ def render_trade_notification(
)
detailed_text = (
f"{translator('rebalance_title')}\n"
f"{chr(10).join(extra_lines) + chr(10) if extra_lines else ''}"
f"{dashboard}\n"
f"{separator}\n"
f"{chr(10).join(notification_trade_lines)}"
Expand All @@ -478,10 +491,12 @@ def render_trade_notification(
separator=separator,
body_lines=notification_trade_lines,
dashboard_text=strategy_dashboard,
extra_notification_lines=extra_lines,
)
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)

detailed_text = f"{translator('heartbeat_title')}\n{dashboard}\n{separator}\n{translator('no_trades')}"
detailed_parts = [translator("heartbeat_title"), *extra_lines, dashboard, separator, translator("no_trades")]
detailed_text = "\n".join(str(part) for part in detailed_parts if str(part).strip())
compact_text = _build_compact_message(
title=translator("heartbeat_title"),
strategy_display_name=strategy_display_name,
Expand All @@ -492,5 +507,6 @@ def render_trade_notification(
separator=separator,
body_lines=[translator("no_trades")],
dashboard_text=strategy_dashboard,
extra_notification_lines=extra_lines,
)
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)
Loading