From 6e2f8c23f7969e64c67391814a7cedac914a6ebf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:32:04 +0800 Subject: [PATCH 1/8] Add top50 balanced profile support --- README.md | 10 ++++++---- notifications/telegram.py | 2 ++ tests/test_notifications.py | 2 ++ tests/test_runtime_config_support.py | 26 ++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7e938e2..034985a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The mainline runtime now follows one path only: - `tech_communication_pullback_enhancement` - `mega_cap_leader_rotation_aggressive` - `mega_cap_leader_rotation_dynamic_top20` +- `mega_cap_leader_rotation_top50_balanced` - `dynamic_mega_leveraged_pullback` @@ -55,6 +56,7 @@ The mainline runtime now follows one path only: | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | `us_equity` | enabled feature-snapshot alternative | | `mega_cap_leader_rotation_aggressive` | Mega Cap Leader Rotation Aggressive | Yes | Yes | `us_equity` | enabled aggressive leader rotation | | `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | Yes | `us_equity` | enabled concentrated leader rotation | +| `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | enabled balanced Top50 leader rotation | | `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | Yes | `us_equity` | enabled 2x mega-cap pullback line | Check the current matrix locally: @@ -101,9 +103,9 @@ The selected `ACCOUNT_GROUP` is now the runtime identity. Keep broker-specific i | `IB_GATEWAY_ZONE` | Optional fallback | GCE zone (for example `us-central1-a`). Recommended to keep in the selected account-group entry; this env var is only a transition fallback. | | `IB_GATEWAY_IP_MODE` | Optional fallback | `internal` (default) or `external`. Recommended to keep in the selected account-group entry; this env var is only a transition fallback. | | `IBKR_CONNECT_TIMEOUT_SECONDS` | No | IB API handshake timeout in seconds. Defaults to `60`; raise only if Gateway remote API startup is consistently slow. | -| `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_aggressive`, `mega_cap_leader_rotation_dynamic_top20`, `dynamic_mega_leveraged_pullback` | +| `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_aggressive`, `mega_cap_leader_rotation_dynamic_top20`, `mega_cap_leader_rotation_top50_balanced`, `dynamic_mega_leveraged_pullback` | | `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_dynamic_top20`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). | +| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_dynamic_top20`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). | | `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Yes for Cloud Run | Secret Manager secret name for account-group config JSON. Recommended production source. | | `IB_ACCOUNT_GROUP_CONFIG_JSON` | No | Local/dev JSON fallback for account-group config. Not recommended for production Cloud Run. | | `TELEGRAM_TOKEN` | Yes | Telegram bot token. For Cloud Run, prefer a Secret Manager reference instead of a literal env var. | @@ -291,7 +293,7 @@ gcloud run services update ibkr-quant \ IBKR runtime 负责把共享的 `us_equity` 策略档位部署到 GCP Cloud Run,并连接 GCE 上的 IB Gateway 执行。策略逻辑、策略频率、标的池、参数和研究/回测说明都放在 `UsEquityStrategies`;这个仓库只维护 IBKR 运行时、账号组、Gateway 连接、下单和通知。 -当前 `global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_aggressive`、`mega_cap_leader_rotation_dynamic_top20` 和 `dynamic_mega_leveraged_pullback` 的策略实现都来自 `UsEquityStrategies`。 +当前 `global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_aggressive`、`mega_cap_leader_rotation_dynamic_top20`、`mega_cap_leader_rotation_top50_balanced` 和 `dynamic_mega_leveraged_pullback` 的策略实现都来自 `UsEquityStrategies`。 完整策略说明现在放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies)。这个 README 只保留 IBKR 运行时、profile 启用状态、部署和凭据说明。 @@ -333,7 +335,7 @@ IBKR 账户 | `IB_GATEWAY_ZONE` | 可选过渡项 | GCE zone(如 `us-central1-a`)。推荐直接放进选中的账号组配置里;这里只保留过渡 fallback。 | | `IB_GATEWAY_IP_MODE` | 可选过渡项 | `internal`(默认)或 `external`。推荐直接放进选中的账号组配置里;这里只保留过渡 fallback。 | | `IBKR_CONNECT_TIMEOUT_SECONDS` | 否 | IB API 握手超时时间,单位秒。默认 `60`;只有 Gateway 远程 API 启动持续偏慢时才需要调高。 | -| `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_aggressive`、`mega_cap_leader_rotation_dynamic_top20`、`dynamic_mega_leveraged_pullback` | +| `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_aggressive`、`mega_cap_leader_rotation_dynamic_top20`、`mega_cap_leader_rotation_top50_balanced`、`dynamic_mega_leveraged_pullback` | | `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 | | `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 | | `IB_ACCOUNT_GROUP_CONFIG_JSON` | 否 | 本地开发用的账号组配置 JSON fallback。不建议在生产 Cloud Run 直接使用。 | diff --git a/notifications/telegram.py b/notifications/telegram.py index b7189a9..e770f25 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -73,6 +73,7 @@ "strategy_name_qqq_tech_enhancement": "科技通信回调增强", "strategy_name_mega_cap_leader_rotation_aggressive": "Mega Cap 激进龙头轮动", "strategy_name_mega_cap_leader_rotation_dynamic_top20": "Mega Cap 动态 Top20 龙头轮动", + "strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Top50 平衡龙头轮动", "strategy_name_dynamic_mega_leveraged_pullback": "Mega Cap 2x 回调策略", "strategy_name_tqqq_growth_income": "TQQQ 增长收益", "strategy_name_soxl_soxx_trend_income": "SOXL/SOXX 半导体趋势收益", @@ -146,6 +147,7 @@ "strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement", "strategy_name_mega_cap_leader_rotation_aggressive": "Mega Cap Leader Rotation Aggressive", "strategy_name_mega_cap_leader_rotation_dynamic_top20": "Mega Cap Leader Rotation Dynamic Top20", + "strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Leader Rotation Top50 Balanced", "strategy_name_dynamic_mega_leveraged_pullback": "Dynamic Mega Leveraged Pullback", "strategy_name_tqqq_growth_income": "TQQQ Growth Income", "strategy_name_soxl_soxx_trend_income": "SOXL/SOXX Semiconductor Trend Income", diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 5ef517f..7273a81 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -27,9 +27,11 @@ def test_strategy_display_name_translates_new_live_profiles(): assert zh_name("mega_cap_leader_rotation_aggressive") == "Mega Cap 激进龙头轮动" assert zh_name("mega_cap_leader_rotation_dynamic_top20") == "Mega Cap 动态 Top20 龙头轮动" + assert zh_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Top50 平衡龙头轮动" assert zh_name("dynamic_mega_leveraged_pullback") == "Mega Cap 2x 回调策略" assert en_name("mega_cap_leader_rotation_aggressive") == "Mega Cap Leader Rotation Aggressive" assert en_name("mega_cap_leader_rotation_dynamic_top20") == "Mega Cap Leader Rotation Dynamic Top20" + assert en_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Leader Rotation Top50 Balanced" assert en_name("dynamic_mega_leveraged_pullback") == "Dynamic Mega Leveraged Pullback" diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index cda1a58..10a87a6 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -157,6 +157,7 @@ def test_platform_supported_profiles_are_filtered_by_registry(): "dynamic_mega_leveraged_pullback", "mega_cap_leader_rotation_aggressive", "mega_cap_leader_rotation_dynamic_top20", + "mega_cap_leader_rotation_top50_balanced", "russell_1000_multi_factor_defensive", } ) @@ -172,6 +173,7 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix(): "dynamic_mega_leveraged_pullback", "mega_cap_leader_rotation_aggressive", "mega_cap_leader_rotation_dynamic_top20", + "mega_cap_leader_rotation_top50_balanced", "russell_1000_multi_factor_defensive", } ) @@ -267,6 +269,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): "tech_communication_pullback_enhancement", "mega_cap_leader_rotation_aggressive", "mega_cap_leader_rotation_dynamic_top20", + "mega_cap_leader_rotation_top50_balanced", } assert by_profile["global_etf_rotation"] == { "canonical_profile": "global_etf_rotation", @@ -320,6 +323,10 @@ def test_print_strategy_profile_status_json_matches_registry(): assert by_profile["mega_cap_leader_rotation_dynamic_top20"]["input_mode"] == "feature_snapshot" assert by_profile["mega_cap_leader_rotation_dynamic_top20"]["requires_snapshot_artifacts"] is True assert by_profile["mega_cap_leader_rotation_dynamic_top20"]["requires_strategy_config_path"] is False + assert by_profile["mega_cap_leader_rotation_top50_balanced"]["profile_group"] == "snapshot_backed" + assert by_profile["mega_cap_leader_rotation_top50_balanced"]["input_mode"] == "feature_snapshot" + assert by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_snapshot_artifacts"] is True + assert by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_strategy_config_path"] is False assert by_profile["dynamic_mega_leveraged_pullback"]["profile_group"] == "snapshot_backed" assert ( by_profile["dynamic_mega_leveraged_pullback"]["input_mode"] @@ -390,6 +397,25 @@ def test_print_strategy_switch_env_plan_for_mega_cap_feature_snapshot_profile(): assert "IBKR_RECONCILIATION_OUTPUT_PATH" in plan["remove_if_present"] +def test_print_strategy_switch_env_plan_for_mega_cap_top50_balanced_profile(): + result = subprocess.run( + [sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", "mega_cap_leader_rotation_top50_balanced", "--json"], + check=True, + capture_output=True, + text=True, + ) + + plan = json.loads(result.stdout) + assert plan["canonical_profile"] == "mega_cap_leader_rotation_top50_balanced" + assert plan["profile_group"] == "snapshot_backed" + assert plan["input_mode"] == "feature_snapshot" + assert plan["requires_snapshot_artifacts"] is True + assert plan["requires_strategy_config_path"] is False + assert plan["set_env"]["IBKR_FEATURE_SNAPSHOT_PATH"] == "" + assert plan["set_env"]["IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"] == "" + assert plan["hints"]["feature_snapshot_filename"] == "mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv" + + def test_print_strategy_switch_env_plan_for_feature_snapshot_profile(): result = subprocess.run( [sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", "tech_communication_pullback_enhancement", "--json"], From 1580aa80d08482201d91388b2b78bd12f375ea00 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:43:11 +0800 Subject: [PATCH 2/8] Translate small account warning notes --- notifications/telegram.py | 4 ++++ tests/test_notifications.py | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/notifications/telegram.py b/notifications/telegram.py index e770f25..7e4f3dc 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -67,6 +67,8 @@ "order_filled": "✅ 订单成交 | {symbol} {side} {qty}股 均价 ${price}(订单号: {order_id})", "order_partial": "⚠️ 部分成交 | {symbol} {side} {executed}/{qty}股 均价 ${price}(订单号: {order_id})", "order_rejected": "❌ 订单异常 | {symbol} {side} {qty}股 状态: {status}(订单号: {order_id})", + "small_account_warning_note": "小账户提示:净值 {portfolio_equity} 低于建议 {min_recommended_equity};{reason}", + "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "整数股和最小仓位限制可能导致实盘无法完全复现回测", "strategy_name_global_etf_rotation": "全球 ETF 轮动", "strategy_name_russell_1000_multi_factor_defensive": "罗素1000多因子", "strategy_name_tech_communication_pullback_enhancement": "科技通信回调增强", @@ -141,6 +143,8 @@ "order_filled": "✅ Filled | {symbol} {side} {qty} shares avg ${price} (ID: {order_id})", "order_partial": "⚠️ Partial | {symbol} {side} {executed}/{qty} shares avg ${price} (ID: {order_id})", "order_rejected": "❌ Rejected | {symbol} {side} {qty} shares status: {status} (ID: {order_id})", + "small_account_warning_note": "small account warning: portfolio equity {portfolio_equity} is below recommended {min_recommended_equity}; {reason}", + "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "integer-share minimum position sizing may prevent backtest replication", "strategy_name_global_etf_rotation": "Global ETF Rotation", "strategy_name_russell_1000_multi_factor_defensive": "Russell 1000 Multi-Factor", "strategy_name_tech_communication_pullback_enhancement": "Tech/Communication Pullback Enhancement", diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 7273a81..4c9529b 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -19,6 +19,17 @@ def test_build_translator_supports_chinese(): ) == "SOXX 站上 140 日门槛线,持有 SOXL 70.0% + SOXX 20.0%" ) + assert ( + translate( + "small_account_warning_note", + portfolio_equity="$0", + min_recommended_equity="$1,000", + reason=translate( + "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication" + ), + ) + == "小账户提示:净值 $0 低于建议 $1,000;整数股和最小仓位限制可能导致实盘无法完全复现回测" + ) def test_strategy_display_name_translates_new_live_profiles(): From fe3cfab3f221ca86a5a088a8ea35b572573addbf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:52:22 +0800 Subject: [PATCH 3/8] Add IBKR paper Top50 switch rehearsal --- .github/workflows/ci.yml | 27 ++++--- application/paper_liquidation_service.py | 89 ++++++++++++++++++++++++ docs/ibkr_paper_top50_switch.md | 42 +++++++++++ main.py | 53 ++++++++++++++ notifications/telegram.py | 4 ++ requirements.txt | 2 +- tests/test_notifications.py | 1 + tests/test_paper_liquidation_service.py | 64 +++++++++++++++++ 8 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 application/paper_liquidation_service.py create mode 100644 docs/ibkr_paper_top50_switch.md create mode 100644 tests/test_paper_liquidation_service.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f723a6..2378d3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,9 +17,12 @@ jobs: run: | set -euo pipefail ref="main" - if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/QuantPlatformKit.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then - ref="${GITHUB_HEAD_REF}" - fi + for candidate in "${GITHUB_HEAD_REF:-}" "${GITHUB_BASE_REF:-}"; do + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/QuantPlatformKit.git "$candidate" >/dev/null 2>&1; then + ref="$candidate" + break + fi + done echo "ref=${ref}" >> "$GITHUB_OUTPUT" - name: Resolve UsEquityStrategies ref @@ -27,9 +30,12 @@ jobs: run: | set -euo pipefail ref="main" - if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/UsEquityStrategies.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then - ref="${GITHUB_HEAD_REF}" - fi + for candidate in "${GITHUB_HEAD_REF:-}" "${GITHUB_BASE_REF:-}"; do + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/UsEquityStrategies.git "$candidate" >/dev/null 2>&1; then + ref="$candidate" + break + fi + done echo "ref=${ref}" >> "$GITHUB_OUTPUT" - name: Resolve UsEquitySnapshotPipelines ref @@ -37,9 +43,12 @@ jobs: run: | set -euo pipefail ref="main" - if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/UsEquitySnapshotPipelines.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then - ref="${GITHUB_HEAD_REF}" - fi + for candidate in "${GITHUB_HEAD_REF:-}" "${GITHUB_BASE_REF:-}"; do + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/UsEquitySnapshotPipelines.git "$candidate" >/dev/null 2>&1; then + ref="$candidate" + break + fi + done echo "ref=${ref}" >> "$GITHUB_OUTPUT" - name: Checkout QuantPlatformKit diff --git a/application/paper_liquidation_service.py b/application/paper_liquidation_service.py new file mode 100644 index 0000000..ea3eca1 --- /dev/null +++ b/application/paper_liquidation_service.py @@ -0,0 +1,89 @@ +"""Paper-account liquidation helper for controlled strategy switch rehearsals.""" + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from typing import Any + + +def _position_symbol(position: Any) -> str: + if isinstance(position, dict): + return str(position.get("symbol") or "").strip().upper() + return str(getattr(position, "symbol", "") or "").strip().upper() + + +def _position_quantity(position: Any) -> float: + if isinstance(position, dict): + return float(position.get("quantity") or 0.0) + return float(getattr(position, "quantity", 0.0) or 0.0) + + +def build_liquidation_intents(positions, *, order_intent_cls) -> tuple[Any, ...]: + intents = [] + iterable = positions.values() if isinstance(positions, dict) else positions + for position in iterable or (): + symbol = _position_symbol(position) + quantity = _position_quantity(position) + if not symbol or quantity == 0: + continue + side = "sell" if quantity > 0 else "buy" + intents.append( + order_intent_cls( + symbol=symbol, + side=side, + quantity=abs(quantity), + ) + ) + return tuple(intents) + + +def _report_to_dict(report: Any) -> dict[str, Any]: + if is_dataclass(report): + return asdict(report) + return { + "symbol": getattr(report, "symbol", None), + "side": getattr(report, "side", None), + "quantity": getattr(report, "quantity", None), + "status": getattr(report, "status", None), + "broker_order_id": getattr(report, "broker_order_id", None), + } + + +def execute_paper_liquidation( + ib, + positions, + *, + submit_order_intent, + order_intent_cls, + dry_run_only: bool, +) -> dict[str, Any]: + intents = build_liquidation_intents(positions, order_intent_cls=order_intent_cls) + summary: dict[str, Any] = { + "mode": "dry_run" if dry_run_only else "paper", + "positions_seen": len(positions or ()), + "orders_submitted": [], + "orders_skipped": [], + "execution_status": "no_op" if not intents else "dry_run" if dry_run_only else "executing", + } + if dry_run_only: + summary["orders_submitted"] = [ + { + "symbol": intent.symbol, + "side": intent.side, + "quantity": intent.quantity, + "status": "dry_run", + } + for intent in intents + ] + return summary + + for intent in intents: + report = submit_order_intent(ib, intent) + payload = _report_to_dict(report) + status = str(payload.get("status") or "") + if status in {"Submitted", "PreSubmitted", "Filled", "PartiallyFilled", "Partial"}: + summary["orders_submitted"].append(payload) + else: + summary["orders_skipped"].append({**payload, "reason": status or "submit_failed"}) + summary["execution_status"] = "submitted" if summary["orders_submitted"] else "blocked" + return summary diff --git a/docs/ibkr_paper_top50_switch.md b/docs/ibkr_paper_top50_switch.md new file mode 100644 index 0000000..22d71c0 --- /dev/null +++ b/docs/ibkr_paper_top50_switch.md @@ -0,0 +1,42 @@ +# IBKR Paper Top50 Balanced Switch Runbook + +This branch is for a paper-account rehearsal only. Do not use it for live IBKR. + +## Safety Gates + +- `ACCOUNT_GROUP=default` must resolve to `ib_gateway_mode=paper`. +- `IBKR_PAPER_LIQUIDATE_ONLY=true` is guarded in code and raises if the selected account group is not paper. +- Keep `IBKR_DRY_RUN_ONLY=true` for the first invocation after every deploy or env change. + +## Required Runtime Env + +```text +STRATEGY_PROFILE=mega_cap_leader_rotation_top50_balanced +ACCOUNT_GROUP=default +IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=ibkr-account-groups +IBKR_FEATURE_SNAPSHOT_PATH=gs://qsl-runtime-logs-interactivebrokersquant/strategy-artifacts/us_equity/mega_cap_leader_rotation_top50_balanced_staging/mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv +IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=gs://qsl-runtime-logs-interactivebrokersquant/strategy-artifacts/us_equity/mega_cap_leader_rotation_top50_balanced_staging/mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv.manifest.json +NOTIFY_LANG=zh +``` + +## Rehearsal Sequence + +1. Publish a fresh `mega_cap_leader_rotation_top50_balanced` snapshot/manifest/ranking/release summary to the GCS prefix above. +2. Deploy this branch to `interactive-brokers-quant-service`. +3. Set `IBKR_DRY_RUN_ONLY=true` and invoke once. Confirm the notification is Chinese and the target list is sensible. +4. Set `IBKR_PAPER_LIQUIDATE_ONLY=true` and keep `IBKR_DRY_RUN_ONLY=true`; invoke once to preview paper liquidation orders. +5. Remove `IBKR_DRY_RUN_ONLY`, keep `IBKR_PAPER_LIQUIDATE_ONLY=true`; invoke once to clear the paper account. +6. Remove `IBKR_PAPER_LIQUIDATE_ONLY`; invoke once to run the Top50 balanced strategy on the cleared paper account. +7. Review Telegram, Cloud Logging, and the execution report artifact before any future live discussion. + +## Rollback + +Restore the previous paper strategy env: + +```text +STRATEGY_PROFILE=soxl_soxx_trend_income +unset IBKR_FEATURE_SNAPSHOT_PATH +unset IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH +unset IBKR_PAPER_LIQUIDATE_ONLY +unset IBKR_DRY_RUN_ONLY +``` diff --git a/main.py b/main.py index 97a1d75..c5339ff 100644 --- a/main.py +++ b/main.py @@ -36,6 +36,7 @@ execute_rebalance as application_execute_rebalance, get_market_prices as application_get_market_prices, ) +from application.paper_liquidation_service import execute_paper_liquidation from application.rebalance_service import run_strategy_core as run_rebalance_cycle from decision_mapper import map_strategy_decision from entrypoints.cloud_run import is_market_open_today @@ -158,6 +159,10 @@ def get_ib_connect_timeout_seconds(): return timeout_seconds +def _env_flag(name: str) -> bool: + return str(os.getenv(name) or "").strip().lower() in {"1", "true", "yes", "on"} + + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @@ -205,6 +210,7 @@ def get_ib_connect_timeout_seconds(): or RUNTIME_SETTINGS.strategy_config_source ) RECONCILIATION_OUTPUT_PATH = RUNTIME_SETTINGS.reconciliation_output_path +PAPER_LIQUIDATE_ONLY = _env_flag("IBKR_PAPER_LIQUIDATE_ONLY") TG_TOKEN = RUNTIME_SETTINGS.tg_token TG_CHAT_ID = RUNTIME_SETTINGS.tg_chat_id @@ -476,11 +482,58 @@ def execute_rebalance( ) +def _format_liquidation_orders(orders) -> str: + preview = [] + for order in orders or (): + symbol = str(order.get("symbol") or "").strip().upper() + side = str(order.get("side") or "").strip().lower() + quantity = float(order.get("quantity") or 0.0) + status = str(order.get("status") or "").strip() + if symbol: + preview.append(f"{symbol} {side} {quantity:g} {status}".strip()) + return ", ".join(preview) if preview else t("no_trades") + + +def run_paper_liquidation_cycle(): + if RUNTIME_SETTINGS.ib_gateway_mode != "paper": + raise RuntimeError("IBKR_PAPER_LIQUIDATE_ONLY is only allowed when ib_gateway_mode=paper") + + ib = connect_ib() + try: + positions, _account_values = get_current_portfolio(ib) + summary = execute_paper_liquidation( + ib, + positions, + submit_order_intent=submit_order_intent, + order_intent_cls=OrderIntent, + dry_run_only=RUNTIME_SETTINGS.dry_run_only, + ) + message = ( + f"{t('rebalance_title')}\n" + f"{t('strategy_label', name=strategy_display_name)}\n" + f"{t('paper_liquidation_only')}\n" + f"{t('paper_liquidation_status', mode=summary['mode'], status=summary['execution_status'])}\n" + f"{SEPARATOR}\n" + f"{_format_liquidation_orders(summary.get('orders_submitted'))}" + ) + send_tg_message(message) + print(message, flush=True) + global LAST_CYCLE_DETAILS + LAST_CYCLE_DETAILS = {"execution_summary": summary} + return "OK" + finally: + if ib is not None and hasattr(ib, "disconnect"): + ib.disconnect() + + # --------------------------------------------------------------------------- # Main strategy runner # --------------------------------------------------------------------------- def run_strategy_core(): global LAST_CYCLE_DETAILS + if PAPER_LIQUIDATE_ONLY: + return run_paper_liquidation_cycle() + cycle_details: dict[str, object] = {} result = run_rebalance_cycle( connect_ib=connect_ib, diff --git a/notifications/telegram.py b/notifications/telegram.py index 7e4f3dc..c9e526d 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -67,6 +67,8 @@ "order_filled": "✅ 订单成交 | {symbol} {side} {qty}股 均价 ${price}(订单号: {order_id})", "order_partial": "⚠️ 部分成交 | {symbol} {side} {executed}/{qty}股 均价 ${price}(订单号: {order_id})", "order_rejected": "❌ 订单异常 | {symbol} {side} {qty}股 状态: {status}(订单号: {order_id})", + "paper_liquidation_only": "IBKR 模拟账户清仓模式", + "paper_liquidation_status": "模式={mode} 状态={status}", "small_account_warning_note": "小账户提示:净值 {portfolio_equity} 低于建议 {min_recommended_equity};{reason}", "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "整数股和最小仓位限制可能导致实盘无法完全复现回测", "strategy_name_global_etf_rotation": "全球 ETF 轮动", @@ -143,6 +145,8 @@ "order_filled": "✅ Filled | {symbol} {side} {qty} shares avg ${price} (ID: {order_id})", "order_partial": "⚠️ Partial | {symbol} {side} {executed}/{qty} shares avg ${price} (ID: {order_id})", "order_rejected": "❌ Rejected | {symbol} {side} {qty} shares status: {status} (ID: {order_id})", + "paper_liquidation_only": "IBKR paper liquidation only", + "paper_liquidation_status": "mode={mode} status={status}", "small_account_warning_note": "small account warning: portfolio equity {portfolio_equity} is below recommended {min_recommended_equity}; {reason}", "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "integer-share minimum position sizing may prevent backtest replication", "strategy_name_global_etf_rotation": "Global ETF Rotation", diff --git a/requirements.txt b/requirements.txt index eafe1d3..a364baa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.18 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.28 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@codex/mega-cap-top50-balanced pandas numpy requests diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 4c9529b..e158754 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -8,6 +8,7 @@ def test_build_translator_supports_chinese(): assert translate("target_weights_title") == "目标持仓" assert translate("market_status_risk_on", asset="SOXL") == "🚀 风险开启(SOXL)" assert translate("signal_risk_on", window=150, ratio="40.2%") == "SOXL 站上 150 日均线,持有 SOXL,交易层风险仓位 40.2%" + assert translate("paper_liquidation_only") == "IBKR 模拟账户清仓模式" assert translate("market_status_blend_gate_risk_on", asset="SOXX+SOXL") == "🚀 风险开启(SOXX+SOXL)" assert ( translate( diff --git a/tests/test_paper_liquidation_service.py b/tests/test_paper_liquidation_service.py new file mode 100644 index 0000000..8c02a82 --- /dev/null +++ b/tests/test_paper_liquidation_service.py @@ -0,0 +1,64 @@ +from types import SimpleNamespace + +from quant_platform_kit.common.models import OrderIntent + +from application.paper_liquidation_service import build_liquidation_intents, execute_paper_liquidation + + +def test_build_liquidation_intents_sells_longs_and_buys_shorts(): + positions = { + "AAPL": {"symbol": "AAPL", "quantity": 3}, + "TSLA": {"symbol": "TSLA", "quantity": -2}, + "CASH": {"symbol": "CASH", "quantity": 0}, + } + + intents = build_liquidation_intents(positions, order_intent_cls=OrderIntent) + + assert [(intent.symbol, intent.side, intent.quantity) for intent in intents] == [ + ("AAPL", "sell", 3), + ("TSLA", "buy", 2), + ] + + +def test_execute_paper_liquidation_supports_dry_run(): + positions = {"AAPL": {"symbol": "AAPL", "quantity": 3}} + + summary = execute_paper_liquidation( + object(), + positions, + submit_order_intent=lambda *_args: None, + order_intent_cls=OrderIntent, + dry_run_only=True, + ) + + assert summary["mode"] == "dry_run" + assert summary["execution_status"] == "dry_run" + assert summary["orders_submitted"] == [ + {"symbol": "AAPL", "side": "sell", "quantity": 3, "status": "dry_run"} + ] + + +def test_execute_paper_liquidation_submits_market_orders(): + submitted = [] + + def submit(_ib, intent): + submitted.append(intent) + return SimpleNamespace( + symbol=intent.symbol, + side=intent.side, + quantity=intent.quantity, + status="Submitted", + broker_order_id="1", + ) + + summary = execute_paper_liquidation( + object(), + {"AAPL": {"symbol": "AAPL", "quantity": 3}}, + submit_order_intent=submit, + order_intent_cls=OrderIntent, + dry_run_only=False, + ) + + assert [(intent.symbol, intent.side, intent.quantity) for intent in submitted] == [("AAPL", "sell", 3)] + assert summary["execution_status"] == "submitted" + assert summary["orders_submitted"][0]["symbol"] == "AAPL" From 694efa5f04c267be11bbf1b06a2d155fe81aa677 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:33:35 +0800 Subject: [PATCH 4/8] Retry paper liquidation position fetch --- application/paper_liquidation_service.py | 4 ++-- main.py | 6 ++++++ notifications/telegram.py | 2 ++ tests/test_notifications.py | 1 + tests/test_paper_liquidation_service.py | 2 +- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/application/paper_liquidation_service.py b/application/paper_liquidation_service.py index ea3eca1..9698e6c 100644 --- a/application/paper_liquidation_service.py +++ b/application/paper_liquidation_service.py @@ -14,8 +14,8 @@ def _position_symbol(position: Any) -> str: def _position_quantity(position: Any) -> float: if isinstance(position, dict): - return float(position.get("quantity") or 0.0) - return float(getattr(position, "quantity", 0.0) or 0.0) + return float(position.get("quantity", position.get("position", 0.0)) or 0.0) + return float(getattr(position, "quantity", getattr(position, "position", 0.0)) or 0.0) def build_liquidation_intents(positions, *, order_intent_cls) -> tuple[Any, ...]: diff --git a/main.py b/main.py index c5339ff..a7f3d27 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ """IBKR strategy runner for shared us_equity strategy profiles.""" import os +import time import traceback from datetime import datetime, timezone from zoneinfo import ZoneInfo @@ -501,6 +502,10 @@ def run_paper_liquidation_cycle(): ib = connect_ib() try: positions, _account_values = get_current_portfolio(ib) + if not positions: + print("paper_liquidation_positions_empty_retry", flush=True) + time.sleep(2.0) + positions, _account_values = get_current_portfolio(ib) summary = execute_paper_liquidation( ib, positions, @@ -513,6 +518,7 @@ def run_paper_liquidation_cycle(): f"{t('strategy_label', name=strategy_display_name)}\n" f"{t('paper_liquidation_only')}\n" f"{t('paper_liquidation_status', mode=summary['mode'], status=summary['execution_status'])}\n" + f"{t('paper_liquidation_positions_seen', count=summary['positions_seen'])}\n" f"{SEPARATOR}\n" f"{_format_liquidation_orders(summary.get('orders_submitted'))}" ) diff --git a/notifications/telegram.py b/notifications/telegram.py index c9e526d..871bced 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -69,6 +69,7 @@ "order_rejected": "❌ 订单异常 | {symbol} {side} {qty}股 状态: {status}(订单号: {order_id})", "paper_liquidation_only": "IBKR 模拟账户清仓模式", "paper_liquidation_status": "模式={mode} 状态={status}", + "paper_liquidation_positions_seen": "识别持仓={count}", "small_account_warning_note": "小账户提示:净值 {portfolio_equity} 低于建议 {min_recommended_equity};{reason}", "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "整数股和最小仓位限制可能导致实盘无法完全复现回测", "strategy_name_global_etf_rotation": "全球 ETF 轮动", @@ -147,6 +148,7 @@ "order_rejected": "❌ Rejected | {symbol} {side} {qty} shares status: {status} (ID: {order_id})", "paper_liquidation_only": "IBKR paper liquidation only", "paper_liquidation_status": "mode={mode} status={status}", + "paper_liquidation_positions_seen": "positions_seen={count}", "small_account_warning_note": "small account warning: portfolio equity {portfolio_equity} is below recommended {min_recommended_equity}; {reason}", "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "integer-share minimum position sizing may prevent backtest replication", "strategy_name_global_etf_rotation": "Global ETF Rotation", diff --git a/tests/test_notifications.py b/tests/test_notifications.py index e158754..16a6655 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -9,6 +9,7 @@ def test_build_translator_supports_chinese(): assert translate("market_status_risk_on", asset="SOXL") == "🚀 风险开启(SOXL)" assert translate("signal_risk_on", window=150, ratio="40.2%") == "SOXL 站上 150 日均线,持有 SOXL,交易层风险仓位 40.2%" assert translate("paper_liquidation_only") == "IBKR 模拟账户清仓模式" + assert translate("paper_liquidation_positions_seen", count=4) == "识别持仓=4" assert translate("market_status_blend_gate_risk_on", asset="SOXX+SOXL") == "🚀 风险开启(SOXX+SOXL)" assert ( translate( diff --git a/tests/test_paper_liquidation_service.py b/tests/test_paper_liquidation_service.py index 8c02a82..9a10b29 100644 --- a/tests/test_paper_liquidation_service.py +++ b/tests/test_paper_liquidation_service.py @@ -8,7 +8,7 @@ def test_build_liquidation_intents_sells_longs_and_buys_shorts(): positions = { "AAPL": {"symbol": "AAPL", "quantity": 3}, - "TSLA": {"symbol": "TSLA", "quantity": -2}, + "TSLA": {"symbol": "TSLA", "position": -2}, "CASH": {"symbol": "CASH", "quantity": 0}, } From cb1e156ec1696ed664f9261474d39d01b87dbccf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:39:14 +0800 Subject: [PATCH 5/8] Use position keys for paper liquidation symbols --- application/paper_liquidation_service.py | 16 ++++++++++------ tests/test_paper_liquidation_service.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/application/paper_liquidation_service.py b/application/paper_liquidation_service.py index 9698e6c..80ebbcd 100644 --- a/application/paper_liquidation_service.py +++ b/application/paper_liquidation_service.py @@ -6,10 +6,10 @@ from typing import Any -def _position_symbol(position: Any) -> str: +def _position_symbol(position: Any, *, fallback: str | None = None) -> str: if isinstance(position, dict): - return str(position.get("symbol") or "").strip().upper() - return str(getattr(position, "symbol", "") or "").strip().upper() + return str(position.get("symbol") or fallback or "").strip().upper() + return str(getattr(position, "symbol", fallback or "") or "").strip().upper() def _position_quantity(position: Any) -> float: @@ -20,9 +20,13 @@ def _position_quantity(position: Any) -> float: def build_liquidation_intents(positions, *, order_intent_cls) -> tuple[Any, ...]: intents = [] - iterable = positions.values() if isinstance(positions, dict) else positions - for position in iterable or (): - symbol = _position_symbol(position) + iterable = ( + positions.items() + if isinstance(positions, dict) + else ((None, position) for position in positions or ()) + ) + for symbol_hint, position in iterable: + symbol = _position_symbol(position, fallback=symbol_hint) quantity = _position_quantity(position) if not symbol or quantity == 0: continue diff --git a/tests/test_paper_liquidation_service.py b/tests/test_paper_liquidation_service.py index 9a10b29..af0b7d0 100644 --- a/tests/test_paper_liquidation_service.py +++ b/tests/test_paper_liquidation_service.py @@ -20,6 +20,20 @@ def test_build_liquidation_intents_sells_longs_and_buys_shorts(): ] +def test_build_liquidation_intents_uses_dict_keys_as_symbol_fallbacks(): + positions = { + "AAPL": {"quantity": 3}, + "TSLA": {"position": -2}, + } + + intents = build_liquidation_intents(positions, order_intent_cls=OrderIntent) + + assert [(intent.symbol, intent.side, intent.quantity) for intent in intents] == [ + ("AAPL", "sell", 3), + ("TSLA", "buy", 2), + ] + + def test_execute_paper_liquidation_supports_dry_run(): positions = {"AAPL": {"symbol": "AAPL", "quantity": 3}} From a862899ca679d287fad88ae544f1a6aee7758d5c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:46:39 +0800 Subject: [PATCH 6/8] Set explicit IBKR order TIF --- application/ibkr_order_execution.py | 60 ++++++++++++++++++++++++++++ main.py | 2 +- tests/test_ibkr_order_execution.py | 61 +++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 application/ibkr_order_execution.py create mode 100644 tests/test_ibkr_order_execution.py diff --git a/application/ibkr_order_execution.py b/application/ibkr_order_execution.py new file mode 100644 index 0000000..711c7a4 --- /dev/null +++ b/application/ibkr_order_execution.py @@ -0,0 +1,60 @@ +"""IBKR order submission adapters for platform-specific broker quirks.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Callable + +from quant_platform_kit.common.models import ExecutionReport, OrderIntent +from quant_platform_kit.ibkr.execution import submit_order_intent as _submit_order_intent + +DEFAULT_TIME_IN_FORCE = "DAY" + + +def _intent_with_default_time_in_force(order_intent: OrderIntent) -> OrderIntent: + if order_intent.time_in_force: + return order_intent + return replace(order_intent, time_in_force=DEFAULT_TIME_IN_FORCE) + + +def _market_order_factory_with_time_in_force( + market_order_factory: Callable[..., Any] | None, + *, + time_in_force: str, +) -> Callable[..., Any]: + def factory(side: str, quantity: float) -> Any: + factory_impl = market_order_factory + if factory_impl is None: + from ib_insync import MarketOrder + + factory_impl = MarketOrder + order = factory_impl(side, quantity) + order.tif = time_in_force + return order + + return factory + + +def submit_order_intent( + ib: Any, + order_intent: OrderIntent, + *, + wait_seconds: float = 1.0, + stock_factory: Callable[..., Any] | None = None, + market_order_factory: Callable[..., Any] | None = None, + limit_order_factory: Callable[..., Any] | None = None, +) -> ExecutionReport: + """Submit an IBKR order with explicit TIF to avoid account-preset rejections.""" + + intent = _intent_with_default_time_in_force(order_intent) + return _submit_order_intent( + ib, + intent, + wait_seconds=wait_seconds, + stock_factory=stock_factory, + market_order_factory=_market_order_factory_with_time_in_force( + market_order_factory, + time_in_force=intent.time_in_force or DEFAULT_TIME_IN_FORCE, + ), + limit_order_factory=limit_order_factory, + ) diff --git a/main.py b/main.py index a7f3d27..4843ead 100644 --- a/main.py +++ b/main.py @@ -30,8 +30,8 @@ fetch_historical_price_series, fetch_portfolio_snapshot, fetch_quote_snapshots, - submit_order_intent, ) +from application.ibkr_order_execution import submit_order_intent from application.execution_service import ( check_order_submitted as application_check_order_submitted, execute_rebalance as application_execute_rebalance, diff --git a/tests/test_ibkr_order_execution.py b/tests/test_ibkr_order_execution.py new file mode 100644 index 0000000..e22214d --- /dev/null +++ b/tests/test_ibkr_order_execution.py @@ -0,0 +1,61 @@ +from types import SimpleNamespace + +from quant_platform_kit.common.models import OrderIntent + +from application.ibkr_order_execution import submit_order_intent + + +class FakeMarketOrder: + def __init__(self, side, quantity): + self.action = side + self.totalQuantity = quantity + self.tif = "" + + +class FakeIB: + def __init__(self): + self.placed_order = None + + def qualifyContracts(self, _contract): + return None + + def placeOrder(self, _contract, order): + self.placed_order = order + return SimpleNamespace( + order=SimpleNamespace(orderId=42), + orderStatus=SimpleNamespace(status="Submitted", filled=0, avgFillPrice=0), + ) + + +def fake_stock(symbol, exchange, currency): + return SimpleNamespace(symbol=symbol, exchange=exchange, currency=currency) + + +def test_submit_order_intent_sets_default_day_tif_on_market_orders(): + ib = FakeIB() + + report = submit_order_intent( + ib, + OrderIntent(symbol="AAPL", side="sell", quantity=3), + wait_seconds=0, + stock_factory=fake_stock, + market_order_factory=FakeMarketOrder, + ) + + assert ib.placed_order.tif == "DAY" + assert report.status == "Submitted" + assert report.raw_payload["time_in_force"] == "DAY" + + +def test_submit_order_intent_preserves_explicit_tif_on_market_orders(): + ib = FakeIB() + + submit_order_intent( + ib, + OrderIntent(symbol="AAPL", side="sell", quantity=3, time_in_force="GTC"), + wait_seconds=0, + stock_factory=fake_stock, + market_order_factory=FakeMarketOrder, + ) + + assert ib.placed_order.tif == "GTC" From 8fe1c5e56bb08b4c47b9dd452a18cf7d4429e986 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:51:24 +0800 Subject: [PATCH 7/8] Report no-op paper liquidation cleanly --- application/paper_liquidation_service.py | 3 +++ tests/test_paper_liquidation_service.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/application/paper_liquidation_service.py b/application/paper_liquidation_service.py index 80ebbcd..347fd75 100644 --- a/application/paper_liquidation_service.py +++ b/application/paper_liquidation_service.py @@ -69,6 +69,9 @@ def execute_paper_liquidation( "orders_skipped": [], "execution_status": "no_op" if not intents else "dry_run" if dry_run_only else "executing", } + if not intents: + return summary + if dry_run_only: summary["orders_submitted"] = [ { diff --git a/tests/test_paper_liquidation_service.py b/tests/test_paper_liquidation_service.py index af0b7d0..8ce8939 100644 --- a/tests/test_paper_liquidation_service.py +++ b/tests/test_paper_liquidation_service.py @@ -52,6 +52,20 @@ def test_execute_paper_liquidation_supports_dry_run(): ] +def test_execute_paper_liquidation_reports_no_op_when_no_positions(): + summary = execute_paper_liquidation( + object(), + {}, + submit_order_intent=lambda *_args: None, + order_intent_cls=OrderIntent, + dry_run_only=False, + ) + + assert summary["mode"] == "paper" + assert summary["execution_status"] == "no_op" + assert summary["orders_submitted"] == [] + + def test_execute_paper_liquidation_submits_market_orders(): submitted = [] From 384345d994aa6007547eea39a4d9274670c2f2d0 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:18:17 +0800 Subject: [PATCH 8/8] Use main strategies dependency --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a364baa..ba28448 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.18 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@codex/mega-cap-top50-balanced +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@main pandas numpy requests