Skip to content

Commit 000418f

Browse files
committed
Route IBKR live orders by account group
1 parent 8976786 commit 000418f

9 files changed

Lines changed: 217 additions & 14 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ Recommended account-group config payload:
197197
}
198198
```
199199

200-
If you later add live, keep it as a separate live group such as `live-main` with `ib_gateway_mode=live` and its own `account_ids`.
200+
For live multi-account rollout, keep one Cloud Run service per live account group. Each live group should carry exactly one `account_ids` value so portfolio reads, pending/fill guards, and submitted IBKR orders are all routed to that account.
201201

202202
See [`docs/examples/ibkr-account-groups.paper.json`](docs/examples/ibkr-account-groups.paper.json) for a ready-to-edit starter example, and [`docs/ibkr_runtime_rollout.md`](docs/ibkr_runtime_rollout.md) for the exact rollout steps to get `ACCOUNT_GROUP=paper` running.
203203

@@ -403,6 +403,8 @@ IB_GATEWAY_IP_MODE=internal
403403

404404
仓库里也提供了一个可以直接改的起始样例:[`docs/examples/ibkr-account-groups.paper.json`](docs/examples/ibkr-account-groups.paper.json)。如果你要按 `ACCOUNT_GROUP=paper` 先落地,直接看 [`docs/ibkr_runtime_rollout.md`](docs/ibkr_runtime_rollout.md)
405405

406+
实盘多账户建议一个 UID 对应一个 Cloud Run 服务和一个账号组。每个实盘账号组只放一个 `account_ids` 值;运行时会用它过滤持仓、pending/fill 检查,并把同一个 UID 写进 IBKR 订单的 `order.account`
407+
406408
当前行为改成了 fail-fast:
407409

408410
- 没有 `STRATEGY_PROFILE` → 启动直接报错

application/execution_service.py

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,44 @@ def check_order_submitted(report, *, translator):
6464
return False, f"❌ {translator('failed', reason=status)}"
6565

6666

67-
def get_available_buying_power(ib, fallback_buying_power):
67+
def _normalize_account_ids(account_ids=None) -> tuple[str, ...]:
68+
if account_ids is None:
69+
return ()
70+
if isinstance(account_ids, str):
71+
candidates = [account_ids]
72+
else:
73+
candidates = list(account_ids)
74+
normalized = []
75+
for candidate in candidates:
76+
text = str(candidate or "").strip()
77+
if text:
78+
normalized.append(text)
79+
return tuple(dict.fromkeys(normalized))
80+
81+
82+
def _matches_account(account_id: str | None, selected_account_ids: tuple[str, ...]) -> bool:
83+
if not selected_account_ids:
84+
return True
85+
return str(account_id or "").strip() in selected_account_ids
86+
87+
88+
def _resolve_order_account_id(account_ids=None) -> str | None:
89+
normalized = _normalize_account_ids(account_ids)
90+
if len(normalized) > 1:
91+
raise ValueError(
92+
"IBKR live order routing requires a single account_id per runtime service; "
93+
f"got {len(normalized)} account_ids."
94+
)
95+
return normalized[0] if normalized else None
96+
97+
98+
def get_available_buying_power(ib, fallback_buying_power, *, account_ids=None):
99+
selected_account_ids = _normalize_account_ids(account_ids)
68100
buying_power = fallback_buying_power
69101
for account_value in ib.accountValues():
102+
account_id = str(getattr(account_value, "account", "") or "").strip() or None
103+
if not _matches_account(account_id, selected_account_ids):
104+
continue
70105
if account_value.tag == "AvailableFunds" and account_value.currency == "USD":
71106
buying_power = float(account_value.value)
72107
return buying_power
@@ -101,9 +136,24 @@ def _extract_open_order_status(order_like: Any) -> str:
101136
return str(status or "").strip()
102137

103138

104-
def _collect_pending_symbols(ib, symbols: set[str]) -> tuple[str, ...]:
139+
def _extract_open_order_account(order_like: Any) -> str | None:
140+
order = getattr(order_like, "order", None)
141+
for candidate in (
142+
getattr(order, "account", None),
143+
getattr(order_like, "account", None),
144+
):
145+
text = str(candidate or "").strip()
146+
if text:
147+
return text
148+
return None
149+
150+
151+
def _collect_pending_symbols(ib, symbols: set[str], *, account_ids=None) -> tuple[str, ...]:
152+
selected_account_ids = _normalize_account_ids(account_ids)
105153
pending = []
106154
for order_like in _iter_open_orders(ib):
155+
if not _matches_account(_extract_open_order_account(order_like), selected_account_ids):
156+
continue
107157
status = _extract_open_order_status(order_like)
108158
if status in {"Cancelled", "ApiCancelled", "Inactive", "Filled"}:
109159
continue
@@ -127,6 +177,19 @@ def _extract_fill_symbol(fill_like: Any) -> str | None:
127177
return symbol_text or None
128178

129179

180+
def _extract_fill_account(fill_like: Any) -> str | None:
181+
execution = getattr(fill_like, "execution", None)
182+
for candidate in (
183+
getattr(execution, "acctNumber", None),
184+
getattr(execution, "account", None),
185+
getattr(fill_like, "account", None),
186+
):
187+
text = str(candidate or "").strip()
188+
if text:
189+
return text
190+
return None
191+
192+
130193
def _normalize_date_like(value: Any) -> str | None:
131194
if value in {None, ""}:
132195
return None
@@ -150,11 +213,20 @@ def _extract_fill_date(fill_like: Any) -> str | None:
150213
return None
151214

152215

153-
def _collect_same_day_filled_symbols(ib, symbols: set[str], trade_date: str | None) -> tuple[str, ...]:
216+
def _collect_same_day_filled_symbols(
217+
ib,
218+
symbols: set[str],
219+
trade_date: str | None,
220+
*,
221+
account_ids=None,
222+
) -> tuple[str, ...]:
154223
if not trade_date:
155224
return ()
225+
selected_account_ids = _normalize_account_ids(account_ids)
156226
matched = []
157227
for fill_like in _iter_fills(ib):
228+
if not _matches_account(_extract_fill_account(fill_like), selected_account_ids):
229+
continue
158230
symbol = _extract_fill_symbol(fill_like)
159231
if not symbol or symbol not in symbols:
160232
continue
@@ -425,9 +497,15 @@ def execute_rebalance(
425497
safe_haven_symbols = tuple(allocation["safe_haven_symbols"])
426498
safe_haven_symbol = safe_haven_symbols[0] if safe_haven_symbols else None
427499
equity = account_values.get("equity", 0)
500+
normalized_account_ids = _normalize_account_ids(account_ids)
501+
order_account_id = _resolve_order_account_id(normalized_account_ids)
428502
execution_summary = {
429503
"mode": "dry_run" if dry_run_only else "paper",
430504
"strategy_profile": strategy_profile,
505+
"account_group": account_group,
506+
"account_ids": list(normalized_account_ids),
507+
"order_account_id": order_account_id,
508+
"service_name": service_name,
431509
"trade_date": trade_date,
432510
"snapshot_as_of": snapshot_date,
433511
"safe_haven_symbol": safe_haven_symbol,
@@ -508,7 +586,7 @@ def execute_rebalance(
508586
(sum(current_mv.values()) - current_safe_haven_mv) / equity
509587
)
510588

511-
pending_symbols = _collect_pending_symbols(ib, set(all_symbols))
589+
pending_symbols = _collect_pending_symbols(ib, set(all_symbols), account_ids=normalized_account_ids)
512590
if pending_symbols:
513591
reason = f"pending_orders_detected:{','.join(pending_symbols)}"
514592
execution_summary["execution_status"] = "blocked"
@@ -597,6 +675,7 @@ def execute_rebalance(
597675
anticipated_buying_power = get_available_buying_power(
598676
ib,
599677
account_values.get("buying_power", 0),
678+
account_ids=normalized_account_ids,
600679
)
601680
has_buy_plan = False
602681
for symbol, target in target_mv.items():
@@ -655,7 +734,12 @@ def execute_rebalance(
655734
trade_logs.append(translator("failed", reason=reason))
656735
return _finalize_result(trade_logs, execution_summary, return_summary=return_summary)
657736

658-
same_day_filled_symbols = _collect_same_day_filled_symbols(ib, set(all_symbols), trade_date)
737+
same_day_filled_symbols = _collect_same_day_filled_symbols(
738+
ib,
739+
set(all_symbols),
740+
trade_date,
741+
account_ids=normalized_account_ids,
742+
)
659743
if same_day_filled_symbols:
660744
reason = f"same_day_fills_detected:{','.join(same_day_filled_symbols)}"
661745
execution_summary["execution_status"] = "blocked"
@@ -685,7 +769,7 @@ def execute_rebalance(
685769
strategy_profile=strategy_profile,
686770
account_group=account_group,
687771
service_name=service_name,
688-
account_ids=tuple(account_ids or ()),
772+
account_ids=normalized_account_ids,
689773
trade_date=trade_date,
690774
snapshot_date=snapshot_date,
691775
target_hash=target_hash,
@@ -754,7 +838,12 @@ def execute_rebalance(
754838
continue
755839
report = submit_order_intent(
756840
ib,
757-
order_intent_cls(symbol=symbol, side="sell", quantity=qty),
841+
order_intent_cls(
842+
symbol=symbol,
843+
side="sell",
844+
quantity=qty,
845+
account_id=order_account_id,
846+
),
758847
)
759848
ok, status_msg = check_order_submitted(report, translator=translator)
760849
status = str(getattr(report, "status", "") or "")
@@ -784,6 +873,7 @@ def execute_rebalance(
784873
buying_power = anticipated_buying_power if not sell_executed else get_available_buying_power(
785874
ib,
786875
account_values.get("buying_power", 0),
876+
account_ids=normalized_account_ids,
787877
)
788878

789879
for symbol, target in target_mv.items():
@@ -830,6 +920,7 @@ def execute_rebalance(
830920
order_type="limit",
831921
limit_price=limit_price,
832922
time_in_force="DAY",
923+
account_id=order_account_id,
833924
),
834925
)
835926
ok, status_msg = check_order_submitted(report, translator=translator)

application/ibkr_order_execution.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def submit_order_intent(
3939
ib: Any,
4040
order_intent: OrderIntent,
4141
*,
42+
account_id: str | None = None,
4243
wait_seconds: float = 1.0,
4344
stock_factory: Callable[..., Any] | None = None,
4445
market_order_factory: Callable[..., Any] | None = None,
@@ -50,6 +51,7 @@ def submit_order_intent(
5051
return _submit_order_intent(
5152
ib,
5253
intent,
54+
account_id=account_id,
5355
wait_seconds=wait_seconds,
5456
stock_factory=stock_factory,
5557
market_order_factory=_market_order_factory_with_time_in_force(

application/runtime_broker_adapters.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ class IBKRRuntimeBrokerAdapters:
4646
sleep_fn: Any
4747
printer: Any = print
4848

49+
def fetch_account_portfolio_snapshot(self, ib):
50+
if self.account_ids:
51+
return self.fetch_portfolio_snapshot_fn(ib, account_ids=self.account_ids)
52+
return self.fetch_portfolio_snapshot_fn(ib)
53+
4954
def connect_ib(self):
5055
self.ensure_event_loop_fn()
5156
host = self.host_resolver()
@@ -82,7 +87,7 @@ def connect_ib(self):
8287
raise last_error
8388

8489
def get_current_portfolio(self, ib):
85-
snapshot = self.fetch_portfolio_snapshot_fn(ib)
90+
snapshot = self.fetch_account_portfolio_snapshot(ib)
8691
positions = {}
8792
for position in snapshot.positions:
8893
positions[position.symbol] = {
@@ -97,7 +102,7 @@ def get_current_portfolio(self, ib):
97102

98103
def build_portfolio_snapshot(self, ib, *, get_current_portfolio_fallback=None):
99104
if hasattr(ib, "reqPositions"):
100-
return self.fetch_portfolio_snapshot_fn(ib)
105+
return self.fetch_account_portfolio_snapshot(ib)
101106
positions, account_values = get_current_portfolio_fallback(ib)
102107
return PortfolioSnapshot(
103108
as_of=datetime.now(timezone.utc),

docs/ibkr_runtime_rollout.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ cp docs/examples/ibkr-account-groups.paper.json /tmp/ibkr-account-groups.json
8787
- `ib_gateway_ip_mode`:推荐 `internal`
8888
- `ib_client_id`:这个账号组对应的 client id。
8989
- `service_name`:当前只是预留元数据,建议先填成现有 Cloud Run 服务名,后面多账号拆服务时更顺。
90-
- `account_ids`当前主要是留档和后续扩展,不是启动必填
90+
- `account_ids`实盘账号组建议只放一个 UID。运行时会用它过滤持仓、pending/fill 检查,并写入 IBKR 订单的 `order.account`;如果一个服务配置多个 UID,实盘下单会因为账户路由不明确而失败
9191

9292
把 secret 建起来:
9393

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@8769362096227320bc05c791b5244d4b3e88db50
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c6b22288e1447e56cd51a93ea9138e4f1110f165
44
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ed55a6af0245323dbed82060e89be96d8f77f756
55
pandas
66
numpy

strategy_runtime.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,14 @@ def _runtime_adapter_with_portfolio(
8888
def _fetch_portfolio_snapshot_for_context(self, ib, *, required: bool) -> Any | None:
8989
if ib is None and not required:
9090
return None
91+
account_ids = tuple(self.runtime_settings.account_ids or ())
9192
if required:
93+
if account_ids:
94+
return fetch_portfolio_snapshot(ib, account_ids=account_ids)
9295
return fetch_portfolio_snapshot(ib)
9396
try:
97+
if account_ids:
98+
return fetch_portfolio_snapshot(ib, account_ids=account_ids)
9499
return fetch_portfolio_snapshot(ib)
95100
except Exception as exc:
96101
self.logger(
@@ -245,7 +250,7 @@ def _evaluate_value_target_strategy(
245250
runtime_config = dict(self.runtime_config)
246251
runtime_config.setdefault("translator", translator)
247252
apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter)
248-
portfolio_snapshot = fetch_portfolio_snapshot(ib)
253+
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(ib, required=True)
249254
market_inputs = self._build_value_target_market_inputs(
250255
ib=ib,
251256
historical_close_loader=historical_close_loader,

0 commit comments

Comments
 (0)