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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,13 @@ jobs:
set -euo pipefail
python - <<'PY'
from quant_platform_kit.common.port_adapters import CallableNotificationPort, CallablePortfolioPort
from hk_equity_strategies import resolve_canonical_profile as resolve_hk_canonical_profile
from us_equity_strategies import resolve_canonical_profile

assert CallableNotificationPort
assert CallablePortfolioPort
assert resolve_canonical_profile("mega_cap_leader_rotation_top50_balanced") == "mega_cap_leader_rotation_top50_balanced"
assert resolve_hk_canonical_profile("hk_blue_chip_leader_rotation") == "hk_blue_chip_leader_rotation"
PY

- name: Install editable shared repositories
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
env:
ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }}
ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }}
ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }}
GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }}
CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}
CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}
Expand All @@ -39,6 +40,12 @@ jobs:
IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }}
IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }}
IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }}
IBKR_MARKET: ${{ vars.IBKR_MARKET }}
IBKR_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }}
IBKR_MARKET_CURRENCY: ${{ vars.IBKR_MARKET_CURRENCY }}
IBKR_MARKET_DATA_SYMBOL_SUFFIX: ${{ vars.IBKR_MARKET_DATA_SYMBOL_SUFFIX }}
IBKR_MARKET_EXCHANGE: ${{ vars.IBKR_MARKET_EXCHANGE }}
IBKR_MARKET_TIMEZONE: ${{ vars.IBKR_MARKET_TIMEZONE }}
IBKR_MIN_RESERVED_CASH_USD: ${{ vars.IBKR_MIN_RESERVED_CASH_USD }}
IBKR_RESERVED_CASH_RATIO: ${{ vars.IBKR_RESERVED_CASH_RATIO }}
IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}
Expand Down Expand Up @@ -93,6 +100,14 @@ jobs:
deploy_enabled=false
env_sync_enabled=false

if [ "${GITHUB_EVENT_NAME:-}" = "push" ] && [ "${ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION:-}" != "true" ]; then
echo "deploy_enabled=false" >> "$GITHUB_OUTPUT"
echo "env_sync_enabled=false" >> "$GITHUB_OUTPUT"
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true." >&2
exit 0
fi

if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ]; then
deploy_enabled=true
fi
Expand Down
70 changes: 49 additions & 21 deletions README.md

Large diffs are not rendered by default.

25 changes: 24 additions & 1 deletion application/ibkr_order_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@
DEFAULT_TIME_IN_FORCE = "DAY"


def _stock_factory_for_market(
stock_factory: Callable[..., Any] | None,
*,
exchange: str,
currency: str,
) -> Callable[..., Any]:
def factory(symbol: str, _exchange: str = "SMART", _currency: str = "USD") -> Any:
factory_impl = stock_factory
if factory_impl is None:
from ib_insync import Stock

factory_impl = Stock
return factory_impl(symbol, exchange, currency)

return factory


def _intent_with_default_time_in_force(order_intent: OrderIntent) -> OrderIntent:
if order_intent.time_in_force:
return order_intent
Expand Down Expand Up @@ -47,6 +64,8 @@ def submit_order_intent(
combo_leg_factory: Callable[..., Any] | None = None,
market_order_factory: Callable[..., Any] | None = None,
limit_order_factory: Callable[..., Any] | None = None,
stock_exchange: str = "SMART",
stock_currency: str = "USD",
) -> ExecutionReport:
"""Submit an IBKR order with explicit TIF to avoid account-preset rejections."""

Expand All @@ -56,7 +75,11 @@ def submit_order_intent(
intent,
account_id=account_id,
wait_seconds=wait_seconds,
stock_factory=stock_factory,
stock_factory=_stock_factory_for_market(
stock_factory,
exchange=str(stock_exchange or "SMART").upper(),
currency=str(stock_currency or "USD").upper(),
),
option_factory=option_factory,
combo_contract_factory=combo_contract_factory,
combo_leg_factory=combo_leg_factory,
Expand Down
121 changes: 121 additions & 0 deletions application/ibkr_portfolio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""IBKR portfolio snapshot helpers with market-currency awareness."""

from __future__ import annotations

from collections.abc import Iterable
from datetime import datetime, timezone
from typing import Any

from quant_platform_kit.common.models import PortfolioSnapshot, Position


def _normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]:
if account_ids is None:
return ()
if isinstance(account_ids, str):
candidates = [account_ids]
else:
candidates = list(account_ids)
normalized = []
for candidate in candidates:
text = str(candidate or "").strip()
if text:
normalized.append(text)
return tuple(dict.fromkeys(normalized))


def _matches_account(account_id: str | None, selected_account_ids: tuple[str, ...]) -> bool:
if not selected_account_ids:
return True
return str(account_id or "").strip() in selected_account_ids


def fetch_portfolio_snapshot(
ib: Any,
*,
account_ids: Iterable[str] | str | None = None,
wait_seconds: float = 1.0,
currency: str = "USD",
) -> PortfolioSnapshot:
"""Fetch stock positions and account values for the configured trading currency.

QuantPlatformKit's default IBKR helper is USD-oriented. Keeping this small
adapter local lets the platform run US and HK services without changing the
shared package release line.
"""

selected_account_ids = _normalize_account_ids(account_ids)
market_currency = str(currency or "USD").strip().upper()
ib.reqPositions()
if wait_seconds:
import time as time_module

time_module.sleep(wait_seconds)

positions = []
option_positions = []
for raw_position in ib.positions():
account_id = str(getattr(raw_position, "account", "") or "").strip() or None
if not _matches_account(account_id, selected_account_ids):
continue
if raw_position.position == 0:
continue
contract = raw_position.contract
quantity = float(raw_position.position)
average_cost = float(raw_position.avgCost)
contract_currency = str(getattr(contract, "currency", "") or "").strip().upper()
if not contract_currency:
contract_currency = market_currency
if str(getattr(contract, "secType", "") or "").strip().upper() == "OPT":
option_positions.append(
{
"underlier": str(getattr(contract, "symbol", "") or "").strip().upper(),
"local_symbol": str(getattr(contract, "localSymbol", "") or "").strip(),
"expiration": str(
getattr(contract, "lastTradeDateOrContractMonth", "") or ""
).strip(),
"right": str(getattr(contract, "right", "") or "").strip().upper(),
"strike": float(getattr(contract, "strike", 0.0) or 0.0),
"quantity": quantity,
"average_cost": average_cost,
"cost_basis": abs(quantity * average_cost),
"account_id": account_id,
"currency": contract_currency,
}
)
continue
positions.append(

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 Filter positions to the configured market currency

When a selected account also holds a position whose contract.currency differs from MARKET_CURRENCY (for example USD AAPL in an HKD account), this still adds it to the portfolio snapshot. get_current_portfolio then feeds every snapshot position into rebalancing, while the new quote/order wrappers force all contracts to the configured MARKET_EXCHANGE/MARKET_CURRENCY, so the runner can try to quote or submit an invalid HKD/SEHK order for a USD symbol. Since account values are already filtered by market_currency, skip or separately quarantine non-market-currency positions here.

Useful? React with 👍 / 👎.

Position(
symbol=str(getattr(contract, "symbol", "") or "").strip().upper(),
quantity=quantity,
market_value=quantity * average_cost,
average_cost=average_cost,
currency=contract_currency,
)
)

total_equity = 0.0
buying_power = None
for account_value in ib.accountValues():
account_id = str(getattr(account_value, "account", "") or "").strip() or None
if not _matches_account(account_id, selected_account_ids):
continue
if str(getattr(account_value, "currency", "") or "").strip().upper() != market_currency:
continue
if account_value.tag == "NetLiquidation":
total_equity += float(account_value.value)
elif account_value.tag == "AvailableFunds":
value = float(account_value.value)
buying_power = value if buying_power is None else buying_power + value

return PortfolioSnapshot(
as_of=datetime.now(timezone.utc),
total_equity=total_equity,
buying_power=buying_power,
positions=tuple(positions),
metadata={
"account_ids": selected_account_ids,
"option_positions": tuple(option_positions),
"currency": market_currency,
},
)
4 changes: 4 additions & 0 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class IBKRRuntimeBrokerAdapters:
separator: str
strategy_display_name: str
sleep_fn: Any
market_currency: str = "USD"
printer: Any = print

def validate_configured_accounts(self, ib):
Expand Down Expand Up @@ -142,6 +143,7 @@ def build_portfolio_snapshot(self, ib, *, get_current_portfolio_fallback=None):
quantity=float(details.get("quantity") or 0),
market_value=float(details.get("quantity") or 0) * float(details.get("avg_cost") or 0.0),
average_cost=float(details.get("avg_cost") or 0.0),
currency=self.market_currency,
)
for symbol, details in dict(positions or {}).items()
),
Expand Down Expand Up @@ -281,6 +283,7 @@ def build_runtime_broker_adapters(
separator: str,
strategy_display_name: str,
sleep_fn,
market_currency: str = "USD",
printer=print,
) -> IBKRRuntimeBrokerAdapters:
return IBKRRuntimeBrokerAdapters(
Expand Down Expand Up @@ -318,5 +321,6 @@ def build_runtime_broker_adapters(
separator=str(separator or ""),
strategy_display_name=str(strategy_display_name or ""),
sleep_fn=sleep_fn,
market_currency=str(market_currency or "USD").upper(),
printer=printer,
)
13 changes: 9 additions & 4 deletions decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@
from collections.abc import Mapping
from typing import Any

from us_equity_strategies.catalog import resolve_canonical_profile

from quant_platform_kit.strategy_contracts import (
StrategyDecision,
build_allocation_intent,
build_allocation_payload,
translate_decision_to_target_mode,
)
from strategy_registry import IBKR_PLATFORM, resolve_strategy_definition


_EMERGENCY_FLAGS = frozenset({"emergency", "hard_defense"})
_NO_EXECUTE_FLAGS = frozenset({"no_execute"})


def _resolve_allocation_order(strategy_profile: str) -> str:
canonical_profile = resolve_canonical_profile(strategy_profile)
canonical_profile = resolve_strategy_definition(
strategy_profile,
platform_id=IBKR_PLATFORM,
).profile
if canonical_profile == "soxl_soxx_trend_income":
return "risk_income_safe"
return "risk_safe_income"
Expand Down Expand Up @@ -133,7 +135,10 @@ def map_strategy_decision(
runtime_metadata: Mapping[str, Any] | None = None,
) -> tuple[dict[str, float] | None, str, bool, str, dict[str, Any]]:
runtime_metadata = dict(runtime_metadata or {})
canonical_profile = resolve_canonical_profile(strategy_profile)
canonical_profile = resolve_strategy_definition(
strategy_profile,
platform_id=IBKR_PLATFORM,
).profile
diagnostics = dict(decision.diagnostics)
risk_flags = tuple(str(flag) for flag in decision.risk_flags)
no_execute = bool(_NO_EXECUTE_FLAGS & set(risk_flags))
Expand Down
90 changes: 90 additions & 0 deletions docs/hk_equity_runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# IBKR 港股运行时接入说明

## 结论

QuantStrategyLab 现有平台仓库里,能接入港股股票交易的平台是 `InteractiveBrokersPlatform` 和 `LongBridgePlatform`。

| 平台仓库 | 港股交易接入判断 | 当前处理 |
| --- | --- | --- |
| `InteractiveBrokersPlatform` | 可接入。IBKR 支持 SEHK/HKD 合约,但账户必须开通港股交易和行情权限。 | 已加入 HK market scope、SEHK/HKD 合约参数、HKD portfolio 口径、通知和结构化日志字段。 |
| `LongBridgePlatform` | 可接入。LongBridge 支持港股账户、`.HK` 行情符号和 HKD 现金口径。 | 在对应仓库单独接入。 |
| `CharlesSchwabPlatform` | 不适合作为港股交易入口。 | 保持 US equity 边界,不改。 |
| `FirstradePlatform` | 不适合作为港股交易入口。 | 保持 US equity 边界,不改。 |
| `BinancePlatform` | 加密货币平台,不是港股股票交易入口。 | 不改。 |

## 运行时设计

本仓库只做券商运行时能力,不把港股策略逻辑硬编码进平台。当前已接入 `HkEquityStrategies` 的港股 profile 元数据:`hk_blue_chip_leader_rotation` 是架构占位,`hk_index_mean_reversion`、`hk_etf_regime_rotation` 和 `hk_listed_global_etf_rotation` 是 `market_history` 研究候选。这些 profile 都只用于框架 wiring、feed/dry-run 兼容性检查,尚未 runtime-enabled。整体沿用美股策略的架构:

1. [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies) 提供 `hk_equity` 策略 profile、运行入口和 IBKR runtime adapter。
2. [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 发布 snapshot-backed profile 的 `<profile>_feature_snapshot_latest.csv`、manifest、ranking 和 release summary。
3. 非 snapshot profile 使用平台 market-data feed 提供的 `market_history`,不需要 snapshot artifact。
4. 平台仓库通过 `RUNTIME_TARGET_JSON`、snapshot/config 路径和平台 market scope 读取策略输入。
5. IBKR 运行时根据 market scope 选择 SEHK/HKD 合约、HKD 账户口径、XHKG 日历和通知/日志字段。

这样可以复用现有 US snapshot 的 artifact contract,同时保持平台仓只负责执行、账户、通知和运行报告。

## 港股 profile 当前状态

| Profile | Domain | Inputs | Target mode | Snapshot manifest | Status |
| --- | --- | --- | --- | --- | --- |
| `hk_blue_chip_leader_rotation` | `hk_equity` | `feature_snapshot` | `weight` | required | eligible but disabled |
| `hk_index_mean_reversion` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled |
| `hk_etf_regime_rotation` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled |
| `hk_listed_global_etf_rotation` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled |

未来启用 snapshot-backed profile 后的最小策略配置示例;当前不要写入 Cloud Run:

```bash
STRATEGY_PROFILE=hk_blue_chip_leader_rotation
ACCOUNT_GROUP=hk-live
RUNTIME_TARGET_JSON={"platform_id":"ibkr","strategy_profile":"hk_blue_chip_leader_rotation","deployment_selector":"hk-live","account_scope":"hk-live","execution_mode":"live"}
IBKR_FEATURE_SNAPSHOT_PATH=gs://<bucket>/hk_blue_chip_leader_rotation_feature_snapshot_latest.csv
IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=gs://<bucket>/hk_blue_chip_leader_rotation_feature_snapshot_latest.csv.manifest.json
```

## 配置项

| 变量 | 默认值 | 港股建议值 | 说明 |
| --- | --- | --- | --- |
| `ACCOUNT_GROUP` | 必填 | 例如 `hk-live` | 包含 `hk` 时会推导港股默认 market scope。 |
| `IBKR_MARKET` | 从 `ACCOUNT_GROUP` 推导,默认 `US` | `HK` | 显式指定市场;优先级高于 `ACCOUNT_GROUP` 推导。 |
| `IBKR_MARKET_CALENDAR` | `NYSE` / 港股为 `XHKG` | `XHKG` | 市场日历。 |
| `IBKR_MARKET_TIMEZONE` | `America/New_York` / 港股为 `Asia/Hong_Kong` | `Asia/Hong_Kong` | 市场时区。 |
| `IBKR_MARKET_EXCHANGE` | `SMART` / 港股为 `SEHK` | `SEHK` | 股票合约交易所。 |
| `IBKR_MARKET_CURRENCY` | `USD` / 港股为 `HKD` | `HKD` | 合约币种、账户净值和购买力过滤口径。 |
| `IBKR_MARKET_DATA_SYMBOL_SUFFIX` | 空 / 港股为 `.HK` | `.HK` | yfinance fallback 行情符号后缀;IBKR 合约本身不附加该后缀。 |

最小港股配置:

```bash
ACCOUNT_GROUP=hk-live
# 可选显式覆盖:
IBKR_MARKET=HK
IBKR_MARKET_CALENDAR=XHKG
IBKR_MARKET_TIMEZONE=Asia/Hong_Kong
IBKR_MARKET_EXCHANGE=SEHK
IBKR_MARKET_CURRENCY=HKD
IBKR_MARKET_DATA_SYMBOL_SUFFIX=.HK
```

## 订单、组合和行情口径

- 股票订单通过 `Stock(symbol, IBKR_MARKET_EXCHANGE, IBKR_MARKET_CURRENCY)` 构造;港股默认是 `SEHK/HKD`。
- Portfolio snapshot 只汇总配置币种的 `NetLiquidation` 和 `AvailableFunds`;港股默认是 HKD。
- IBKR 历史行情和 quote snapshot 会使用配置的 exchange/currency。
- yfinance fallback 会给无后缀 symbol 追加 `IBKR_MARKET_DATA_SYMBOL_SUFFIX`,例如 `00700` -> `00700.HK`。

## 通知和日志

- Telegram 中英文模板新增市场行:市场、交易币种、交易所和日历。
- Runtime report / structured log context 新增:`market`、`market_calendar`、`market_currency`、`market_data_symbol_suffix`、`market_exchange`、`market_timezone`。
- 市场关闭跳过等事件会带上 market scope,便于区分 US/HK 服务。

## 风险和注意事项

- IBKR 港股实盘依赖账户权限、行情权限、Gateway 登录账户可见账号和交易许可;平台配置无法替代这些权限。
- 不同 IBKR 账户或区域对港股 symbol 格式可能有差异,首批上线前需要用 dry-run 和小范围 symbol 做实盘连接验证。
- `XHKG` 是否可用取决于部署环境里的 `pandas_market_calendars` 版本;如不可用,可用 `IBKR_MARKET_CALENDAR` 临时覆盖。
- `hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation`、`hk_listed_global_etf_rotation` 当前均未启用;不要把这些 profile 写入生产 Cloud Run。
- `market_history` 研究候选后续真正启用前,需要先用 IBKR HK 行情 feed 对 `02800`、`03033`、`02822`、`02840`、`03110`、`03188`、`02834`、`03175` 做 dry-run 校验,不提交真实订单。
Loading