Skip to content

Commit c9c7bc5

Browse files
committed
Use QuantPlatformKit for IBKR access
1 parent 2bd8bf1 commit c9c7bc5

4 files changed

Lines changed: 69 additions & 111 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ Quarterly momentum rotation across 22 global ETFs (international markets, commod
5252
```
5353
Cloud Scheduler (daily, 15:45 ET on weekdays)
5454
↓ HTTP POST
55-
Cloud Run (Flask: strategy + orders)
55+
Cloud Run (Flask: strategy + orchestration)
56+
↓ shared adapter package
57+
QuantPlatformKit (IBKR adapter)
5658
↓ ib_insync TCP
5759
GCE (IB Gateway, always-on)
5860
@@ -231,7 +233,9 @@ gcloud run services update ibkr-quant \
231233
```
232234
Cloud Scheduler (每个交易日 15:45 ET)
233235
↓ HTTP POST
234-
Cloud Run (Flask: 策略计算 + 下单)
236+
Cloud Run (Flask: 策略计算 + 编排)
237+
↓ 共享平台适配层
238+
QuantPlatformKit (IBKR adapter)
235239
↓ ib_insync TCP
236240
GCE (IB Gateway 常驻)
237241

main.py

Lines changed: 57 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@
2121
except ImportError:
2222
compute_v1 = None
2323

24-
from ib_insync import IB, Stock, MarketOrder, LimitOrder
24+
from quant_platform_kit.common.models import OrderIntent
25+
from quant_platform_kit.ibkr import (
26+
connect_ib as ibkr_connect_ib,
27+
ensure_event_loop,
28+
fetch_historical_price_series,
29+
fetch_portfolio_snapshot,
30+
fetch_quote_snapshots,
31+
submit_order_intent,
32+
)
2533

2634
app = Flask(__name__)
2735

@@ -224,51 +232,24 @@ def send_tg_message(message):
224232
print(f"Telegram send failed: {e}", flush=True)
225233

226234

227-
# ---------------------------------------------------------------------------
228-
# IB Gateway connection
229-
# ---------------------------------------------------------------------------
230-
def ensure_event_loop():
231-
"""ib_insync expects an event loop even inside Gunicorn worker threads."""
232-
try:
233-
loop = asyncio.get_event_loop_policy().get_event_loop()
234-
except RuntimeError:
235-
loop = asyncio.new_event_loop()
236-
asyncio.set_event_loop(loop)
237-
return loop
238-
239-
if loop.is_closed():
240-
loop = asyncio.new_event_loop()
241-
asyncio.set_event_loop(loop)
242-
243-
return loop
244-
245-
246235
def connect_ib():
247-
ensure_event_loop()
248-
ib = IB()
249-
ib.connect(IB_HOST, IB_PORT, clientId=IB_CLIENT_ID, timeout=20)
250-
return ib
236+
return ibkr_connect_ib(IB_HOST, IB_PORT, IB_CLIENT_ID)
251237

252238

253239
def get_historical_close(ib, symbol, duration="2 Y", bar_size="1 day"):
254-
"""Fetch daily close prices from IBKR for a US stock/ETF."""
255-
contract = Stock(symbol, 'SMART', 'USD')
256-
ib.qualifyContracts(contract) # Fix I4: qualify before requesting data
257-
bars = ib.reqHistoricalData(
258-
contract,
259-
endDateTime='',
260-
durationStr=duration,
261-
barSizeSetting=bar_size,
262-
whatToShow='ADJUSTED_LAST',
263-
useRTH=True,
264-
formatDate=1,
240+
"""Fetch daily close prices from IBKR via QuantPlatformKit."""
241+
series = fetch_historical_price_series(
242+
ib,
243+
symbol,
244+
duration=duration,
245+
bar_size=bar_size,
265246
)
266-
if not bars:
247+
if not series.points:
267248
return pd.Series(dtype=float)
268-
df = pd.DataFrame(bars)
269-
df['date'] = pd.to_datetime(df['date'])
270-
df = df.set_index('date')
271-
return df['close']
249+
return pd.Series(
250+
data=[point.close for point in series.points],
251+
index=pd.to_datetime([point.as_of for point in series.points]),
252+
)
272253

273254

274255
# ---------------------------------------------------------------------------
@@ -399,60 +380,32 @@ def compute_signals(ib, current_holdings):
399380
# ---------------------------------------------------------------------------
400381
def get_current_portfolio(ib):
401382
"""Get current positions and account values."""
402-
ib.reqPositions()
403-
time.sleep(1)
404-
383+
snapshot = fetch_portfolio_snapshot(ib)
405384
positions = {}
406-
for pos in ib.positions():
407-
symbol = pos.contract.symbol
408-
if pos.position != 0:
409-
positions[symbol] = {
410-
'quantity': int(pos.position),
411-
'avg_cost': float(pos.avgCost),
412-
}
413-
414-
account_values = {}
415-
for av in ib.accountValues():
416-
if av.tag == 'NetLiquidation' and av.currency == 'USD':
417-
account_values['equity'] = float(av.value)
418-
if av.tag == 'AvailableFunds' and av.currency == 'USD':
419-
account_values['buying_power'] = float(av.value)
385+
for position in snapshot.positions:
386+
positions[position.symbol] = {
387+
'quantity': int(position.quantity),
388+
'avg_cost': float(position.average_cost or 0.0),
389+
}
390+
391+
account_values = {
392+
'equity': snapshot.total_equity,
393+
'buying_power': snapshot.buying_power or 0.0,
394+
}
420395

421396
return positions, account_values
422397

423398

424399
def get_market_prices(ib, symbols):
425-
"""Fetch market prices for multiple symbols in one pass. Fix C4: no redundant calls."""
426-
prices = {}
427-
contracts = []
428-
for symbol in symbols:
429-
contract = Stock(symbol, 'SMART', 'USD')
430-
ib.qualifyContracts(contract)
431-
contracts.append((symbol, contract))
432-
433-
# Request all at once
434-
tickers = {}
435-
for symbol, contract in contracts:
436-
tickers[symbol] = ib.reqMktData(contract, '', False, False)
437-
438-
time.sleep(3) # Single wait for all
439-
440-
for symbol, contract in contracts:
441-
ib.cancelMktData(contract)
442-
tk = tickers[symbol]
443-
price = tk.marketPrice()
444-
if np.isnan(price) or price <= 0:
445-
price = tk.close
446-
if not np.isnan(price) and price > 0:
447-
prices[symbol] = float(price)
448-
449-
return prices
450-
451-
452-
def check_order_submitted(trade, symbol, side_text, qty):
400+
"""Fetch market prices for multiple symbols in one pass."""
401+
quotes = fetch_quote_snapshots(ib, symbols)
402+
return {symbol: quote.last_price for symbol, quote in quotes.items()}
403+
404+
405+
def check_order_submitted(report):
453406
"""Check if order was accepted. DAY orders auto-expire at close if not filled."""
454-
order_id = trade.order.orderId
455-
status = trade.orderStatus.status
407+
order_id = report.broker_order_id
408+
status = report.status
456409

457410
if status in ['Submitted', 'PreSubmitted', 'Filled']:
458411
return True, f"✅ {t('submitted', order_id=order_id)}"
@@ -505,13 +458,11 @@ def execute_rebalance(ib, target_weights, positions, account_values):
505458
if qty <= 0:
506459
continue
507460

508-
contract = Stock(symbol, 'SMART', 'USD')
509-
ib.qualifyContracts(contract)
510-
order = MarketOrder('SELL', qty)
511-
trade = ib.placeOrder(contract, order)
512-
time.sleep(1)
513-
514-
ok, status_msg = check_order_submitted(trade, symbol, "Sell", qty)
461+
report = submit_order_intent(
462+
ib,
463+
OrderIntent(symbol=symbol, side='sell', quantity=qty),
464+
)
465+
ok, status_msg = check_order_submitted(report)
515466
log = t("market_sell", symbol=symbol, qty=qty) + f" {status_msg}"
516467
trade_logs.append(log)
517468
if ok:
@@ -540,14 +491,18 @@ def execute_rebalance(ib, target_weights, positions, account_values):
540491
if qty <= 0:
541492
continue
542493

543-
contract = Stock(symbol, 'SMART', 'USD')
544-
ib.qualifyContracts(contract)
545-
order = LimitOrder('BUY', qty, limit_price)
546-
order.tif = 'DAY' # Auto-expire at close if not filled
547-
trade = ib.placeOrder(contract, order)
548-
time.sleep(1)
549-
550-
ok, status_msg = check_order_submitted(trade, symbol, "Buy", qty)
494+
report = submit_order_intent(
495+
ib,
496+
OrderIntent(
497+
symbol=symbol,
498+
side='buy',
499+
quantity=qty,
500+
order_type='limit',
501+
limit_price=limit_price,
502+
time_in_force='DAY',
503+
),
504+
)
505+
ok, status_msg = check_order_submitted(report)
551506
log = t("limit_buy", symbol=symbol, qty=qty, price=f"{limit_price:.2f}") + f" {status_msg}"
552507
trade_logs.append(log)
553508
if ok:

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
flask
22
gunicorn
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.1.0
34
pandas
45
numpy
56
requests

tests/test_event_loop.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,16 @@ def worker():
2424
def test_connect_ib_prepares_event_loop_before_connect(strategy_module, monkeypatch):
2525
observed = {}
2626

27-
class FakeIB:
28-
def connect(self, host, port, clientId, timeout):
29-
observed["loop"] = asyncio.get_event_loop_policy().get_event_loop()
30-
observed["args"] = (host, port, clientId, timeout)
27+
def fake_ibkr_connect(host, port, client_id):
28+
observed["args"] = (host, port, client_id)
29+
return object()
3130

32-
monkeypatch.setattr(strategy_module, "IB", FakeIB)
31+
monkeypatch.setattr(strategy_module, "ibkr_connect_ib", fake_ibkr_connect)
3332

3433
with ThreadPoolExecutor(max_workers=1) as executor:
3534
executor.submit(strategy_module.connect_ib).result()
3635

37-
assert observed["args"] == ("127.0.0.1", 4001, 1, 20)
38-
assert observed["loop"] is not None
36+
assert observed["args"] == ("127.0.0.1", 4001, 1)
3937

4038

4139
def test_instance_name_alias_is_used_as_host(strategy_module):

0 commit comments

Comments
 (0)