From a68529f4f3f37947a7543550ecbc0260877733dc 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 1/5] 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 4897a16564a51dda44783adfde661a8ce7a2984e 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 2/5] 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 f32b4a8df82a3b0a2d4a894e0fc92b3ad5d24a26 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 3/5] 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 a522724c72b602a63faf2cd597376663f712d924 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 4/5] 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 b9347dee1c256cbac982027c6070be42fb5e3754 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 5/5] 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 = []