Skill: oscraters/ibkr (version 1.0.0)

--- name: ibkr aliases: - openclaw - openclaw-ibkr description: Comprehensive Interactive Brokers (IBKR) TWS/Gateway skill using ib_insync. Includes Python and bash CLIs for account, market data, historical data, contract lookup, scanners, and order lifecycle management. --- # IBKR Skill ## Overview This skill integrates with Interactive Brokers Trader Workstation (TWS) and IB Gateway through `ib_insync`, exposing operational IBKR workflows through: - A unified Python CLI: `scripts/ibkr_cli.py` - A bash CLI wrapper: `scripts/ibkr.sh` - Compatibility alias: `scripts/openclaw.sh` - Backward-compatible script entrypoints: - `scripts/get_account_info.py` - `scripts/get_historical_data.py` - `scripts/place_order.py` ## Prerequisites - Interactive Brokers account with API permissions. - TWS or IB Gateway running with API enabled. - Python 3.9+ recommended. - `ib_insync` installed: ```bash pip install ib_insync ``` ## Connection Configuration All CLIs support connection overrides using flags or env vars. Environment variables: - `IBKR_HOST` (default `127.0.0.1`) - `IBKR_PORT` (default `7497`) - `IBKR_CLIENT_ID` (default `1`) - `IBKR_ACCOUNT` (optional) Examples: ```bash IBKR_PORT=4002 IBKR_CLIENT_ID=14 ./scripts/ibkr.sh account-summary python3 scripts/ibkr_cli.py positions --host 127.0.0.1 --port 7496 --account DU123456 ``` ## Bash CLI `./scripts/ibkr.sh <command> [args]` Supported commands: - `account` (account summary + positions) - `account-summary` - `positions` - `portfolio` - `pnl` - `quote` - `historical` - `place-order` - `cancel-order` - `open-orders` - `executions` - `contract-details` - `scanner` `./scripts/openclaw.sh` is an alias to the same CLI surface. ## Python CLI `python3 scripts/ibkr_cli.py <command> [args]` ### Account Services ```bash python3 scripts/ibkr_cli.py account-summary --json python3 scripts/ibkr_cli.py positions --account DU123456 python3 scripts/ibkr_cli.py portfolio --json python3 scripts/ibkr_cli.py pnl --account DU123456 --wait 2 ``` ### Market Data Services ```bash python3 scripts/ibkr_cli.py quote --symbol AAPL --sec-type STK --market-data-type 3 python3 scripts/ibkr_cli.py historical --symbol EURUSD --sec-type CASH --duration "30 D" --bar-size "1 hour" ``` Notes: - Historical requests support full IB-format values via flags, including spaced tokens like `"30 D"`. - For `CASH`, default `whatToShow` is `MIDPOINT` unless explicitly overridden. ### Contract Discovery ```bash python3 scripts/ibkr_cli.py contract-details --symbol ES --sec-type FUT --expiry 202606 ``` ### Trading and Order Lifecycle ```bash python3 scripts/ibkr_cli.py place-order --symbol AAPL --sec-type STK --action BUY --quantity 10 --order-type MKT python3 scripts/ibkr_cli.py place-order --symbol AAPL --sec-type STK --action SELL --quantity 10 --order-type LMT --limit-price 250 --tif GTC python3 scripts/ibkr_cli.py open-orders python3 scripts/ibkr_cli.py cancel-order --order-id 12345 python3 scripts/ibkr_cli.py executions --json ``` Supported order types in the CLI: - `MKT` - `LMT` - `STP` - `STP LMT` `place-order` waits for status updates and surfaces API errors collected during placement. ### Scanner Service ```bash python3 scripts/ibkr_cli.py scanner --instrument STK --location-code STK.US.MAJOR --scan-code TOP_PERC_GAIN --rows 25 ``` ## Compatibility Scripts Legacy scripts remain supported and now delegate to the unified CLI: - `get_account_info.py` - `get_historical_data.py` (legacy positional mode still works) - `place_order.py` (legacy positional mode still works) ## Error Handling The CLI returns non-zero exit codes for: - Import/dependency failures - Connection failures - Contract qualification failures - Invalid arguments - Missing open order for cancellation ## References See `references/api_reference.md` for IBKR and ib_insync documentation links.

path: README.md
content: # ibkr-skill
Openclaw skill for using IBKR.


path: references/api_reference.md
content: # Interactive Brokers API Reference

This reference consolidates current primary docs for IBKR automation with TWS/Gateway and `ib_insync`.

## Primary Documentation

- IBKR Campus API Hub (current landing):
  - https://ibkrcampus.com/campus/ibkr-api-page/
- TWS API docs on IBKR Campus:
  - https://ibkrcampus.com/campus/ibkr-api-page/twsapi-doc/
- Legacy TWS API reference (still useful for method pages/examples):
  - https://interactivebrokers.github.io/tws-api/
- ib_insync documentation:
  - https://ib-insync.readthedocs.io/

## Core IBKR API Concepts

### Connection

- Local host is usually `127.0.0.1`.
- Common ports:
  - `7497` (TWS paper default)
  - `7496` (TWS live default)
  - `4002` (Gateway paper default)
  - `4001` (Gateway live default)
- `clientId` must be unique per active client session.

### Contracts

IBKR requires precise contract definitions. Use qualification before requests/orders.

- Stocks: `Stock(symbol, exchange, currency)`
- FX: `Forex('EURUSD')`
- Futures: `Future(symbol, expiry, exchange, currency)`
- Options: `Option(symbol, expiry, strike, right, exchange, multiplier, currency)`

Related docs:

- Contract model: https://interactivebrokers.github.io/tws-api/basic_contracts.html
- Contract details requests: https://interactivebrokers.github.io/tws-api/contract_details.html

### Market Data and Historical Bars

- Market data types: `1` live, `2` frozen, `3` delayed, `4` delayed-frozen.
- Historical bars require valid combinations of `durationStr`, `barSizeSetting`, and `whatToShow`.
- For FX (`CASH`), `MIDPOINT` is commonly used.

Related docs:

- Market data type: https://interactivebrokers.github.io/tws-api/market_data_type.html
- Historical bars: https://interactivebrokers.github.io/tws-api/historical_bars.html

### Orders

Common order families in this skill:

- Market (`MKT`)
- Limit (`LMT`)
- Stop (`STP`)
- Stop Limit (`STP LMT`)

Related docs:

- Order submission: https://interactivebrokers.github.io/tws-api/order_submission.html
- Available order types: https://interactivebrokers.github.io/tws-api/available_orders.html

### Account and Portfolio

Related docs:

- Account summary and updates: https://interactivebrokers.github.io/tws-api/account_updates.html
- PnL: https://interactivebrokers.github.io/tws-api/pnl.html
- Positions: https://interactivebrokers.github.io/tws-api/positions.html

### Scanner

Related docs:

- Market scanners: https://interactivebrokers.github.io/tws-api/market_scanners.html

## Reliability / Safety Notes

- Always test against paper trading first.
- Use explicit `--account`, `--client-id`, and `--port` when running multiple automation processes.
- Qualify contracts before data requests and order submission.
- Wait for terminal order statuses and inspect reported error codes/messages.


path: scripts/get_account_info.py
content: #!/usr/bin/env python3
"""Backward-compatible account summary + positions wrapper.

Usage: python get_account_info.py [--host ... --port ... --client-id ... --account ... --json]
"""

from __future__ import annotations

import argparse
from ibkr_cli import main as ibkr_main


def build_args() -> list[str]:
    parser = argparse.ArgumentParser(description="Get account summary and positions")
    parser.add_argument("--host")
    parser.add_argument("--port", type=int)
    parser.add_argument("--client-id", type=int)
    parser.add_argument("--account")
    parser.add_argument("--readonly", action="store_true")
    parser.add_argument("--timeout", type=float)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    shared = []
    if args.host:
        shared += ["--host", args.host]
    if args.port is not None:
        shared += ["--port", str(args.port)]
    if args.client_id is not None:
        shared += ["--client-id", str(args.client_id)]
    if args.account:
        shared += ["--account", args.account]
    if args.readonly:
        shared += ["--readonly"]
    if args.timeout is not None:
        shared += ["--timeout", str(args.timeout)]
    if args.json:
        shared += ["--json"]

    return shared


def main() -> int:
    shared = build_args()
    rc = ibkr_main(["account-summary", *shared])
    if rc != 0:
        return rc
    return ibkr_main(["positions", *shared])


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/get_historical_data.py
content: #!/usr/bin/env python3
"""Backward-compatible historical data wrapper.

Modern usage:
  python get_historical_data.py --symbol AAPL --sec-type STK --duration "30 D" --bar-size "1 day"

Legacy usage:
  python get_historical_data.py AAPL STK [exchange] [currency] [duration] [bar_size]
"""

from __future__ import annotations

import argparse
import sys
from ibkr_cli import main as ibkr_main


def parse_legacy(argv: list[str]) -> list[str]:
    # Legacy positional mode:
    # <symbol> <sec_type> [exchange] [currency] [duration] [bar_size]
    symbol = argv[0]
    sec_type = argv[1]
    exchange = argv[2] if len(argv) > 2 else "SMART"
    currency = argv[3] if len(argv) > 3 else "USD"
    duration = "30 D"
    bar_size = "1 day"
    tail = argv[4:]
    if len(tail) == 1:
        duration = tail[0]
    elif len(tail) == 2:
        duration = f"{tail[0]} {tail[1]}"
    elif len(tail) == 3:
        duration = f"{tail[0]} {tail[1]}"
        bar_size = tail[2]
    elif len(tail) >= 4:
        duration = f"{tail[0]} {tail[1]}"
        bar_size = f"{tail[2]} {tail[3]}"

    return [
        "historical",
        "--symbol",
        symbol,
        "--sec-type",
        sec_type,
        "--exchange",
        exchange,
        "--currency",
        currency,
        "--duration",
        duration,
        "--bar-size",
        bar_size,
    ]


def parse_modern() -> list[str]:
    parser = argparse.ArgumentParser(description="Fetch historical market data")
    parser.add_argument("--host")
    parser.add_argument("--port", type=int)
    parser.add_argument("--client-id", type=int)
    parser.add_argument("--account")
    parser.add_argument("--readonly", action="store_true")
    parser.add_argument("--timeout", type=float)
    parser.add_argument("--json", action="store_true")

    parser.add_argument("--symbol", required=True)
    parser.add_argument("--sec-type", default="STK")
    parser.add_argument("--exchange", default="SMART")
    parser.add_argument("--currency", default="USD")
    parser.add_argument("--primary-exchange", default="")
    parser.add_argument("--local-symbol", default="")
    parser.add_argument("--trading-class", default="")
    parser.add_argument("--expiry", default="")
    parser.add_argument("--strike", type=float, default=0.0)
    parser.add_argument("--right", default="")
    parser.add_argument("--multiplier", default="")
    parser.add_argument("--con-id", type=int, default=0)

    parser.add_argument("--end", default="")
    parser.add_argument("--duration", default="30 D")
    parser.add_argument("--bar-size", default="1 day")
    parser.add_argument("--what-to-show", default="")
    parser.add_argument("--use-rth", default="true")

    args = parser.parse_args()

    cli_args = [
        "historical",
        "--symbol",
        args.symbol,
        "--sec-type",
        args.sec_type,
        "--exchange",
        args.exchange,
        "--currency",
        args.currency,
        "--primary-exchange",
        args.primary_exchange,
        "--local-symbol",
        args.local_symbol,
        "--trading-class",
        args.trading_class,
        "--expiry",
        args.expiry,
        "--strike",
        str(args.strike),
        "--right",
        args.right,
        "--multiplier",
        args.multiplier,
        "--con-id",
        str(args.con_id),
        "--end",
        args.end,
        "--duration",
        args.duration,
        "--bar-size",
        args.bar_size,
        "--what-to-show",
        args.what_to_show,
        "--use-rth",
        args.use_rth,
    ]

    if args.host:
        cli_args += ["--host", args.host]
    if args.port is not None:
        cli_args += ["--port", str(args.port)]
    if args.client_id is not None:
        cli_args += ["--client-id", str(args.client_id)]
    if args.account:
        cli_args += ["--account", args.account]
    if args.readonly:
        cli_args += ["--readonly"]
    if args.timeout is not None:
        cli_args += ["--timeout", str(args.timeout)]
    if args.json:
        cli_args += ["--json"]

    return cli_args


def main() -> int:
    argv = sys.argv[1:]
    if argv and not argv[0].startswith("-"):
        if len(argv) < 2:
            print(
                "Usage: python get_historical_data.py <symbol> <type> [exchange] [currency] [duration] [bar_size]",
                file=sys.stderr,
            )
            return 2
        return ibkr_main(parse_legacy(argv))

    return ibkr_main(parse_modern())


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/ibkr.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLI="$SCRIPT_DIR/ibkr_cli.py"

if [[ $# -eq 0 ]]; then
  cat <<'EOF'
Usage: ibkr.sh <command> [args]

Commands:
  account              account summary + positions
  account-summary      raw account summary tags
  positions            open positions
  portfolio            portfolio with pnl fields
  pnl                  account pnl snapshot
  quote                quote snapshot
  historical           historical bars
  place-order          place order
  cancel-order         cancel order by id
  open-orders          list open orders
  executions           list executions/fills
  contract-details     lookup contract metadata
  scanner              run scanner query

Examples:
  ibkr.sh account --account DU123456
  ibkr.sh quote --symbol AAPL --sec-type STK --market-data-type 3
  ibkr.sh historical --symbol EURUSD --sec-type CASH --duration "7 D" --bar-size "1 hour"
  ibkr.sh place-order --symbol AAPL --action BUY --quantity 10 --order-type LMT --limit-price 150
EOF
  exit 1
fi

cmd="$1"
shift

case "$cmd" in
  account)
    python3 "$CLI" account-summary "$@"
    python3 "$CLI" positions "$@"
    ;;
  account-summary|positions|portfolio|pnl|quote|historical|place-order|cancel-order|open-orders|executions|contract-details|scanner)
    python3 "$CLI" "$cmd" "$@"
    ;;
  *)
    echo "Unknown command: $cmd" >&2
    exit 2
    ;;
esac


path: scripts/ibkr_cli.py
content: #!/usr/bin/env python3
"""Unified Interactive Brokers CLI built on ib_insync.

This CLI supports common TWS/Gateway API workflows:
- account summary, positions, portfolio, PnL
- quote snapshots and historical bars
- contract discovery
- order placement/cancellation/open-order listing
- executions and scanner queries

Connection defaults can be overridden via env vars:
- IBKR_HOST (default: 127.0.0.1)
- IBKR_PORT (default: 7497)
- IBKR_CLIENT_ID (default: 1)
- IBKR_ACCOUNT (optional)
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from dataclasses import dataclass
from typing import Any, Iterable, Optional

_IB_IMPORT_ERROR: Optional[Exception] = None

try:
    from ib_insync import (
        IB,
        CFD,
        Bag,
        Bond,
        Commodity,
        Contract,
        Crypto,
        Forex,
        Future,
        Index,
        LimitOrder,
        MarketOrder,
        Option,
        ScannerSubscription,
        Stock,
        StopLimitOrder,
        StopOrder,
        Trade,
    )
except Exception as exc:  # pragma: no cover - runtime dependency gate
    _IB_IMPORT_ERROR = exc


DONE_STATUSES = {
    "ApiCancelled",
    "Cancelled",
    "Filled",
    "Inactive",
}


@dataclass
class ConnectionConfig:
    host: str
    port: int
    client_id: int
    account: str
    readonly: bool
    timeout: float


def die(message: str, code: int = 2) -> int:
    print(f"ERROR: {message}", file=sys.stderr)
    return code


def require_ib() -> Optional[int]:
    if _IB_IMPORT_ERROR is not None:
        return die(
            "ib_insync is required. Install with: pip install ib_insync\n"
            f"Import error: {_IB_IMPORT_ERROR}",
            3,
        )
    return None


def create_connection_config(args: argparse.Namespace) -> ConnectionConfig:
    return ConnectionConfig(
        host=args.host,
        port=args.port,
        client_id=args.client_id,
        account=args.account,
        readonly=args.readonly,
        timeout=args.timeout,
    )


def connect_ib(config: ConnectionConfig) -> Any:
    ib = IB()
    ib.connect(
        host=config.host,
        port=config.port,
        clientId=config.client_id,
        timeout=config.timeout,
        readonly=config.readonly,
        account=config.account or "",
    )
    return ib


def bool_flag(value: str) -> bool:
    normalized = value.strip().lower()
    if normalized in {"1", "true", "yes", "y", "on"}:
        return True
    if normalized in {"0", "false", "no", "n", "off"}:
        return False
    raise argparse.ArgumentTypeError(f"invalid boolean value: {value}")


def print_rows(rows: Iterable[dict[str, Any]], as_json: bool) -> None:
    if as_json:
        print(json.dumps(list(rows), indent=2, default=str))
        return
    for row in rows:
        print(" ".join(f"{k}={v}" for k, v in row.items()))


def build_contract(args: argparse.Namespace) -> Any:
    sec_type = args.sec_type.upper()

    if sec_type == "STK":
        return Stock(args.symbol, args.exchange, args.currency, primaryExchange=args.primary_exchange)
    if sec_type == "CASH":
        if len(args.symbol) != 6:
            raise ValueError("CASH symbol must be a 6-letter pair like EURUSD")
        return Forex(args.symbol, exchange=args.exchange)
    if sec_type == "OPT":
        if not args.expiry or not args.strike or not args.right:
            raise ValueError("OPT requires --expiry, --strike, and --right")
        return Option(
            args.symbol,
            args.expiry,
            args.strike,
            args.right.upper(),
            args.exchange,
            args.multiplier,
            args.currency,
            args.trading_class,
        )
    if sec_type == "FUT":
        if not args.expiry:
            raise ValueError("FUT requires --expiry")
        return Future(
            args.symbol,
            args.expiry,
            args.exchange,
            args.currency,
            args.multiplier,
            args.local_symbol,
            args.trading_class,
        )
    if sec_type == "CFD":
        return CFD(args.symbol, args.exchange, args.currency)
    if sec_type == "IND":
        return Index(args.symbol, args.exchange, args.currency, localSymbol=args.local_symbol)
    if sec_type == "CRYPTO":
        return Crypto(args.symbol, args.exchange, args.currency)
    if sec_type == "BOND":
        return Bond(
            secType="BOND",
            symbol=args.symbol,
            exchange=args.exchange,
            currency=args.currency,
            localSymbol=args.local_symbol,
        )
    if sec_type == "BAG":
        return Bag(symbol=args.symbol, exchange=args.exchange, currency=args.currency)
    if sec_type == "CMDTY":
        return Commodity(args.symbol, args.exchange, args.currency)
    return Contract(
        symbol=args.symbol,
        secType=sec_type,
        exchange=args.exchange,
        currency=args.currency,
        primaryExchange=args.primary_exchange,
        localSymbol=args.local_symbol,
        tradingClass=args.trading_class,
        lastTradeDateOrContractMonth=args.expiry,
        strike=args.strike,
        right=args.right,
        multiplier=args.multiplier,
        conId=args.con_id,
    )


def normalize_what_to_show(sec_type: str, requested: str) -> str:
    if requested:
        return requested.upper()
    if sec_type.upper() == "CASH":
        return "MIDPOINT"
    return "TRADES"


def wait_for_terminal_status(ib: Any, trade: Trade, wait_seconds: float) -> None:
    remaining = wait_seconds
    seen_status = None
    while remaining > 0:
        status = trade.orderStatus.status
        if status != seen_status:
            print(f"order_status={status}")
            seen_status = status
        if status in DONE_STATUSES:
            break
        ib.sleep(0.2)
        remaining -= 0.2


def add_shared_connection_flags(parser: argparse.ArgumentParser) -> None:
    parser.add_argument("--host", default=os.getenv("IBKR_HOST", "127.0.0.1"))
    parser.add_argument("--port", type=int, default=int(os.getenv("IBKR_PORT", "7497")))
    parser.add_argument("--client-id", type=int, default=int(os.getenv("IBKR_CLIENT_ID", "1")))
    parser.add_argument("--account", default=os.getenv("IBKR_ACCOUNT", ""))
    parser.add_argument("--readonly", action="store_true", help="Request read-only mode")
    parser.add_argument("--timeout", type=float, default=10.0, help="IB connect timeout in seconds")
    parser.add_argument("--json", action="store_true", help="Emit JSON output")


def add_contract_flags(parser: argparse.ArgumentParser, include_sec_type: bool = True) -> None:
    parser.add_argument("--symbol", required=True)
    if include_sec_type:
        parser.add_argument("--sec-type", default="STK", help="e.g. STK, CASH, OPT, FUT, CFD, IND, CRYPTO")
    parser.add_argument("--exchange", default="SMART")
    parser.add_argument("--currency", default="USD")
    parser.add_argument("--primary-exchange", default="")
    parser.add_argument("--local-symbol", default="")
    parser.add_argument("--trading-class", default="")
    parser.add_argument("--expiry", default="")
    parser.add_argument("--strike", type=float, default=0.0)
    parser.add_argument("--right", default="")
    parser.add_argument("--multiplier", default="")
    parser.add_argument("--con-id", type=int, default=0)


def cmd_account_summary(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        items = ib.accountSummary(args.account or "")
        rows = [
            {
                "account": item.account,
                "tag": item.tag,
                "value": item.value,
                "currency": item.currency,
            }
            for item in items
        ]
        print_rows(rows, args.json)
    finally:
        ib.disconnect()
    return 0


def cmd_positions(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        items = ib.positions(args.account or "")
        rows = [
            {
                "account": p.account,
                "symbol": p.contract.symbol,
                "secType": p.contract.secType,
                "exchange": p.contract.exchange,
                "currency": p.contract.currency,
                "position": p.position,
                "avgCost": p.avgCost,
            }
            for p in items
        ]
        print_rows(rows, args.json)
    finally:
        ib.disconnect()
    return 0


def cmd_portfolio(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        items = ib.portfolio(args.account or "")
        rows = [
            {
                "account": p.account,
                "symbol": p.contract.symbol,
                "secType": p.contract.secType,
                "position": p.position,
                "marketPrice": p.marketPrice,
                "marketValue": p.marketValue,
                "avgCost": p.averageCost,
                "unrealizedPNL": p.unrealizedPNL,
                "realizedPNL": p.realizedPNL,
            }
            for p in items
        ]
        print_rows(rows, args.json)
    finally:
        ib.disconnect()
    return 0


def cmd_pnl(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        account = args.account
        if not account:
            managed = ib.managedAccounts()
            if not managed:
                return die("No managed accounts returned; pass --account explicitly", 4)
            account = managed[0]

        pnl = ib.reqPnL(account, modelCode=args.model_code)
        ib.sleep(args.wait)
        row = {
            "account": account,
            "modelCode": args.model_code,
            "dailyPnL": pnl.dailyPnL,
            "unrealizedPnL": pnl.unrealizedPnL,
            "realizedPnL": pnl.realizedPnL,
        }
        print_rows([row], args.json)
        ib.cancelPnL(account, modelCode=args.model_code)
    finally:
        ib.disconnect()
    return 0


def cmd_quote(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        contract = build_contract(args)
        qualified = ib.qualifyContracts(contract)
        if not qualified:
            return die("Contract qualification failed", 4)
        ib.reqMarketDataType(args.market_data_type)
        ticker = ib.reqTickers(qualified[0])[0]
        row = {
            "symbol": ticker.contract.symbol,
            "secType": ticker.contract.secType,
            "bid": ticker.bid,
            "ask": ticker.ask,
            "last": ticker.last,
            "close": ticker.close,
            "high": ticker.high,
            "low": ticker.low,
            "volume": ticker.volume,
            "time": ticker.time,
            "marketDataType": args.market_data_type,
        }
        print_rows([row], args.json)
    finally:
        ib.disconnect()
    return 0


def cmd_historical(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        contract = build_contract(args)
        qualified = ib.qualifyContracts(contract)
        if not qualified:
            return die("Contract qualification failed", 4)

        what_to_show = normalize_what_to_show(args.sec_type, args.what_to_show)
        bars = ib.reqHistoricalData(
            qualified[0],
            endDateTime=args.end,
            durationStr=args.duration,
            barSizeSetting=args.bar_size,
            whatToShow=what_to_show,
            useRTH=args.use_rth,
            formatDate=1,
            keepUpToDate=False,
            timeout=args.timeout,
        )
        rows = [
            {
                "date": b.date,
                "open": b.open,
                "high": b.high,
                "low": b.low,
                "close": b.close,
                "volume": b.volume,
                "average": b.average,
                "barCount": b.barCount,
            }
            for b in bars
        ]
        print_rows(rows, args.json)
    finally:
        ib.disconnect()
    return 0


def build_order(args: argparse.Namespace) -> Any:
    order_type = args.order_type.upper()
    action = args.action.upper()

    if order_type == "MKT":
        order = MarketOrder(action, args.quantity)
    elif order_type == "LMT":
        if args.limit_price is None:
            raise ValueError("LMT requires --limit-price")
        order = LimitOrder(action, args.quantity, args.limit_price)
    elif order_type == "STP":
        if args.stop_price is None:
            raise ValueError("STP requires --stop-price")
        order = StopOrder(action, args.quantity, args.stop_price)
    elif order_type in {"STP LMT", "STP-LMT", "STPLMT"}:
        if args.stop_price is None or args.limit_price is None:
            raise ValueError("STP LMT requires --stop-price and --limit-price")
        order = StopLimitOrder(action, args.quantity, args.limit_price, args.stop_price)
    else:
        raise ValueError(f"Unsupported order type: {order_type}")

    if args.tif:
        order.tif = args.tif
    if args.outside_rth:
        order.outsideRth = True
    if args.account:
        order.account = args.account

    return order


def cmd_place_order(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    errors: list[dict[str, Any]] = []

    def on_error(req_id: int, code: int, msg: str, contract: Any) -> None:
        errors.append(
            {
                "reqId": req_id,
                "code": code,
                "message": msg,
                "contract": getattr(contract, "symbol", ""),
            }
        )

    ib.errorEvent += on_error
    try:
        contract = build_contract(args)
        qualified = ib.qualifyContracts(contract)
        if not qualified:
            return die("Contract qualification failed", 4)

        order = build_order(args)
        trade = ib.placeOrder(qualified[0], order)
        wait_for_terminal_status(ib, trade, args.wait)

        row = {
            "orderId": trade.order.orderId,
            "permId": trade.order.permId,
            "clientId": trade.order.clientId,
            "symbol": qualified[0].symbol,
            "action": trade.order.action,
            "orderType": trade.order.orderType,
            "quantity": trade.order.totalQuantity,
            "status": trade.orderStatus.status,
            "filled": trade.orderStatus.filled,
            "remaining": trade.orderStatus.remaining,
            "avgFillPrice": trade.orderStatus.avgFillPrice,
            "lastFillPrice": trade.orderStatus.lastFillPrice,
        }

        if args.json:
            payload = {"trade": row, "errors": errors}
            print(json.dumps(payload, indent=2, default=str))
        else:
            print_rows([row], False)
            for err in errors:
                print_rows([err], False)
    finally:
        ib.errorEvent -= on_error
        ib.disconnect()
    return 0


def cmd_cancel_order(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        open_trades = ib.openTrades()
        matching = [t for t in open_trades if t.order.orderId == args.order_id]
        if not matching:
            return die(f"No open trade found for orderId={args.order_id}", 4)
        trade = matching[0]
        ib.cancelOrder(trade.order)
        ib.sleep(0.5)
        row = {
            "orderId": trade.order.orderId,
            "status": trade.orderStatus.status,
        }
        print_rows([row], args.json)
    finally:
        ib.disconnect()
    return 0


def cmd_open_orders(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        rows = []
        for trade in ib.openTrades():
            rows.append(
                {
                    "orderId": trade.order.orderId,
                    "permId": trade.order.permId,
                    "symbol": trade.contract.symbol,
                    "secType": trade.contract.secType,
                    "action": trade.order.action,
                    "orderType": trade.order.orderType,
                    "quantity": trade.order.totalQuantity,
                    "status": trade.orderStatus.status,
                    "filled": trade.orderStatus.filled,
                    "remaining": trade.orderStatus.remaining,
                }
            )
        print_rows(rows, args.json)
    finally:
        ib.disconnect()
    return 0


def cmd_executions(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        fills = ib.reqExecutions()
        rows = [
            {
                "time": f.time,
                "symbol": f.contract.symbol,
                "secType": f.contract.secType,
                "side": f.execution.side,
                "shares": f.execution.shares,
                "price": f.execution.price,
                "orderId": f.execution.orderId,
                "execId": f.execution.execId,
                "account": f.execution.acctNumber,
            }
            for f in fills
        ]
        print_rows(rows, args.json)
    finally:
        ib.disconnect()
    return 0


def cmd_contract_details(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        contract = build_contract(args)
        details = ib.reqContractDetails(contract)
        rows = [
            {
                "conId": d.contract.conId,
                "symbol": d.contract.symbol,
                "secType": d.contract.secType,
                "exchange": d.contract.exchange,
                "primaryExchange": d.contract.primaryExchange,
                "currency": d.contract.currency,
                "longName": d.longName,
                "category": d.category,
                "subcategory": d.subcategory,
                "timeZoneId": d.timeZoneId,
                "tradingHours": d.tradingHours,
                "liquidHours": d.liquidHours,
                "minTick": d.minTick,
            }
            for d in details
        ]
        print_rows(rows, args.json)
    finally:
        ib.disconnect()
    return 0


def cmd_scanner(args: argparse.Namespace) -> int:
    ib = connect_ib(create_connection_config(args))
    try:
        subscription = ScannerSubscription(
            instrument=args.instrument,
            locationCode=args.location_code,
            scanCode=args.scan_code,
            abovePrice=args.above_price,
            belowPrice=args.below_price,
            aboveVolume=args.above_volume,
            numberOfRows=args.rows,
        )
        scan_data = ib.reqScannerData(subscription)
        rows = [
            {
                "rank": item.rank,
                "symbol": item.contractDetails.contract.symbol,
                "secType": item.contractDetails.contract.secType,
                "exchange": item.contractDetails.contract.exchange,
                "distance": item.distance,
                "benchmark": item.benchmark,
                "projection": item.projection,
            }
            for item in scan_data
        ]
        print_rows(rows, args.json)
    finally:
        ib.disconnect()
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="ibkr-cli",
        description="Interactive Brokers TWS/Gateway CLI powered by ib_insync",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    p = sub.add_parser("account-summary", help="Fetch account summary tags")
    add_shared_connection_flags(p)
    p.set_defaults(func=cmd_account_summary)

    p = sub.add_parser("positions", help="Fetch current positions")
    add_shared_connection_flags(p)
    p.set_defaults(func=cmd_positions)

    p = sub.add_parser("portfolio", help="Fetch portfolio with PnL metrics")
    add_shared_connection_flags(p)
    p.set_defaults(func=cmd_portfolio)

    p = sub.add_parser("pnl", help="Fetch account-level PnL")
    add_shared_connection_flags(p)
    p.add_argument("--model-code", default="")
    p.add_argument("--wait", type=float, default=1.0, help="Seconds to wait for initial PnL update")
    p.set_defaults(func=cmd_pnl)

    p = sub.add_parser("quote", help="Fetch quote snapshot")
    add_shared_connection_flags(p)
    add_contract_flags(p)
    p.add_argument(
        "--market-data-type",
        type=int,
        default=1,
        choices=[1, 2, 3, 4],
        help="1=live 2=frozen 3=delayed 4=delayed-frozen",
    )
    p.set_defaults(func=cmd_quote)

    p = sub.add_parser("historical", help="Fetch historical bar data")
    add_shared_connection_flags(p)
    add_contract_flags(p)
    p.add_argument("--end", default="", help="IB endDateTime, e.g. 20260226-16:00:00")
    p.add_argument("--duration", default="30 D", help="e.g. '30 D', '2 W', '1 Y'")
    p.add_argument("--bar-size", default="1 day", help="e.g. '1 min', '1 hour', '1 day'")
    p.add_argument("--what-to-show", default="", help="TRADES, MIDPOINT, BID, ASK, ...")
    p.add_argument("--use-rth", type=bool_flag, default=True, help="Use regular trading hours")
    p.set_defaults(func=cmd_historical)

    p = sub.add_parser("place-order", help="Place an order and wait for status updates")
    add_shared_connection_flags(p)
    add_contract_flags(p)
    p.add_argument("--action", required=True, choices=["BUY", "SELL"])
    p.add_argument("--quantity", required=True, type=float)
    p.add_argument("--order-type", default="MKT", help="MKT, LMT, STP, STP LMT")
    p.add_argument("--limit-price", type=float)
    p.add_argument("--stop-price", type=float)
    p.add_argument("--tif", default="", help="DAY, GTC, IOC, FOK, OPG, etc")
    p.add_argument("--outside-rth", action="store_true")
    p.add_argument("--wait", type=float, default=8.0, help="Seconds to wait for terminal status")
    p.set_defaults(func=cmd_place_order)

    p = sub.add_parser("cancel-order", help="Cancel an open order by order id")
    add_shared_connection_flags(p)
    p.add_argument("--order-id", required=True, type=int)
    p.set_defaults(func=cmd_cancel_order)

    p = sub.add_parser("open-orders", help="List open orders")
    add_shared_connection_flags(p)
    p.set_defaults(func=cmd_open_orders)

    p = sub.add_parser("executions", help="List execution fills currently available in session")
    add_shared_connection_flags(p)
    p.set_defaults(func=cmd_executions)

    p = sub.add_parser("contract-details", help="Resolve contract details")
    add_shared_connection_flags(p)
    add_contract_flags(p)
    p.set_defaults(func=cmd_contract_details)

    p = sub.add_parser("scanner", help="Run market scanner query")
    add_shared_connection_flags(p)
    p.add_argument("--instrument", default="STK")
    p.add_argument("--location-code", default="STK.US.MAJOR")
    p.add_argument("--scan-code", default="TOP_PERC_GAIN")
    p.add_argument("--above-price", type=float, default=0)
    p.add_argument("--below-price", type=float, default=0)
    p.add_argument("--above-volume", type=int, default=0)
    p.add_argument("--rows", type=int, default=25)
    p.set_defaults(func=cmd_scanner)

    return parser


def main(argv: Optional[list[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    need_ib = require_ib()
    if need_ib is not None:
        return need_ib

    try:
        return args.func(args)
    except KeyboardInterrupt:
        return die("Interrupted", 130)
    except Exception as exc:
        return die(str(exc), 1)


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/openclaw.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/ibkr.sh" "$@"


path: scripts/place_order.py
content: #!/usr/bin/env python3
"""Backward-compatible order placement wrapper.

Modern usage:
  python place_order.py --symbol AAPL --action BUY --quantity 100 --order-type MKT

Legacy usage:
  python place_order.py <symbol> <action> <quantity> [type] [price]
"""

from __future__ import annotations

import argparse
import sys
from ibkr_cli import main as ibkr_main


def parse_legacy(argv: list[str]) -> list[str]:
    symbol = argv[0]
    action = argv[1].upper()
    quantity = argv[2]
    order_type = argv[3].upper() if len(argv) > 3 else "MKT"
    price = argv[4] if len(argv) > 4 else None

    cli_args = [
        "place-order",
        "--symbol",
        symbol,
        "--sec-type",
        "STK",
        "--exchange",
        "SMART",
        "--currency",
        "USD",
        "--action",
        action,
        "--quantity",
        quantity,
        "--order-type",
        order_type,
    ]

    if order_type == "LMT" and price is not None:
        cli_args += ["--limit-price", price]

    return cli_args


def parse_modern() -> list[str]:
    parser = argparse.ArgumentParser(description="Place an order")
    parser.add_argument("--host")
    parser.add_argument("--port", type=int)
    parser.add_argument("--client-id", type=int)
    parser.add_argument("--account")
    parser.add_argument("--timeout", type=float)
    parser.add_argument("--json", action="store_true")

    parser.add_argument("--symbol", required=True)
    parser.add_argument("--sec-type", default="STK")
    parser.add_argument("--exchange", default="SMART")
    parser.add_argument("--currency", default="USD")
    parser.add_argument("--primary-exchange", default="")
    parser.add_argument("--local-symbol", default="")
    parser.add_argument("--trading-class", default="")
    parser.add_argument("--expiry", default="")
    parser.add_argument("--strike", type=float, default=0.0)
    parser.add_argument("--right", default="")
    parser.add_argument("--multiplier", default="")
    parser.add_argument("--con-id", type=int, default=0)

    parser.add_argument("--action", required=True, choices=["BUY", "SELL"])
    parser.add_argument("--quantity", required=True, type=float)
    parser.add_argument("--order-type", default="MKT")
    parser.add_argument("--limit-price", type=float)
    parser.add_argument("--stop-price", type=float)
    parser.add_argument("--tif", default="")
    parser.add_argument("--outside-rth", action="store_true")
    parser.add_argument("--wait", type=float, default=8.0)

    args = parser.parse_args()

    cli_args = [
        "place-order",
        "--symbol",
        args.symbol,
        "--sec-type",
        args.sec_type,
        "--exchange",
        args.exchange,
        "--currency",
        args.currency,
        "--primary-exchange",
        args.primary_exchange,
        "--local-symbol",
        args.local_symbol,
        "--trading-class",
        args.trading_class,
        "--expiry",
        args.expiry,
        "--strike",
        str(args.strike),
        "--right",
        args.right,
        "--multiplier",
        args.multiplier,
        "--con-id",
        str(args.con_id),
        "--action",
        args.action,
        "--quantity",
        str(args.quantity),
        "--order-type",
        args.order_type,
        "--wait",
        str(args.wait),
    ]

    if args.limit_price is not None:
        cli_args += ["--limit-price", str(args.limit_price)]
    if args.stop_price is not None:
        cli_args += ["--stop-price", str(args.stop_price)]
    if args.tif:
        cli_args += ["--tif", args.tif]
    if args.outside_rth:
        cli_args += ["--outside-rth"]

    if args.host:
        cli_args += ["--host", args.host]
    if args.port is not None:
        cli_args += ["--port", str(args.port)]
    if args.client_id is not None:
        cli_args += ["--client-id", str(args.client_id)]
    if args.account:
        cli_args += ["--account", args.account]
    if args.timeout is not None:
        cli_args += ["--timeout", str(args.timeout)]
    if args.json:
        cli_args += ["--json"]

    return cli_args


def main() -> int:
    argv = sys.argv[1:]
    if argv and not argv[0].startswith("-"):
        if len(argv) < 3:
            print("Usage: python place_order.py <symbol> <action> <quantity> [type] [price]", file=sys.stderr)
            return 2
        return ibkr_main(parse_legacy(argv))

    return ibkr_main(parse_modern())


if __name__ == "__main__":
    raise SystemExit(main())


path: tests/cli_smoke.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

python3 -m py_compile \
  scripts/ibkr_cli.py \
  scripts/get_account_info.py \
  scripts/get_historical_data.py \
  scripts/place_order.py

python3 scripts/ibkr_cli.py --help >/dev/null
python3 scripts/ibkr_cli.py historical --help >/dev/null
python3 scripts/ibkr_cli.py place-order --help >/dev/null

./scripts/ibkr.sh >/dev/null 2>&1 || true
./scripts/ibkr.sh quote --help >/dev/null
./scripts/openclaw.sh quote --help >/dev/null

echo "CLI smoke checks passed"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)