fix
This commit is contained in:
@@ -26,6 +26,30 @@ def update_universe(cfg: Config) -> None:
|
|||||||
print(f"{len(table)} symbols -> {cfg.data.universe}")
|
print(f"{len(table)} symbols -> {cfg.data.universe}")
|
||||||
|
|
||||||
|
|
||||||
|
def show_rebalance_schedule(cfg: Config, today: dt.date) -> None:
|
||||||
|
if not cfg.data.bars.exists():
|
||||||
|
print("rebalance schedule unavailable: no daily bars; run `make bars`")
|
||||||
|
return
|
||||||
|
dates = pd.read_csv(cfg.data.bars, usecols=["date"], parse_dates=["date"])["date"]
|
||||||
|
sessions = pd.DatetimeIndex(dates.dropna().unique()).sort_values()
|
||||||
|
sessions = sessions[sessions.date <= today]
|
||||||
|
if sessions.empty:
|
||||||
|
print("rebalance schedule unavailable: no sessions through today; run `make bars`")
|
||||||
|
return
|
||||||
|
last = sessions[-1].date()
|
||||||
|
st = cfg.strategy
|
||||||
|
wait = sessions_until_rebalance(sessions, st.anchor, st.rebalance_every)
|
||||||
|
if wait is None:
|
||||||
|
schedule = f"first trading session on or after {st.anchor}"
|
||||||
|
elif wait == 0:
|
||||||
|
schedule = f"{last} (0 trading sessions remaining)"
|
||||||
|
else:
|
||||||
|
schedule = f"in {wait} trading sessions"
|
||||||
|
print(f"next rebalance: {schedule} (as of {last}; every {st.rebalance_every} trading sessions)")
|
||||||
|
if last < today:
|
||||||
|
print(f"Bars end {last}; run `make bars` to update the rebalance countdown for {today}.")
|
||||||
|
|
||||||
|
|
||||||
async def check(cfg: Config) -> None:
|
async def check(cfg: Config) -> None:
|
||||||
async with Broker(cfg.ib) as broker:
|
async with Broker(cfg.ib) as broker:
|
||||||
state = await broker.account_state()
|
state = await broker.account_state()
|
||||||
@@ -36,12 +60,37 @@ async def check(cfg: Config) -> None:
|
|||||||
print(f"{len(trades)} open orders")
|
print(f"{len(trades)} open orders")
|
||||||
for t in trades:
|
for t in trades:
|
||||||
print(f" {t.contract.symbol} {t.order.action} {t.order.totalQuantity} {t.order.orderType} {t.orderStatus.status}")
|
print(f" {t.contract.symbol} {t.order.action} {t.order.totalQuantity} {t.order.orderType} {t.orderStatus.status}")
|
||||||
|
show_rebalance_schedule(cfg, dt.datetime.now(NEW_YORK).date())
|
||||||
contracts, _ = await broker.qualify_stocks(["SPY"])
|
contracts, _ = await broker.qualify_stocks(["SPY"])
|
||||||
prices = await broker.snapshot_prices(contracts)
|
if "SPY" not in contracts:
|
||||||
|
print("no SPY price: IBKR could not qualify the SPY contract")
|
||||||
|
return
|
||||||
|
errors: dict[int, str] = {}
|
||||||
|
|
||||||
|
def on_error(req_id, code, message, contract):
|
||||||
|
if contract == contracts["SPY"]:
|
||||||
|
errors[code] = message
|
||||||
|
|
||||||
|
broker.ib.errorEvent += on_error
|
||||||
|
try:
|
||||||
|
prices = await broker.snapshot_prices(contracts)
|
||||||
|
finally:
|
||||||
|
broker.ib.errorEvent -= on_error
|
||||||
if "SPY" in prices:
|
if "SPY" in prices:
|
||||||
print(f"SPY snapshot {prices['SPY']:.2f} (market_data_type={cfg.ib.market_data_type})")
|
print(f"SPY snapshot {prices['SPY']:.2f} (requested market_data_type={cfg.ib.market_data_type})")
|
||||||
else:
|
else:
|
||||||
print("no SPY price: check the market data subscription or set ib.market_data_type = 3")
|
reason = "; ".join(f"IBKR {code}: {message}" for code, message in errors.items()) or "no valid snapshot received"
|
||||||
|
print(f"no SPY price (requested market_data_type={cfg.ib.market_data_type}): {reason}")
|
||||||
|
if 10197 in errors:
|
||||||
|
print(
|
||||||
|
"Log out of the competing live market-data session, then retry `make check`. "
|
||||||
|
"Delayed mode (3/4) does not force delayed quotes when live data permissions exist; "
|
||||||
|
"changing ib.market_data_type cannot resolve this session conflict."
|
||||||
|
)
|
||||||
|
elif cfg.ib.market_data_type in (1, 2):
|
||||||
|
print("Check the market data subscription, or set ib.market_data_type = 3 to allow delayed data.")
|
||||||
|
else:
|
||||||
|
print("Delayed data is already requested; check market data permissions and availability in IB Gateway/TWS.")
|
||||||
|
|
||||||
|
|
||||||
async def probe_gateway(cfg: Config) -> None:
|
async def probe_gateway(cfg: Config) -> None:
|
||||||
|
|||||||
64
tests/test_check.py
Normal file
64
tests/test_check.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from ib_async import Stock
|
||||||
|
|
||||||
|
from ibkr_quant import cli
|
||||||
|
from ibkr_quant.broker import AccountState, Broker
|
||||||
|
from ibkr_quant.config import load_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mode, error, price, expected, absent",
|
||||||
|
[
|
||||||
|
(3, (10197, "No market data during competing live session"), None,
|
||||||
|
"Log out of the competing live market-data session", "set ib.market_data_type = 3"),
|
||||||
|
(1, (10197, "No market data during competing live session"), None,
|
||||||
|
"changing ib.market_data_type cannot resolve this session conflict", "set ib.market_data_type = 3"),
|
||||||
|
(1, (354, "Requested market data is not subscribed"), None,
|
||||||
|
"set ib.market_data_type = 3", "competing live market-data session"),
|
||||||
|
(3, (354, "Requested market data is not subscribed"), None,
|
||||||
|
"Delayed data is already requested", "set ib.market_data_type = 3"),
|
||||||
|
(3, None, None, "no valid snapshot received", "set ib.market_data_type = 3"),
|
||||||
|
(3, None, 600.0, "SPY snapshot 600.00 (requested market_data_type=3)", "no SPY price"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_check_explains_snapshot_result(monkeypatch, capsys, mode, error, price, expected, absent):
|
||||||
|
cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml")
|
||||||
|
cfg = dataclasses.replace(cfg, ib=dataclasses.replace(cfg.ib, market_data_type=mode))
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
broker = Broker(cfg.ib)
|
||||||
|
listeners = len(broker.ib.errorEvent)
|
||||||
|
contract = Stock("SPY", "SMART", "USD", conId=756733)
|
||||||
|
monkeypatch.setattr(Broker, "__aenter__", AsyncMock(return_value=broker))
|
||||||
|
monkeypatch.setattr(Broker, "__aexit__", AsyncMock(return_value=None))
|
||||||
|
monkeypatch.setattr(cli, "Broker", lambda _: broker)
|
||||||
|
monkeypatch.setattr(broker, "account_state", AsyncMock(return_value=AccountState("DU123", 1000, 1000, [])))
|
||||||
|
monkeypatch.setattr(broker, "open_trades", AsyncMock(return_value=[]))
|
||||||
|
monkeypatch.setattr(broker, "qualify_stocks", AsyncMock(return_value=({"SPY": contract}, [])))
|
||||||
|
monkeypatch.setattr(broker.ib.client, "getReqId", Mock(return_value=59))
|
||||||
|
|
||||||
|
def respond(req_id, *args):
|
||||||
|
# Exercise ib_async's real request completion and errorEvent path.
|
||||||
|
if error:
|
||||||
|
broker.ib.wrapper.error(req_id, *error, "")
|
||||||
|
else:
|
||||||
|
if price is not None:
|
||||||
|
broker.ib.wrapper.priceSizeTick(req_id, 4, price, 1)
|
||||||
|
broker.ib.wrapper.tickSnapshotEnd(req_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(broker.ib.client, "reqMktData", respond)
|
||||||
|
await cli.check(cfg)
|
||||||
|
assert len(broker.ib.errorEvent) == listeners
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "0 open orders" in output
|
||||||
|
assert expected in output
|
||||||
|
assert absent not in output
|
||||||
|
if error:
|
||||||
|
assert f"IBKR {error[0]}: {error[1]}" in output
|
||||||
Reference in New Issue
Block a user