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
27 changes: 18 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,38 @@ 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
id: us-equity-strategies-ref
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
id: us-equity-snapshot-pipelines-ref
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
Expand Down
60 changes: 60 additions & 0 deletions application/ibkr_order_execution.py
Original file line number Diff line number Diff line change
@@ -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,
)
96 changes: 96 additions & 0 deletions application/paper_liquidation_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""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, *, fallback: str | None = None) -> str:
if isinstance(position, dict):
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:
if isinstance(position, dict):
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, ...]:
intents = []
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
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 not intents:
return summary

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
42 changes: 42 additions & 0 deletions docs/ibkr_paper_top50_switch.md
Original file line number Diff line number Diff line change
@@ -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
```
61 changes: 60 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,13 +30,14 @@
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,
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
Expand Down Expand Up @@ -158,6 +160,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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -205,6 +211,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
Expand Down Expand Up @@ -476,11 +483,63 @@ 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)
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,
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"{t('paper_liquidation_positions_seen', count=summary['positions_seen'])}\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,
Expand Down
6 changes: 6 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
"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}",
"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 轮动",
Expand Down Expand Up @@ -143,6 +146,9 @@
"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}",
"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",
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading