Pular para o conteúdo
Todos os documentos da biblioteca

Conectando backtests à Interactive Brokers em modo sombra simulado

Código Machine Learning for Trading

Resumo

Este notebook apresenta um fluxo operacional para conectar uma estratégia à Interactive Brokers por meio de TWS ou Gateway. Ele verifica se a sessão é de uma conta demo, lê os valores e as posições da conta e solicita barras históricas para inicializar o estado da estratégia antes do início do feed ao vivo. Uma estratégia de momentum de ETF dias para ETFs é compartilhada entre o backtesting e o mecanismo ao vivo, enquanto os ticks recebidos são agregados em barras.

As ordens passam por um wrapper de segurança em modo sombra, no qual é possível inspecionar mudanças virtuais na carteira sem enviar ordens ao mercado. O notebook enfatiza mensagens claras de erro para sessões inacessíveis, dados de aquecimento ausentes ou divergências na conta, além de uma desconexão ordenada. Ele demonstra integração e proteções, não o desempenho da estratégia: não são apresentados resultados de trading nem evidências de lucratividade, e a abordagem depende de uma sessão IB configurada e de acesso adequado a dados de mercado.

Ideias principais

  • Confirme a identidade e o estado da conta paper antes de iniciar a estratégia ao vivo.
  • Carregue barras históricas para inicializar os indicadores antes de processar dados ao vivo.
  • Reutilize a estratégia do backtest no mecanismo ao vivo e agregue os ticks recebidos em barras.
  • O modo sombra registra ordens virtuais para inspeção sem enviá-las ao mercado.
  • Considere as verificações de conexão, dados e encerramento parte da confiabilidade do live trading.

Tags

Texto completo
# 03_ib_paper_trading_demo.py


```py
# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.3
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Interactive Brokers Paper Trading Demo
#
# **Chapter**: 25 - Live Trading Systems
# **Section Reference**: 25.2 (Integrating with Interactive Brokers)
# **Learning Outcome**: LO2 - Connect ml4t-backtest strategies to live brokers
#
# **Docker image**: `ml4t` (requires IB TWS/Gateway running on host port 7497)
#
# This notebook demonstrates:
# 1. Connecting to IB TWS paper trading account
# 2. Querying account information and positions
# 3. Requesting historical data for strategy warm-up
# 4. Real-time data feed with tick aggregation
# 5. Safe order submission in shadow mode
#
# **Learning Objectives**:
# - Validate IB connectivity, account state, and warm-up data before a live engine starts.
# - Reuse the same backtest strategy inside a shadow-mode live workflow.
# - Exercise the loud-fail setup checklist: the notebook does not synthesize live behaviour from history when TWS/Gateway is unreachable or the market is closed.
#
# **Prerequisites**:
# - IB TWS or Gateway running with API enabled
# - Paper trading port: 7497 (TWS) or 4002 (Gateway)
# - `IB_ACCOUNT` environment variable set to your IB paper account ID (e.g. `DU1234567`); the notebook passes `None` to `IBBroker` if unset, which lets the broker pick the default account on the session.
# - Familiarity with the ETF momentum example used throughout the live-trading chapter

# %%
"""Connect ml4t strategies to IB with shadow-mode risk controls."""

import os

# %% [markdown]
# `MARKET_DATA_TYPE` decides which quote TWS returns. Leaving it at `None` keeps whatever the
# session is configured for, which is the right choice when the paper account carries live
# Level 1 subscriptions. A paper account without them rejects MARKET orders with "No market
# data available...", so this notebook asks for delayed quotes instead. The four values TWS
# accepts are 1 for real time, 2 for frozen, 3 for delayed and 4 for delayed frozen.

# %% tags=["parameters"]
# Production defaults. Papermill may inject overrides for CI.
IB_HOST = "127.0.0.1"
IB_PORT = 7497  # Paper trading port
CLIENT_ID = 10  # Use unique ID per notebook
ACCOUNT = os.environ.get(
    "IB_ACCOUNT"
)  # set IB_ACCOUNT=DU... before running; None picks the session default
MARKET_DATA_TYPE: int | None = 3  # delayed quotes; see above
SYMBOLS = ["SPY", "QQQ", "IWM"]  # ETFs to monitor
WARMUP_DAYS = 10  # Must provide at least lookback + 1 daily closes
LIVE_DURATION_SECONDS = 75

# %%
import asyncio
import logging
import warnings
from datetime import datetime

os.environ.setdefault("NUMEXPR_MAX_THREADS", "16")

from async_utils import run_async

from utils.paths import display_path, get_output_dir

# %%
# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)

# Reduce noise from ib_async
logging.getLogger("ib_async").setLevel(logging.WARNING)
logging.getLogger("ml4t.live.brokers.ib").setLevel(logging.WARNING)

# %%
# Import ml4t.live components
from ml4t.backtest import OrderSide, Strategy
from ml4t.live import LiveEngine, LiveRiskConfig
from ml4t.live.brokers.ib import IBBroker
from ml4t.live.feeds import BarAggregator, IBDataFeed
from ml4t.live.safety import SafeBroker

print("[OK] ml4t.live components imported successfully")

# %% [markdown]
# The import check runs before any Interactive Brokers connection is attempted, so an environment
# that is missing a package fails differently from a session that cannot reach TWS. Collapsing the
# two into one opaque error is what makes a live deployment slow to debug.

# %% [markdown]
# ## 1. Connect to Interactive Brokers
#
# TWS/Gateway must be running with:
# - API access enabled (Edit > Global Configuration > API > Settings)
# - Socket port set (default: 7497 for paper)
# - "Enable ActiveX and Socket Clients" checked

# %%
# Create IBBroker instance
broker = IBBroker(
    host=IB_HOST,
    port=IB_PORT,
    client_id=CLIENT_ID,
    account=ACCOUNT,
    market_data_type=MARKET_DATA_TYPE,
)

print(f"IBBroker configured for {IB_HOST}:{IB_PORT}")
print(f"Account selection: {'configured paper account' if ACCOUNT else 'session default'}")
print(f"Client ID: {CLIENT_ID}")


# %% [markdown]
# Host, port, account and client ID are printed rather than left as constants in a cell above,
# because an account or session mismatch is one of the most common live failures and is invisible
# until something has already been submitted to the wrong place.
#


# %%
async def connect_to_ib():
    """Connect to IB and show account summary."""
    print("\n" + "=" * 60)
    print("CONNECTING TO INTERACTIVE BROKERS")
    print("=" * 60)

    await broker.connect()

    account = str(broker._account or "")
    if not account.startswith("DU"):
        await broker.disconnect()
        raise RuntimeError("Refusing to continue: connected IB account is not a paper account")

    print("\n[OK] Connected to IB")
    print("   Paper account identity: [OK]")
    # Get account values
    nlv = await broker.get_account_value_async()
    cash = await broker.get_cash_async()

    print("\nACCOUNT READINESS")
    print("   Account values received: [OK]")

    # Get positions
    positions = await broker.get_positions_async()
    print(f"   Open positions received: {len(positions)}")

    return nlv, cash, positions


# %%
# Run connection
try:
    warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"nest_asyncio")
    nlv, cash, positions = run_async(connect_to_ib())
except Exception as exc:
    print()
    print("=" * 60)
    print("ERROR: IB paper session unreachable")
    print("=" * 60)
    print(f"Could not connect to {IB_HOST}:{IB_PORT}")
    print(f"Underlying error: {type(exc).__name__}: {exc}")
    print()
    print("Setup checklist:")
    print("  1. Start TWS or IB Gateway and log into a paper account.")
    print("  2. Edit -> Global Configuration -> API -> Settings:")
    print("     - 'Enable ActiveX and Socket Clients' must be checked.")
    print(f"     - Socket port must be {IB_PORT} (TWS paper=7497, Gateway paper=4002).")
    print("     - 127.0.0.1 must be in 'Trusted IPs', or 'Read-Only API' unchecked.")
    print("  3. Confirm no other client is using this client_id.")
    print()
    print("Re-run this notebook once TWS is reachable.")
    raise RuntimeError("IB paper session unreachable") from exc

# %% [markdown]
# When TWS is unreachable the connection block prints a checklist and stops, rather than
# substituting placeholder data and letting the rest of the notebook read as if it had traded.
# A disconnected broker is an operational state, which is why the check comes before the signal
# loop rather than inside it.

# %% [markdown]
# ## 2. Request Historical Data
#
# Before running a strategy live, we need historical data to:
# - Calculate initial indicator values (moving averages, etc.)
# - Establish baseline for position sizing
# - Verify data quality
#
# IB provides historical bars via `reqHistoricalData`.

# %%
from ib_async import Stock


async def get_historical_data(symbol: str, days: int = 5) -> list[dict]:
    """Request historical daily bars from IB and return OHLCV records."""
    contract = Stock(symbol, "SMART", "USD")
    qualified = await broker.ib.qualifyContractsAsync(contract)
    if not qualified:
        raise RuntimeError(f"IB could not qualify the {symbol} contract")

    # `timeout=0` disables ib_async's internal `asyncio.wait_for` wrapper,
    # which fails under nest_asyncio on Python 3.14.
    bars = await broker.ib.reqHistoricalDataAsync(
        contract,
        endDateTime="",
        durationStr=f"{days} D",
        barSizeSetting="1 day",
        whatToShow="TRADES",
        useRTH=True,
        timeout=0,
    )

    result = []
    for bar in bars:
        result.append(
            {
                "timestamp": bar.date,
                "open": bar.open,
                "high": bar.high,
                "low": bar.low,
                "close": bar.close,
                "volume": bar.volume,
            }
        )

    if not result:
        raise RuntimeError(f"IB returned no warm-up bars for {symbol}")
    return result


# %%
# Request historical data for our symbols
print("\n" + "=" * 60)
print("HISTORICAL DATA (Warm-up)")
print("=" * 60)

historical_data = {}
try:
    for symbol in SYMBOLS:
        warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"nest_asyncio")
        bars = run_async(get_historical_data(symbol, WARMUP_DAYS))
        if len(bars) < 6:
            raise RuntimeError(f"{symbol} returned {len(bars)} bars; at least 6 are required")
        historical_data[symbol] = bars
        latest = bars[-1]
        print(f"\n{symbol}: {len(bars)} days of data")
        print(
            f"   Latest: {latest['timestamp']} - Close: ${latest['close']:.2f}, Volume: {latest['volume']:,}"
        )
except Exception:
    warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"nest_asyncio")
    run_async(broker.disconnect())
    raise

assert set(historical_data) == set(SYMBOLS)

# %% [markdown]
# The warm-up prints the exact bars the indicators are initialized from. Without a deterministic
# warm-up path, the first minutes after a reconnect or an open size positions from indicators that
# are still filling.

# %% [markdown]
# ## 3. Strategy Definition
#
# This strategy is **identical** to what we use in backtesting.
# The `on_data` signature works with both `ml4t.backtest.Engine` and `ml4t.live.LiveEngine`.


# %%
class MomentumStrategy(Strategy):
    """Five-day ETF momentum strategy shared by backtest and live engines."""

    def __init__(self, lookback: int = 5, threshold: float = 0.02):
        self.lookback = lookback
        self.threshold = threshold
        self.prices: dict[str, list[float]] = {}
        self.signals: list[dict] = []

    def on_start(self, broker):
        """Called when engine starts."""
        logger.info(f"Strategy started: Momentum({self.lookback}, {self.threshold})")
        for symbol, bars in historical_data.items():
            self.prices[symbol] = [bar["close"] for bar in bars]
            logger.info(f"  {symbol}: Loaded {len(self.prices[symbol])} historical prices")

    def on_data(self, timestamp: datetime, data: dict, context: dict, broker):
        """Update trailing momentum and route threshold crossings."""
        for symbol, bar in data.items():
            prices = self.prices.setdefault(symbol, [])
            close = bar["close"]
            prices.append(close)
            if len(prices) <= self.lookback:
                continue
            momentum = (close - prices[-self.lookback - 1]) / prices[-self.lookback - 1]
            position = broker.get_position(symbol)
            has_position = position is not None and position.quantity > 0
            side = None
            if momentum > self.threshold and not has_position:
                side = OrderSide.BUY
            elif momentum < -self.threshold and has_position:
                side = OrderSide.SELL
            if side is None:
                continue
            action = side.value.upper()
            self.signals.append(
                {"timestamp": timestamp, "symbol": symbol, "action": action, "momentum": momentum}
            )
            logger.info(f"{action} {symbol}: momentum {momentum:.2%}")
            broker.submit_order(symbol, 100, side=side)

    def on_end(self, broker):
        """Called when engine stops."""
        logger.info(f"Strategy ended. Signals generated: {len(self.signals)}")


# %% [markdown]
# ## 4. Safe Broker Configuration
#
# Before going live, we wrap the broker with `SafeBroker` which provides:
# - Shadow mode (virtual orders routed through `VirtualPortfolio`, never to IB)
# - Position and order value caps, rate limiting, kill switch
# - Persisted `RiskState`, so the daily-loss counter is read back after a restart
# - Startup reconciliation: `safe_broker.connect()` diffs the persisted snapshot from the previous run
#   against the broker's current positions and pending orders


# %%
RISK_STATE_PATH = get_output_dir(25, "ib_paper_demo") / "risk_state.json"
risk_config = LiveRiskConfig(
    shadow_mode=True,  # CRITICAL: Virtual orders only!
    max_position_value=50_000.0,
    max_order_value=10_000.0,
    max_orders_per_minute=5,
    max_daily_loss=2_500.0,
    max_data_staleness_seconds=60,
    dedup_window_seconds=0.0,  # Disable for demo
    state_file=str(RISK_STATE_PATH),
)

safe_broker = SafeBroker(broker, risk_config)
warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"nest_asyncio")
run_async(safe_broker.connect())

print("\n" + "=" * 60)
print("RISK CONFIGURATION (SHADOW MODE)")
print("=" * 60)
print("   Shadow Mode: [OK] ENABLED (no real orders)")
print(f"   Max Position Value: ${risk_config.max_position_value:,.0f}")
print(f"   Max Order Value: ${risk_config.max_order_value:,.0f}")
print(f"   Max Daily Loss: ${risk_config.max_daily_loss:,.0f}")
print(f"   Max Data Staleness: {risk_config.max_data_staleness_seconds}s")
print(f"   Rate Limit: {risk_config.max_orders_per_minute}/minute")
print(f"   Risk State: {display_path(RISK_STATE_PATH)}")
report = safe_broker.reconciliation_report
print(f"   Startup Reconciliation: {'clean' if report and report['clean'] else 'review report'}")

# %% [markdown]
# ## 5. Real-Time Data Feed
#
# `IBDataFeed` subscribes to live tick data from IB; `BarAggregator` rolls those
# ticks into minute bars for the strategy. A live notebook is only meaningful
# while the market is open. If the run starts after the close, the
# notebook fails loudly rather than substituting historical bars (a backtest
# wearing a live disguise).


# %%
def _is_rth_now(zone: str = "America/New_York") -> bool:
    """Return True iff wall-clock now is inside US-equity regular trading hours."""
    from zoneinfo import ZoneInfo

    now = datetime.now(ZoneInfo(zone))
    if now.weekday() >= 5:
        return False
    open_t = now.replace(hour=9, minute=30, second=0, microsecond=0)
    close_t = now.replace(hour=16, minute=0, second=0, microsecond=0)
    return open_t <= now <= close_t


# %% [markdown]
# The feed, aggregator, and engine are assembled in one helper so the bounded
# run below focuses only on lifecycle and cleanup.


# %%
def build_live_stack() -> tuple[BarAggregator, LiveEngine]:
    """Build the IB feed, minute aggregator, and shadow execution engine."""
    ib_feed = IBDataFeed(ib=broker.ib, symbols=SYMBOLS, tick_throttle_ms=1000)
    feed = BarAggregator(source_feed=ib_feed, bar_size_minutes=1, assets=SYMBOLS)
    strategy = MomentumStrategy(lookback=5, threshold=0.02)
    engine = LiveEngine(strategy=strategy, broker=safe_broker, feed=feed)
    return feed, engine


# %% [markdown]
# A fixed-duration RTH run proves connectivity without leaving an unattended
# strategy loop behind.


# %%
async def run_live_demo(duration_seconds: int = 30):
    """Run strategy with live IB data for specified duration."""
    print("\n" + "=" * 60)
    print("LIVE TRADING DEMO (Shadow Mode)")
    print("=" * 60)

    if not _is_rth_now():
        print()
        print("ERROR: Market is closed. This notebook requires an open RTH session.")
        print("US equity RTH: 09:30-16:00 America/New_York, Mon-Fri.")
        print("Re-run during RTH; the notebook will not synthesize live behaviour from history.")
        raise RuntimeError("IB live-feed gate requires an open US-equity RTH session")

    feed, engine = build_live_stack()
    print(f"\nStarting live engine for {duration_seconds} seconds...")
    print("   Watching: " + ", ".join(SYMBOLS))

    engine_task: asyncio.Task | None = None
    try:
        await engine.connect()

        # Run for a fixed duration. `asyncio.wait_for` is unreliable under
        # nest_asyncio in notebook kernels, so we drive the timeout manually.
        engine_task = asyncio.create_task(engine.run())
        await asyncio.sleep(duration_seconds)
        print(f"\nDemo duration ({duration_seconds}s) reached")
    finally:
        if engine_task is not None and not engine_task.done():
            engine_task.cancel()
            try:
                await engine_task
            except asyncio.CancelledError:
                pass
        feed.stop()
        print("\nEngine Statistics:")
        for key, value in engine.stats.items():
            print(f"   {key}: {value}")


# The live workflow runs after the shadow-order routine is defined so one
# outer `finally` block can always release the IB session.

# %% [markdown]
# The strategy and the risk configuration are the same whether or not the live feed is active;
# only the transport changes. Keeping that boundary is what makes the stack testable, and it means
# a change of feed is never a change of trading rule.

# %% [markdown]
# ## 6. Order Submission Demo
#
# Let's demonstrate order submission in shadow mode.
# Orders are tracked virtually but never sent to IB.


# %%
async def fetch_ib_snapshot(symbol: str) -> float | None:
    """Fetch one delayed top-of-book snapshot from IB for `symbol` and return mid price."""
    contract = Stock(symbol, "SMART", "USD")
    qualified = await broker.ib.qualifyContractsAsync(contract)
    if not qualified:
        return None
    ticker = broker.ib.reqMktData(qualified[0], "", snapshot=True, regulatorySnapshot=False)
    deadline = datetime.now().timestamp() + 5.0
    while datetime.now().timestamp() < deadline:
        bid = float(ticker.bid) if ticker.bid and ticker.bid > 0 else None
        ask = float(ticker.ask) if ticker.ask and ticker.ask > 0 else None
        last = float(ticker.last) if ticker.last and ticker.last > 0 else None
        close = float(ticker.close) if ticker.close and ticker.close > 0 else None
        if bid and ask:
            return (bid + ask) / 2
        if last:
            return last
        if close:
            return close
        await asyncio.sleep(0.2)
    return None


# %% [markdown]
# Every order below is preceded by a real snapshot quote pulled from IB and recorded on
# `SafeBroker`, never a research-time price and never an in-memory mock. That ordering is the
# operational discipline the chapter is teaching. Delayed data is enough to demonstrate it;
# whether the account carries live subscriptions is an account-level question.
#
# The two quantities are sized to fit `max_order_value` at current SPY and QQQ levels, so the
# demo shows a virtual fill rather than the cap rejection. Every other control still applies to
# each leg.


# %%
async def demonstrate_order_submission():
    """Show order submission in shadow mode using live IB snapshot quotes."""
    print("\n" + "=" * 60)
    print("ORDER SUBMISSION DEMO (Shadow Mode)")
    print("=" * 60)

    print("\nFetching delayed snapshot quotes from IB...")
    snapshot_prices = {}
    for symbol in SYMBOLS:
        price = await fetch_ib_snapshot(symbol)
        if price is None:
            raise RuntimeError(f"No IB snapshot quote received for {symbol} within 5 seconds")
        safe_broker.record_market_snapshot(symbol, price)
        snapshot_prices[symbol] = price
        print(f"   {symbol}: ${price:,.2f}")
    assert set(snapshot_prices) == set(SYMBOLS)

    orders = [("SPY", 10), ("QQQ", 12)]

    for symbol, qty in orders:
        print(f"\nSubmitting virtual BUY order: {qty} shares {symbol}")
        order = await safe_broker.submit_order_async(symbol, qty, side=OrderSide.BUY)
        print(f"   Order ID: {order.order_id}")
        print(f"   Status: {order.status.value}")
        print(f"   Side: {order.side.value}")
        print(f"   Quantity: {order.quantity}")

    vp = safe_broker._virtual_portfolio
    print("\nFinal Virtual Portfolio:")
    print(f"   Cash: ${vp.cash:,.2f}")
    for symbol, pos in vp.positions.items():
        value = pos.quantity * (pos.current_price or pos.entry_price)
        print(f"   {symbol}: {pos.quantity} shares @ ${pos.entry_price:.2f} = ${value:,.2f}")


# %% [markdown]
# One outer cleanup boundary covers both the live-feed demonstration and the
# virtual-order demonstration.


# %%
async def run_shadow_workflow() -> None:
    """Run the live-feed and shadow-order gates with guaranteed cleanup."""
    try:
        await run_live_demo(duration_seconds=LIVE_DURATION_SECONDS)
        await demonstrate_order_submission()
    finally:
        await safe_broker.disconnect()


warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"nest_asyncio")
run_async(run_shadow_workflow())

# %% [markdown]
# The shadow portfolio is printed after each submission, so the path from intent to inventory is
# visible without capital at risk. That path is where production bugs surface first, because the
# strategy, the broker adapter and the risk guard all interact on it at once.

# %% [markdown]
# ## 7. Clean Shutdown
#
# Always disconnect cleanly to avoid connection issues on the next run. A disciplined shutdown sequence is
# part of live reliability because stale sessions and dangling subscriptions are operational bugs too.

# %%
print("\n[OK] Shadow workflow completed and disconnected from IB")

# %% [markdown]
# ## Summary
#
# This notebook demonstrated the complete IB integration workflow:
#
# 1. **Connection**: Connect to TWS/Gateway paper trading
# 2. **Account Info**: Query NLV, cash, positions
# 3. **Historical Data**: Request bars for strategy warm-up
# 4. **Real-Time Feed**: Subscribe to tick data, aggregate to bars
# 5. **Safe Trading**: Use SafeBroker in shadow mode
# 6. **Order Submission**: Virtual orders tracked in VirtualPortfolio
#
# ### Key Takeaways
#
# - **Same Strategy class** works in backtest and live
# - **Shadow mode first** - always test before real trading
# - **SafeBroker** provides 8 layers of protection
# - **IBBroker** handles all IB-specific details
#
# ### Next Steps
#
# 1. Run in shadow mode for 1-2 weeks
# 2. Verify signals match backtest expectations
# 3. Enable paper trading (`shadow_mode=False`)
# 4. Monitor for 2-4 weeks on paper
# 5. Gradually transition to live with small positions

# %%
print("\n" + "=" * 60)
print("DEMO COMPLETE")
print("=" * 60)
print("Paper account readiness: verified")
print(f"Symbols Monitored: {', '.join(SYMBOLS)}")
print("Shadow Mode: ENABLED [OK]")
print("\nThe same MomentumStrategy that runs in backtest")
print("completed the IB shadow workflow without sending an order to the venue.")

# %% [markdown]
# ## Key Takeaways
#
# IB carries more connectivity and market-structure complexity than Alpaca, and the execution
# architecture above absorbs all of it without the strategy knowing. Once connectivity, warm-up
# and the shadow-mode controls are standard, moving a strategy between brokers is a configuration
# change rather than a rewrite.
#
# **Next**: Compare the simpler REST-style path in `04_alpaca_paper_trading_demo`, then move to
# `08_pipeline_verification` to check that research outputs and live inputs still match.

```

Exibido na íntegra, com atribuição conforme a licença da fonte. Licença: MIT

Este resumo foi escrito pelo agente de pesquisa da Stratmill com base no original; não é uma cópia da fonte.