Skill: alexliu0130/ibkr-trader-toolkit (version 0.2.4)

--- name: ibkr-trader-toolkit description: DEPRECATED — this skill was renamed to ibkr-options-assistant. Install that skill instead. If a user invokes this skill, do not run any scripts; tell them to install `ibkr-options-assistant` from ClawHub or `https://github.com/AlexLiu0130/ibkr-options-assistant`. --- # ⚠️ DEPRECATED — Renamed to `ibkr-options-assistant` This skill has been renamed. **No code or behavior changes**, just a new slug that better reflects positioning (options-analysis assistant driven by Claude, not a trading bot). ## What you should do Install the renamed skill instead: ### ClawHub ``` ibkr-options-assistant ``` ### GitHub ```bash git clone https://github.com/AlexLiu0130/ibkr-options-assistant.git \ ~/.claude/skills/ibkr-options-assistant ``` (The old GitHub URL `AlexLiu0130/ibkr-trader-toolkit` 301-redirects, so existing clones keep working — but rename your local symlink to `ibkr-options-assistant` so Claude Code picks the skill up under the new name.) ## Why renamed? The old name overlapped with order-execution / bot-style IBKR skills on ClawHub. This toolkit is **17 read-only analysis scripts plus one opt-in order-execution script with dual safety gates**, designed to be driven by Claude (or another AI agent), not a 24/7 bot. The new name makes that distinction visible in search results. ## No further updates here All future releases ship under `ibkr-options-assistant`. This slug stays published only as a redirect for users who installed the old name.

path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.4] - 2026-05-14

### Changed
- **Renamed** the project from `ibkr-trader-toolkit` to `ibkr-options-assistant`.
  - GitHub repo renamed (old URL 301-redirects to the new one).
  - `SKILL.md` frontmatter `name:` updated; description, scripts, and behavior unchanged.
  - README titles and all references updated.

### Why
The old name overlapped with order-execution / bot-style IBKR skills and
buried the toolkit's actual positioning. This is an options-analysis
assistant designed to be driven by Claude (or another AI agent), not a
trading bot — the new name makes that distinction visible in search
results and skill catalogs.

## [0.2.3] - 2026-05-14

### Docs
- New "Security Model" section in `README.md` and `README.zh-CN.md` covering
  what the toolkit can do, the two trust boundaries the user controls
  (Gateway login + `trade.py` dual gates), how output data should be
  treated, and a recommended setup. No code changes.

### Why
ClawScan ASI03 (Identity and Privilege Abuse) and ASI06 (Memory and
Context Poisoning) flag the toolkit's intrinsic broker-account access
and JSON output as risk surface. Both are design-intent rather than
defects — ClawScan itself notes "purpose-aligned" on ASI06. Documenting
the security model explicitly lets users and reviewers see that the
trust boundaries are deliberate and where they can be tightened
further. The `clawscan-note` published alongside this release covers
the same ground for automated review.

## [0.2.2] - 2026-05-14

### Security
- `alerts_monitor.py` no longer uses `eval()` to evaluate rule conditions.
  Conditions are now parsed with `ast.parse(mode="eval")` and walked by a
  small interpreter that only accepts: the documented variables
  (`price`, `delta`, `iv`, `dte`, `unrealized_pnl`), numeric literals,
  comparisons (`< <= > >= == !=`), boolean operators (`and / or / not`),
  arithmetic (`+ - * /`, unary `-`), and the calls `abs / min / max /
  round`. Attribute access, indexing, other function calls, lambdas, and
  comprehensions are rejected at parse time, eliminating the
  ClawScan ASI05 "unrevised eval()" finding.

### Why
ClawScan flagged the eval-based path as high-risk even with restricted
globals, because eval-based alert rules are materially riskier than a
restricted parser, especially for a script documented as cron-friendly.
The new evaluator preserves every condition shown in the docs while
removing the attack surface.

## [0.2.1] - 2026-05-14

### Added
- `trade.py` now supports extended-hours order routing. By default, the
  script auto-detects whether the current time is inside US/Eastern Regular
  Trading Hours (09:30–16:00 weekdays) and sets `outsideRth=True` on the
  order when placed off-hours. Two explicit flags override this:
    - `--outside-rth` — force outsideRth=True (pre/post-market, overnight)
    - `--rth-only` — force outsideRth=False (RTH-only routing)
  Both flags work for `stock`, `option`, `combo`, `future`, and `forex`
  subcommands.
- Order payload JSON now includes an `outside_rth` field, and the dry-run
  preview shows the resolved value plus the reason (auto-detection or
  explicit flag).

### Why
Without `outsideRth=True`, orders submitted off-hours sit on IBKR's gateway
until 09:30 ET (Warning 399). With it, orders can route to ECNs during the
pre/post-market sessions (04:00–09:30 and 16:00–20:00 ET) and to overnight
sessions where supported. Auto-detection is the default so users don't
have to remember the flag in normal use.

## [0.2.0] - 2026-05-14

### Added
- **`cost_basis.py`** — premium-adjusted effective cost basis for wheel positions
  (subtracts collected premium from broker avg cost). Discovers held stocks via
  `--portfolio-file` or accepts symbols positionally. Pulls option fills from
  `ib.fills()` and optional `~/.ibkr_flex/*.csv`. ClientId offset 17.
- **`concentration.py`** — HHI, top-3/top-5 concentration %, sector breakdown
  with built-in ~70-ticker GICS map, plus actionable warnings (top-3 > 30%,
  any sector > 20%, single position > 20%). Reuses `portfolio_positions` fetch.
  ClientId offset 18.
- **`flex_import.py`** — parses both CSV and XML IBKR Flex Statement files via
  `ibflex` library (or plain `csv` fallback). Normalizes trades into a unified
  JSON schema consumable by `pnl_analytics.py` / `cost_basis.py`. Supports
  `--since` and `--symbol` filters.
- **`trade.py`** — opt-in order execution for stocks, single-leg options,
  multi-leg combos (BAG/ComboLeg), futures, and FX. **Dual-gate safety**:
  refuses to place orders unless both `IBKR_TRADING_ENABLED=1` is set AND
  `--confirm-trade` is passed. Subcommands: `stock`, `option`, `combo`,
  `future`, `forex`, `cancel`, `list-orders`. Guardrails: notional > $100k,
  options qty > 1000, stock qty > 10000 require `--allow-large`; blocklist
  via `IBKR_TRADING_BLOCKLIST`. ClientId offset 19; the only script that uses
  `readonly=False`.
- **`references/trading.md`** — bilingual (EN + 中文) documentation of trading
  setup, both safety gates, the Gateway "Read-Only API" toggle, and full
  examples for each subcommand.
- **`references/options_book_summary.md`** — 500-line operational-rules
  reference distilled from McMillan / Overby / Natenberg / Sinclair. 13
  sections covering IV environment playbook, strike/DTE selection, adjustment
  decision trees, position sizing, skew interpretation, earnings IV crush,
  volatility estimators, Greeks-vs-Greeks math, common mistakes.
- **`README.zh-CN.md`** — full Chinese translation of the README. Both
  versions cross-link from the top.
- 5th SKILL.md workflow: "Should I roll position X?" — covers portfolio
  confirm, roll candidate chain survey, and decision tree pointer.
- `Trading Mode (Optional)` section in README pointing to `references/trading.md`.
- `CHANGELOG.md` (this file) at repo root.
- Module-level constant `IBKR_REALIZED_PNL_SENTINEL_THRESHOLD` in
  `pnl_analytics.py` documenting the IBKR sentinel-value cutoff.

### Changed
- **All scripts normalized to English-first.** Module docstrings, `log()`
  strings, inline comments, and argparse help text translated from Chinese
  to English. The Chinese strategy display names in `options_analyzer.py`'s
  `STRATEGIES[*].name_cn` JSON output keys are intentionally preserved.
- `risk_simulator.py` concentration warning threshold lowered from 50% to 30% to
  match the SKILL.md workflow documentation.
- `wheel_tracker.py` annualized-return math no longer multiplies capital by the
  number of journal entries (rolls reuse capital, they don't multiply it). Now
  uses the latest entry's strike × 100 as the open-leg capital.
- README's `IBKR_MARKET_DATA_TYPE` default documented as `3` in both the Quick
  Start example and the Configuration table, matching `.env.example`.
- README API setup step clarified: "Leave 'Allow connections from localhost
  only' checked — it's safer and the toolkit doesn't need it disabled."
- GitHub repo description updated to remove the stale "Streamlit dashboard"
  mention.
- `references/wheel_strategy.md` examples now use the actual positional
  subcommand syntax (`wheel_tracker.py add-entry ...` and
  `wheel_tracker.py summary`) instead of the non-existent `--add-entry` flag.
- `requirements.txt` adds `ibflex>=0.16` and upper bounds on `pandas` (<3) and
  `numpy` (<3).
- SKILL.md Operating Constraints table updated: "Smart data type" replaces
  the stale "Real-time by default" claim that contradicted the `=3` default.

## [0.1.4] - 2026-05-14

### Added
- Operations Guide section in README: second-user setup to keep Gateway alive
  when using the mobile app, launchd/systemd auto-restart recipes, and triage
  for the `ushmds` (US historical-data farm) outage.

## [0.1.3] - 2026-05-14

### Added
- Comprehensive IBKR market data subscription guide in README: per-feature
  subscription requirements, recommended bundles, commission-waiver math.

## [0.1.2] - 2026-05-14

### Changed
- Default `IBKR_MARKET_DATA_TYPE` is now `3` (delayed-smart). IBKR auto-upgrades
  to realtime for subscribed instruments and falls back to delayed otherwise,
  avoiding Error 10089 ("subscription required") on day one.

## [0.1.1] - 2026-05-13

### Changed
- SKILL.md rewritten for readability on the ClawHub web view.

### Removed
- Streamlit dashboard (out of scope for a Claude Code skill; the toolkit emits
  JSON for the agent to reason about, not a UI to look at).

### Fixed
- `earnings_calendar.py` now uses the Nasdaq public API instead of the
  deprecated yahoo-earnings-calendar source.
- `options_analyzer.py` no longer crashes when a strategy's max-loss is
  unlimited (e.g. naked calls).

## [0.1.0] - 2026-05-12

### Added
- Initial release of IBKR Trader Toolkit.
- 13 Python scripts covering market quotes, option chains, portfolio Greeks,
  McMillan/Overby strategy recommender, P&L analytics, risk simulator, wheel
  tracker, earnings calendar, technical indicators, and YAML-driven alerts.
- Shared `ib_client.py` connection layer with readonly safety, per-script
  clientId offsets, and historical-data pacing.
- SKILL.md so Claude Code can use the toolkit directly.
- Reference docs: full strategy library, Greeks primer, wheel strategy guide,
  troubleshooting.

[Unreleased]: https://github.com/AlexLiu0130/ibkr-options-assistant/compare/v0.2.4...HEAD
[0.2.4]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.2.4
[0.2.3]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.2.3
[0.2.2]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.2.2
[0.2.1]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.2.1
[0.2.0]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.2.0
[0.1.4]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.1.4
[0.1.3]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.1.3
[0.1.2]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.1.2
[0.1.1]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.1.1
[0.1.0]: https://github.com/AlexLiu0130/ibkr-options-assistant/releases/tag/v0.1.0


path: LICENSE
content: MIT License

Copyright (c) 2026 AlexLiu0130

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


path: README.md
content: # IBKR Options Assistant

> ## ⚠️ This skill was renamed
>
> **The package `ibkr-trader-toolkit` is deprecated.** All future releases ship
> as **[`ibkr-options-assistant`](https://github.com/AlexLiu0130/ibkr-options-assistant)** —
> same code, same scripts, same behavior. The new name better reflects
> positioning (an options-analysis assistant for Claude, not a trading bot).
>
> **Install the renamed skill:**
> ```bash
> git clone https://github.com/AlexLiu0130/ibkr-options-assistant.git \
>   ~/.claude/skills/ibkr-options-assistant
> ```
> On ClawHub, search for **`ibkr-options-assistant`**.

---

> A complete options & stock trading assistant for Interactive Brokers — real-time Greeks, McMillan/Overby strategy library, P&L analytics, Wheel tracking, earnings warnings, and risk simulation. Designed to plug straight into Claude Code as a skill.

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![IBKR](https://img.shields.io/badge/broker-Interactive%20Brokers-red.svg)](https://www.interactivebrokers.com/)

> [中文版 README](README.zh-CN.md)

<!-- screenshot: hero -->

---

## Table of Contents

- [Features](#-features)
- [Requirements](#-requirements)
- [IBKR Market Data Subscriptions](#-ibkr-market-data-subscriptions)
- [Quick Start](#-quick-start)
- [Operations Guide (Second User, Auto-Restart)](#-operations-guide)
- [Trading Mode (Optional)](#-trading-mode-optional)
- [Security Model](#-security-model)
- [Claude Code Integration](#-claude-code-integration)
- [Command Reference](#-command-reference)
- [Configuration](#-configuration)
- [Troubleshooting](#-troubleshooting)
- [Advanced](#-advanced)
- [Contributing](#-contributing)
- [License](#-license)
- [Disclaimer](#-disclaimer)

---

## ✨ Features

17 focused Python scripts. Read-only scripts output JSON so Claude (or any other agent) can reason about the data. Only `trade.py` can place orders, and only when both safety gates are explicitly opened.

**Data & quotes**
- `market_quote.py` — Real-time bid/ask/last/IV/volume for stocks, ETFs, options.
- `contracts.py` — Universal contract resolver (`SPY`, `AAPL 2026-06-19 200 C`, etc.).
- `technical_indicators.py` — RSI, MA(20/50/200), Bollinger, ATR with text summary.

**Options analysis**
- `options_chain.py` — Full option chain with Greeks, OI, volume, IV per expiry.
- `options_analyzer.py` — McMillan/Overby strategy recommender (20+ strategies across 4 tiers, IV-aware).
- `options_daily.py` — End-of-day options report: warnings, IV environment, position-specific suggestions.

**Portfolio & P&L**
- `portfolio_positions.py` — Live positions with per-leg and portfolio-level Greeks.
- `pnl_analytics.py` — Realized P&L, win rate, best/worst trades (from `ib.executions` + optional Flex CSV).
- `flex_import.py` — Parse IBKR Flex Statement CSV/XML history into normalized JSON.
- `cost_basis.py` — **Premium-adjusted** effective cost basis (the wheel-trader number IBKR doesn't compute).
- `concentration.py` — HHI, sector mix, top-N concentration risk metrics.
- `risk_simulator.py` — "What if I add this trade?" Greeks delta preview before execution.

**Strategy automation**
- `wheel_tracker.py` — Track wheel cycles (short put → assignment → covered call → called away) with cumulative premium and annualized yield.
- `earnings_calendar.py` — Next earnings date for portfolio symbols, flags options positions expiring across earnings.
- `alerts_monitor.py` — YAML-driven threshold alerts (delta, IV percentile, DTE, P&L) for cron use.

**Trade execution (opt-in)**
- `trade.py` — Stocks, single-leg options, multi-leg combos, futures, FX. Dual-gate safety (`IBKR_TRADING_ENABLED=1` + `--confirm-trade`). See [Trading Mode](#-trading-mode-optional).

**Connection layer**
- `ib_client.py` — Shared IB Gateway connection with readonly safety, per-script clientId offsets, and historical-data pacing.

---

## 📋 Requirements

| Requirement | Notes |
|---|---|
| **Python** | 3.10 or newer |
| **IBKR account** | Live or paper. Paper account is fine for learning. |
| **IB Gateway** | Free download from [IBKR](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php). TWS also works (different port). |
| **Market data subscriptions** | See [next section](#-ibkr-market-data-subscriptions) — needed for realtime quotes & Greeks. Delayed data is free. |
| **OS** | macOS / Linux / Windows. All scripts are pure Python. |

> **Why IB Gateway, not TWS?** Gateway is headless, uses less memory, and is the standard choice for programmatic access. TWS works too — set `IBKR_PORT=7497` (paper) or `7496` (live).

---

## 💳 IBKR Market Data Subscriptions

This toolkit's value depends heavily on **what data IBKR will send you**. Subscriptions are configured per account at Client Portal → Settings → User Settings → Market Data Subscriptions.

### What each feature needs

| Feature | Subscriptions needed | Works on delayed? |
|---------|---------------------|-------------------|
| Stock/ETF price (`market_quote.py`) | None — Snapshot bundle for realtime, otherwise delayed | ✅ Yes |
| Portfolio positions & P&L (`portfolio_positions.py`, `pnl_analytics.py`) | None — account data is always available | ✅ Yes |
| Option chain bid/ask (`options_chain.py`) | **OPRA Top of Book** | ⚠️ Partial — bid/ask only, no Greeks |
| **Option Greeks** (IV, delta, gamma, vega, theta) | **OPRA + the underlying's stock exchange** | ❌ **No** — Greeks require realtime |
| Earnings calendar (`earnings_calendar.py`) | None — uses Nasdaq public API | ✅ Yes |
| Technical indicators (`technical_indicators.py`) | None — uses historical bars (free) | ✅ Yes |

**Key insight from IBKR API docs:**
> *"To receive live Greek values it is necessary to have market data subscriptions for both the option and the underlying contract."*

Translation: if you only subscribe to OPRA but not (say) NYSE ARCA, you get SPY option **prices** but not SPY option **Greeks** — because IBKR can't compute delta/gamma without realtime underlying.

### Recommended bundles for this toolkit

| Bundle | Monthly cost | Waived if | What you get |
|--------|--------------|-----------|--------------|
| **Free (delayed)** | $0 | always | Stock prices, bid/ask, portfolio data, historical bars. **No Greeks**, no live IV environment. |
| **OPRA only** | $1.50 | $20+ commissions/mo | Realtime option bid/ask. Greeks only for symbols whose underlying you also subscribe to. |
| **US Securities Bundle + OPRA** ⭐ recommended | $11.50 | $30+ commissions/mo | Realtime stock + option data + Greeks for all US-listed symbols. The toolkit's full feature set. |

**Bundle contents (US Securities Snapshot and Futures Value Bundle):**
- Consolidated realtime NBBO for US stocks/ETFs
- Top-of-book for major futures (CME, CBOT, COMEX, NYMEX)
- OTC Markets quotes

> **Commission waiver math:** If you trade 1 lot of options per week (~4 contracts × $0.65 commission ≈ $2.60/wk = ~$10/mo), you're partway there. Two roundtrip options trades per month usually clears the $30 threshold.

### How to subscribe

1. Log into [IBKR Client Portal](https://www.interactivebrokers.com/sso/Login)
2. Settings (top right) → User Settings → Market Data Subscriptions
3. Click "Configure"
4. Search and add:
   - **"US Securities Snapshot and Futures Value Bundle"** (NL)
   - **"OPRA Top of Book"** (NL)
5. Confirm and accept
6. Subscriptions usually activate within 10 minutes; restart IB Gateway

### How the toolkit handles missing subscriptions

The default `IBKR_MARKET_DATA_TYPE=3` (delayed-smart) tells IBKR:
> *"Give me realtime if I'm subscribed; fall back to delayed if I'm not."*

This means **the toolkit works on day one with $0 subscriptions** — you just won't have Greeks until you upgrade. No Error 10089 crashes.

If you ever want to force a specific mode:
- `IBKR_MARKET_DATA_TYPE=1` — strict realtime (errors on unsubscribed)
- `IBKR_MARKET_DATA_TYPE=3` — smart delayed (default; auto-upgrades)
- `IBKR_MARKET_DATA_TYPE=4` — delayed-frozen (last cached value, useful after-hours)

**Sources:**
- [IBKR Market Data Pricing](https://www.interactivebrokers.com/en/pricing/market-data-pricing.php)
- [TWS API: Option Greeks docs](https://interactivebrokers.github.io/tws-api/option_computations.html)

---

## 🚀 Quick Start

### 1. Install IB Gateway

Download from [interactivebrokers.com/en/trading/ibgateway-stable.php](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php) and install. Launch it and log in with your IBKR credentials (use **paper** mode for testing).

<!-- screenshot: gateway-login -->

### 2. Enable the API

Inside IB Gateway:

1. `Configure → Settings → API → Settings`
2. Check **Enable ActiveX and Socket Clients**
3. Check **Read-Only API** (recommended — this toolkit is read-only by design)
4. **Socket port**: `4001` (live) or `4002` (paper). Match this to `IBKR_PORT` in your `.env`.
5. **Trusted IPs**: add `127.0.0.1`
6. Leave **Allow connections from localhost only** checked — it's safer and the toolkit doesn't need it disabled.
7. Click **OK** and restart Gateway.

<!-- screenshot: gateway-api-settings -->

### 3. Clone & install

```bash
git clone https://github.com/AlexLiu0130/ibkr-options-assistant.git
cd ibkr-options-assistant

python -m venv .venv
source .venv/bin/activate            # Windows: .venv\Scripts\activate

pip install -r requirements.txt
```

### 4. Configure environment

```bash
cp .env.example .env
$EDITOR .env
```

Minimum fields to review (defaults usually work):

```ini
IBKR_HOST=127.0.0.1
IBKR_PORT=4001                  # 4002 if paper, 7497 if TWS paper
IBKR_CLIENT_ID_BASE=11
IBKR_MARKET_DATA_TYPE=3         # default 3; auto-upgrades to realtime when subscribed
```

### 5. First call

With Gateway logged in:

```bash
python scripts/market_quote.py SPY
```

Expected output (JSON):

```json
{
  "symbol": "SPY",
  "last": 612.34,
  "bid": 612.31,
  "ask": 612.35,
  "volume": 28931402,
  "timestamp": "2026-05-12 10:14:22"
}
```

If you see this — you're done. Try `python scripts/portfolio_positions.py` next.

---

## 🛠️ Operations Guide

Running this toolkit 24/7 reliably hits two operational problems IBKR doesn't talk about loudly. Solve them once, never think about them again.

### Problem 1: Mobile app kills your Gateway session

**IBKR allows only one active session per username.** If your script runs IB Gateway on the Mac and then you open IBKR Mobile to check your portfolio, **the mobile login kicks the Gateway out** — all your scripts fail until you log Gateway back in.

**Solution: Create a second user (free)**

Use one username for the API (Gateway) and another for the mobile/TWS. They share the same account and see the same positions, but each has its own login session.

**Steps:**

1. Log into [IBKR Client Portal](https://www.interactivebrokers.com/sso/Login) with your primary username
2. Profile icon (top right) → **Settings**
3. Under **Account Settings**, find **Users & Access Rights**
4. Click **+** to add a user
5. Select **"Yes"** for *"Is this a secondary user for the primary account holder?"*
6. Complete the form (the second user can be view-only or have trading rights — your choice)
7. Submit. IBKR usually approves within 1 business day
8. Logout, log in with the new secondary username once to set the password
9. **Use the secondary username in IB Gateway**; keep the primary for the mobile app

This is **free** and the second user has full read access to the same account.

**Source:** [Adding a Second User on IBKR](https://help.piranhaprofits.com/knowledge/how-to-create-a-second-user-why-do-i-need-it)

---

### Problem 2: Gateway dies overnight, scripts fail at 9am

IB Gateway auto-logs-out daily (IBKR forces it for security) and sometimes crashes after weeks of uptime. If you rely on cron jobs or a morning routine, you want it always-on.

**Solution: Auto-restart with launchd (macOS) or systemd (Linux)**

#### macOS — launchd

Create `~/Library/LaunchAgents/com.user.ibgateway.plist`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.user.ibgateway</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Applications/IB Gateway 10.30/ibgateway.app/Contents/MacOS/JavaApplicationStub</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/ibgateway.out.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/ibgateway.err.log</string>
</dict>
</plist>
```

Load it:

```bash
launchctl load ~/Library/LaunchAgents/com.user.ibgateway.plist
```

It will auto-restart Gateway whenever it dies. To stop: `launchctl unload ~/Library/LaunchAgents/com.user.ibgateway.plist`.

> **Note:** Gateway still requires daily 2FA via IBKR Mobile. Auto-restart handles crashes but not the once-a-day login prompt — set [auto-restart inside Gateway](#enable-auto-restart-inside-gateway) (see below) to skip 2FA for 7 days.

#### Enable auto-restart inside Gateway

In IB Gateway: **Configure → Lock and Exit → Auto Restart**. Pick a daily restart time (e.g. 03:00 ET). This keeps Gateway running for up to a week without re-entering 2FA. After 7 days you have to log in manually once.

#### Linux — systemd

Create `~/.config/systemd/user/ibgateway.service`:

```ini
[Unit]
Description=IB Gateway
After=network.target

[Service]
ExecStart=/opt/ibgateway/ibgateway
Restart=always
RestartSec=30

[Install]
WantedBy=default.target
```

```bash
systemctl --user enable --now ibgateway
```

---

### Problem 3: `Warning 2105: ushmds connection broken`

If `market_quote.py` or `technical_indicators.py` hangs and you see this in Gateway logs, **IBKR's US historical-data farm is down**. It's a server-side outage; usually self-heals in 5–30 minutes.

**What you'll see:**

```
reqHistoricalData: Timeout for Stock(...)
RuntimeError: Historical data returned empty
```

**Diagnose by enabling logs:**

```python
from ib_async import util
util.logToConsole()
# look for: Warning 2105, reqId -1: 历史市场数据场连接中断:ushmds
```

**What still works during a `ushmds` outage:**
- `options_chain.py`, `portfolio_positions.py`, `options_daily.py` — they use realtime market data (`hfarm`), not historical
- `market_quote.py`, `technical_indicators.py` — these need `ushmds`, will time out

**Workarounds:**
- Wait it out (5–30 min, IBKR usually recovers automatically)
- Restart IB Gateway to force-reconnect to a different farm endpoint
- For automation, scripts should treat historical-data errors as soft failures — the toolkit already raises a clear `RuntimeError` you can catch upstream

---

## 💱 Trading Mode (Optional)

The 13 core scripts in this toolkit are **read-only by design** — they query
data and compute Greeks, but never call `ib.placeOrder()`. If you want to
actually place orders, opt in by using `scripts/trade.py`, the **one** script
in the repo that sends orders.

`trade.py` supports stocks, single-leg options, multi-leg option combos,
futures, and FX. Every order command requires **two safety gates**:

1. `IBKR_TRADING_ENABLED=1` env var (set per shell)
2. `--confirm-trade` CLI flag (set per invocation)

Without both, the script runs in dry-run mode — it qualifies the contract,
runs pre-flight checks (Gateway readonly toggle, buying power, notional &
quantity guardrails, blocklist), and prints exactly what it *would* have sent,
without calling `placeOrder()`.

**Quick example (dry-run):**

```bash
python scripts/trade.py option MU 2026-06-12 720 P 2 \
    --action SELL --order-type LMT --limit-price 14.50
# → mode: "dry_run", result: "DRY_RUN_NO_ORDER_PLACED"
```

**Quick example (live, paper account first!):**

```bash
export IBKR_PORT=4002          # paper
export IBKR_TRADING_ENABLED=1  # Gate 1
python scripts/trade.py option MU 2026-06-12 720 P 2 \
    --action SELL --order-type LMT --limit-price 14.50 \
    --confirm-trade             # Gate 2
```

Built-in guardrails reject notionals > $100k, stock qty > 10,000, option qty
> 1,000, and any symbol in `IBKR_TRADING_BLOCKLIST` — override with
`--allow-large`.

⚠️ **Test on paper (`IBKR_PORT=4002`) before pointing at live.** Full docs,
all subcommands, cancel/list-orders workflow, and bilingual reference in
[`references/trading.md`](references/trading.md).

---

## 🔐 Security Model

This toolkit talks to a live broker session, so the security posture is worth
stating explicitly. Most of it is by design — what matters is knowing where
the trust boundaries are.

### What the toolkit can do

| Capability | Which scripts | Default state |
|------------|---------------|---------------|
| Read your IBKR account (positions, balances, P&L, market data) | All scripts | **On** — required for any analysis |
| Place / cancel / list orders against your IBKR account | `trade.py` only | **Off** — needs `IBKR_TRADING_ENABLED=1` **and** `--confirm-trade` |
| Call `api.nasdaq.com` (and `finnhub.io` if `FINNHUB_API_KEY` set) | `earnings_calendar.py` | On — public HTTPS, no IBKR credentials transmitted |
| Read / write `~/.ibkr_wheel_journal.json`, `~/.ibkr_alerts.yaml`, `~/.ibkr_flex/*.csv` | Wheel / alerts / Flex scripts | On — user-owned files in `$HOME` |
| Evaluate arbitrary code from config files | **None** | `alerts_monitor.py` parses conditions via `ast.parse` with a strict whitelist (no `eval`, no `__import__`, no attribute access) |

The 16 non-trading scripts open the IBKR connection with `readonly=True`. Only
`trade.py` uses `readonly=False`, and only after both gates are open.

### Trust boundaries

The toolkit's authority is bounded by **two layers you control**, not by the toolkit itself:

1. **The IB Gateway login** — your gateway session decides which account is reachable. Use a paper trading account (`IBKR_PORT=4002`) or a dedicated read-only IBKR sub-user if you want to cap blast radius further. Leaving Gateway's "Read-Only API" toggle enabled prevents `trade.py` from working at all, even with both software gates opened.
2. **The two software gates inside `trade.py`** — `IBKR_TRADING_ENABLED` (env) and `--confirm-trade` (CLI flag). Missing either one and the script prints a dry-run payload and exits without contacting the broker. Additional guardrails refuse oversized orders (`--allow-large` required for notional > $100k, options qty > 1000, stock qty > 10000) and honor `IBKR_TRADING_BLOCKLIST` for tickers you never want touched.

### Data the toolkit emits

Read-only scripts emit JSON to stdout (or to `--output FILE`) containing your
positions, P&L history, Greeks, Flex statement contents, and similar
broker-derived data. This is the toolkit's purpose — Claude (or any other
agent) reads that JSON to reason about your portfolio. Treat the output the
same way you'd treat a brokerage statement:

- Don't paste it into untrusted chats or share `--output` files publicly.
- The agent context window will contain the same data while you're working — keep that conversation private.
- Nothing is uploaded by the toolkit itself; it only talks to the IBKR Gateway and (optionally) Nasdaq / Finnhub public endpoints.

### Recommended setup

- Run on a personal machine, not shared infrastructure.
- Keep IB Gateway's "Allow connections from localhost only" checked.
- Default `IBKR_HOST=127.0.0.1` is correct unless you specifically need a remote Gateway.
- Use a paper account during initial testing.
- Leave `trade.py`'s safety gates closed unless you explicitly want order execution.

---

## 🤖 Claude Code Integration

This repo ships a `SKILL.md` so Claude Code can use it directly. Two ways to install:

### Option A — Symlink (recommended for development)

```bash
mkdir -p ~/.claude/skills
ln -s "$(pwd)" ~/.claude/skills/ibkr-options-assistant
```

Restart Claude Code. Ask: *"What's SPY trading at right now?"* — Claude will trigger `market_quote.py` instead of doing a web search.

### Option B — Plugin

If you use the Claude Code plugin system, point the marketplace at this repo and install `ibkr-options-assistant` from your plugin manager.

### Trigger phrases

The skill description (see `SKILL.md`) is tuned to fire whenever you mention any of: options strategy, position risk, Greeks, IV, wheel, earnings impact on options, P&L analysis, or stock price. You usually don't need to say "use IBKR".

---

## 📖 Command Reference

All scripts read `.env` automatically and accept `--help`. Every script prints JSON to stdout and logs to stderr — pipe stdout into `jq` or `--output file.json`.

| Script | One-liner | Example |
|---|---|---|
| `market_quote.py` | Real-time quote for one symbol | `python scripts/market_quote.py SPY` |
| `options_chain.py` | Option chain with Greeks | `python scripts/options_chain.py AAPL --dte-min 7 --dte-max 45` |
| `portfolio_positions.py` | Live positions + Greeks | `python scripts/portfolio_positions.py` |
| `options_analyzer.py` | Strategy recommender | `python scripts/options_analyzer.py SPY --outlook bullish --iv-context` |
| `options_daily.py` | End-of-day options report | `python scripts/options_daily.py --output ~/daily.json` |
| `pnl_analytics.py` | Realized P&L summary | `python scripts/pnl_analytics.py --days 30 --by symbol` |
| `earnings_calendar.py` | Next earnings + DTE | `python scripts/earnings_calendar.py AAPL ARM MU --days 30` |
| `risk_simulator.py` | Pre-trade Greeks preview | `python scripts/risk_simulator.py --add "AAPL 200 2026-06-26 P SELL 2"` |
| `technical_indicators.py` | RSI / MA / BB / ATR | `python scripts/technical_indicators.py NVDA --indicators rsi,ma,bb` |
| `wheel_tracker.py` | Wheel cycle journal | `python scripts/wheel_tracker.py summary` |
| `alerts_monitor.py` | Threshold alerts | `python scripts/alerts_monitor.py --config ~/.ibkr_alerts.yaml` |
| `cost_basis.py` | Premium-adjusted cost basis (wheel) | `python scripts/cost_basis.py MU --portfolio-file /tmp/portfolio.json` |
| `concentration.py` | HHI / sector / top-N concentration | `python scripts/concentration.py` |
| `flex_import.py` | Parse IBKR Flex CSV/XML history | `python scripts/flex_import.py --flex-dir ~/.ibkr_flex --since 2026-01-01` |
| `trade.py` | **Place orders (opt-in)** — see [Trading Mode](#-trading-mode-optional) | `python scripts/trade.py stock AAPL 1` (dry-run by default) |
| `contracts.py` | (library) contract resolver | imported by other scripts |
| `ib_client.py` | (library) shared connection | imported by other scripts |

### Common patterns

**Save a chain then analyze offline** (avoids hammering IBKR):

```bash
python scripts/options_chain.py AAPL --output /tmp/aapl_chain.json
python scripts/options_analyzer.py AAPL --outlook neutral \
       --chain-file /tmp/aapl_chain.json --iv-context
```

**Cron a daily alerts check** (every weekday at 9:33am):

```cron
33 9 * * 1-5 cd /path/to/ibkr-options-assistant && \
    .venv/bin/python scripts/alerts_monitor.py >> ~/.ibkr_alerts.log 2>&1
```

**Risk-check before a trade**:

```bash
python scripts/risk_simulator.py \
    --add "SPY 600 2026-06-19 P SELL 1" \
    --add "SPY 580 2026-06-19 P BUY 1"
```

---

## 🔧 Configuration

All configuration lives in `.env` (copied from `.env.example`).

| Variable | Default | Purpose |
|---|---|---|
| `IBKR_HOST` | `127.0.0.1` | Gateway host. Almost always localhost. |
| `IBKR_PORT` | `4001` | `4001` Gateway live · `4002` Gateway paper · `7496` TWS live · `7497` TWS paper |
| `IBKR_CLIENT_ID_BASE` | `11` | Scripts add an offset (7–16); the resulting clientId must be unique across all your apps. |
| `IBKR_MARKET_DATA_TYPE` | `3` | `1` realtime · `2` frozen · `3` delayed (default — auto-upgrades to realtime when subscribed) · `4` delayed-frozen |
| `FINNHUB_API_KEY` | *(unset)* | Optional. Falls back when `yahoo-earnings-calendar` is unavailable. Free at <https://finnhub.io>. |
| `IBKR_FLEX_TOKEN` | *(unset)* | Optional. IBKR Flex Web Service token for full historical P&L (beyond the ~2-day execution window). |
| `IBKR_FLEX_QUERY_ID` | *(unset)* | Optional. Flex Query ID. |

### ClientId offsets

Each script reserves a unique offset so they can coexist:

```
market_quote.py        offset 7   → clientId = base + 7
options_chain.py       offset 8
portfolio_positions.py offset 9
options_analyzer.py    offset 10
options_daily.py       offset 11
pnl_analytics.py       offset 12
risk_simulator.py      offset 13
technical_indicators   offset 14
wheel_tracker.py       offset 15
alerts_monitor.py      offset 16
```

With `IBKR_CLIENT_ID_BASE=11` (default), `market_quote.py` uses clientId `18`. If you run TWS/Gateway with **another** app on clientId `18`, raise the base.

### User data (outside the repo)

These files live in your home dir and are not committed:

- `~/.ibkr_wheel_journal.json` — wheel cycle entries
- `~/.ibkr_alerts.yaml` — alert rules
- `~/.ibkr_flex/*.csv` — Flex Statement exports

---

## ❓ Troubleshooting

Full guide: [`references/troubleshooting.md`](references/troubleshooting.md). The five issues that cover 90% of first-run problems:

### 1. `clientId X already in use`

Two scripts (or two copies of one script) hit IB Gateway with the same clientId. Either:
- Wait for the previous script to disconnect (usually a couple of seconds), **or**
- Raise `IBKR_CLIENT_ID_BASE` to a value no other app uses, **or**
- Confirm you don't have TWS *and* Gateway running at the same time on overlapping clientIds.

### 2. `Error 200: No security definition has been found`

The contract didn't resolve. Causes:
- Typo in the symbol (`SPYY` → `SPY`).
- Expired option date.
- Strike doesn't exist (e.g. `599.5` when only `599` and `600` are listed).
- Exchange routing — for some tickers you need to pass `--exchange ARCA` instead of `SMART`.

### 3. `Error 10091: subscription required`

You don't have a real-time market-data subscription for that exchange. Two fixes:
- Switch to delayed: `IBKR_MARKET_DATA_TYPE=3` in `.env`.
- Subscribe (Account Management → Settings → Market Data Subscriptions).

### 4. Connection refused / `TimeoutError`

Gateway isn't reachable. Checklist:
- Is Gateway running and **logged in**? (A logged-out Gateway doesn't accept connections.)
- Is the port in `.env` the same as Gateway's `API → Settings → Socket port`?
- Is `127.0.0.1` in **Trusted IPs**?
- Restart Gateway after changing API settings — they don't take effect live.

### 5. `modelGreeks is None`

The market is closed and there's no cached delayed-Greeks snapshot. Either wait for the next open, or set `IBKR_MARKET_DATA_TYPE=4` (delayed-frozen) and retry — delayed-frozen serves the last delayed snapshot from previous session.

---

## 📚 Advanced

| Topic | Doc |
|---|---|
| Full strategy library (20+ McMillan/Overby strategies with construction, IV preference, P&L profile) | [`references/strategies.md`](references/strategies.md) |
| Greeks primer (Delta, Gamma, Vega, Theta, Rho — practical interpretation) | [`references/greeks_primer.md`](references/greeks_primer.md) |
| Wheel strategy in depth (strike/DTE selection, roll-vs-assign decision tree) | [`references/wheel_strategy.md`](references/wheel_strategy.md) |
| All known errors and fixes | [`references/troubleshooting.md`](references/troubleshooting.md) |

---

## 🤝 Contributing

PRs and issues welcome. Keep it minimal:

- One concern per PR.
- New scripts should output JSON to stdout, log to stderr, and reserve a unique `CLIENT_ID_OFFSET`.
- No hard-coded paths — read configuration from `os.getenv()`.
- No buy/sell recommendations baked into the scripts; the toolkit produces *data*, the user (or Claude) makes decisions.

---

## 📜 License

[MIT](LICENSE). Use it, fork it, ship it.

---

## ⚠️ Disclaimer

**This software is for educational and personal use only. It is not financial advice.**

- The toolkit is **read-only by design**: it queries data and does Greeks math; it does not place orders. The repo never calls `placeOrder()`.
- All trading decisions are yours. Options trading involves substantial risk of loss and is not appropriate for every investor.
- The `options_analyzer.py` recommendations are educational mappings from outlook + risk profile → strategy templates. They do not consider your personal situation, capital, or tax position.
- Past performance shown by `pnl_analytics.py` does not predict future results.
- IBKR connectivity, market data quality, and third-party APIs (Yahoo, Finnhub) can fail. Verify critical numbers against your broker's UI before acting.

By using this software you agree that the authors and contributors are not liable for any trading losses, missed trades, or data errors.


path: README.zh-CN.md
content: # IBKR 期权助手 (IBKR Options Assistant)

> 一套完整的 Interactive Brokers 期权与股票交易助手——实时 Greeks、McMillan/Overby 策略库、盈亏分析、Wheel 跟踪、财报预警、风险模拟。设计成可直接作为 Skill 接入 Claude Code。

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![IBKR](https://img.shields.io/badge/broker-Interactive%20Brokers-red.svg)](https://www.interactivebrokers.com/)

> [English README](README.md)

<!-- screenshot: hero -->

---

## 目录

- [功能特性](#-功能特性)
- [环境要求](#-环境要求)
- [IBKR 行情订阅](#-ibkr-行情订阅)
- [快速开始](#-快速开始)
- [运维指南（双用户、自动重启）](#-运维指南)
- [安全模型](#-安全模型)
- [Claude Code 集成](#-claude-code-集成)
- [命令参考](#-命令参考)
- [配置](#-配置)
- [故障排查](#-故障排查)
- [进阶](#-进阶)
- [贡献](#-贡献)
- [许可证](#-许可证)
- [免责声明](#-免责声明)

---

## ✨ 功能特性

13 个专注的 Python 脚本。每个脚本都输出 JSON，便于 Claude（或其他 Agent）做推理；工具本身不会给出买卖信号。

**数据与行情**
- `market_quote.py` — 股票、ETF、期权的实时 bid/ask/last/IV/volume。
- `contracts.py` — 通用合约解析器（`SPY`、`AAPL 2026-06-19 200 C` 等）。
- `technical_indicators.py` — RSI、MA(20/50/200)、Bollinger、ATR，附文字摘要。

**期权分析**
- `options_chain.py` — 含 Greeks、OI、volume、IV 的完整期权链（按到期日组织）。
- `options_analyzer.py` — McMillan/Overby 策略推荐（4 层共 20+ 策略，IV 环境感知）。
- `options_daily.py` — 收盘后期权日报：预警、IV 环境、对应持仓的具体建议。

**组合与盈亏**
- `portfolio_positions.py` — 实时持仓 + 每条腿和组合级 Greeks。
- `pnl_analytics.py` — 已实现盈亏、胜率、最佳/最差交易（来自 `ib.executions` + 可选 Flex CSV）。
- `risk_simulator.py` — "加上这笔交易会怎样？"——执行前预览 Greeks 变化。

**策略自动化**
- `wheel_tracker.py` — 跟踪 wheel 周期（short put → 指派 → covered call → called away），含累计权利金与年化收益率。
- `earnings_calendar.py` — 组合标的的下一次财报日期，标记跨越财报的期权仓位。
- `alerts_monitor.py` — 基于 YAML 的阈值告警（delta、IV 分位、DTE、P&L），适合 cron 调度。

**连接层**
- `ib_client.py` — 共享的 IB Gateway 连接，自带 readonly 安全、按脚本 clientId 偏移、历史数据节流。

---

## 📋 环境要求

| 要求 | 备注 |
|---|---|
| **Python** | 3.10 或更高 |
| **IBKR 账户** | Live 或 paper 均可。Paper 账户用于学习已足够。 |
| **IB Gateway** | 从 [IBKR 官网](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php) 免费下载。TWS 也行（端口不同）。 |
| **行情订阅** | 见[下一节](#-ibkr-行情订阅) —— 实时报价与 Greeks 需要订阅。延迟数据免费。 |
| **操作系统** | macOS / Linux / Windows。所有脚本都是纯 Python。 |

> **为什么用 IB Gateway，不用 TWS？** Gateway 是无界面的，内存占用低，是程序化访问的首选。TWS 也可以——把 `IBKR_PORT` 设为 `7497`（paper）或 `7496`（live）。

---

## 💳 IBKR 行情订阅

工具的价值在很大程度上取决于 **IBKR 给你推送什么数据**。订阅是按账户配置的：Client Portal → Settings → User Settings → Market Data Subscriptions。

### 各功能对订阅的需求

| 功能 | 所需订阅 | 延迟数据可用？ |
|---|---|---|
| 股票/ETF 价格 (`market_quote.py`) | 无 —— 实时需要 Snapshot 套餐，否则使用延迟 | ✅ 可用 |
| 组合持仓与 P&L (`portfolio_positions.py`、`pnl_analytics.py`) | 无 —— 账户数据始终可用 | ✅ 可用 |
| 期权链 bid/ask (`options_chain.py`) | **OPRA Top of Book** | ⚠️ 部分 —— 只有 bid/ask，无 Greeks |
| **期权 Greeks** (IV、delta、gamma、vega、theta) | **OPRA + 标的对应的股票交易所** | ❌ **不可** —— Greeks 必须实时 |
| 财报日历 (`earnings_calendar.py`) | 无 —— 使用 Nasdaq 公开 API | ✅ 可用 |
| 技术指标 (`technical_indicators.py`) | 无 —— 使用历史 bar（免费） | ✅ 可用 |

**IBKR API 文档的关键提示：**
> *"To receive live Greek values it is necessary to have market data subscriptions for both the option and the underlying contract."*

翻译：如果你只订阅了 OPRA 但没订阅（比如）NYSE ARCA，你可以拿到 SPY 期权的**报价**，但拿不到 SPY 期权的 **Greeks**——因为 IBKR 没有实时标的价格就算不出 delta/gamma。

### 推荐的订阅套餐

| 套餐 | 月费 | 减免条件 | 内容 |
|---|---|---|---|
| **免费（延迟）** | $0 | 始终 | 股票报价、bid/ask、组合数据、历史 bar。**无 Greeks**、无实时 IV 环境。 |
| **仅 OPRA** | $1.50 | 每月佣金 $20+ | 实时期权 bid/ask。仅当标的也订阅时才有 Greeks。 |
| **US Securities Bundle + OPRA** ⭐ 推荐 | $11.50 | 每月佣金 $30+ | 美股全部实时数据 + 期权数据 + 所有 US 上市标的的 Greeks。工具完整功能。 |

**Bundle 内容（US Securities Snapshot and Futures Value Bundle）：**
- 美股/ETF 整合的实时 NBBO
- 主要期货（CME、CBOT、COMEX、NYMEX）顶部行情
- OTC Markets 报价

> **佣金减免数学：** 如果你每周交易 1 手期权（~4 手合约 × $0.65 佣金 ≈ $2.60/周 = ~$10/月），已经走到一半了。每月两次往返期权交易通常就过了 $30 门槛。

### 怎么订阅

1. 登录 [IBKR Client Portal](https://www.interactivebrokers.com/sso/Login)
2. 右上角 Settings → User Settings → Market Data Subscriptions
3. 点击 "Configure"
4. 搜索并添加：
   - **"US Securities Snapshot and Futures Value Bundle"**（NL）
   - **"OPRA Top of Book"**（NL）
5. 确认接受
6. 通常 10 分钟内生效；重启 IB Gateway

### 工具如何处理缺失订阅

默认值 `IBKR_MARKET_DATA_TYPE=3`（delayed-smart）告诉 IBKR：
> *"如果我订阅了就给我实时；否则降级为延迟。"*

这意味着 **零订阅情况下工具也能第一天就用** —— 只是没 Greeks，除非升级订阅。不会出现 Error 10089 崩溃。

如果你想强制特定模式：
- `IBKR_MARKET_DATA_TYPE=1` —— 严格实时（未订阅会报错）
- `IBKR_MARKET_DATA_TYPE=3` —— 智能延迟（默认；已订阅自动升级到实时）
- `IBKR_MARKET_DATA_TYPE=4` —— 延迟冻结（上次缓存值，盘后有用）

**参考资料：**
- [IBKR Market Data Pricing](https://www.interactivebrokers.com/en/pricing/market-data-pricing.php)
- [TWS API: Option Greeks 文档](https://interactivebrokers.github.io/tws-api/option_computations.html)

---

## 🚀 快速开始

### 1. 安装 IB Gateway

从 [interactivebrokers.com/en/trading/ibgateway-stable.php](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php) 下载并安装。打开并用 IBKR 凭据登录（测试用 **paper** 模式）。

<!-- screenshot: gateway-login -->

### 2. 启用 API

在 IB Gateway 里：

1. `Configure → Settings → API → Settings`
2. 勾选 **Enable ActiveX and Socket Clients**
3. 勾选 **Read-Only API**（推荐 —— 本工具就是只读设计）
4. **Socket 端口**：`4001`（live）或 `4002`（paper）。和 `.env` 里的 `IBKR_PORT` 对齐。
5. **Trusted IPs**：加上 `127.0.0.1`
6. 保持 **Allow connections from localhost only** 勾选 —— 安全且本工具不需要关闭
7. 点 **OK** 并重启 Gateway

<!-- screenshot: gateway-api-settings -->

### 3. 克隆并安装

```bash
git clone https://github.com/AlexLiu0130/ibkr-options-assistant.git
cd ibkr-options-assistant

python -m venv .venv
source .venv/bin/activate            # Windows: .venv\Scripts\activate

pip install -r requirements.txt
```

### 4. 配置环境变量

```bash
cp .env.example .env
$EDITOR .env
```

需要确认的最小字段（默认通常就行）：

```ini
IBKR_HOST=127.0.0.1
IBKR_PORT=4001                  # 4002 paper、7497 TWS paper
IBKR_CLIENT_ID_BASE=11
IBKR_MARKET_DATA_TYPE=3         # 默认 3；订阅后自动升级到实时
```

### 5. 第一次调用

确保 Gateway 已登录后：

```bash
python scripts/market_quote.py SPY
```

预期输出（JSON）：

```json
{
  "symbol": "SPY",
  "last": 612.34,
  "bid": 612.31,
  "ask": 612.35,
  "volume": 28931402,
  "timestamp": "2026-05-12 10:14:22"
}
```

看到这个就成功了。接着试 `python scripts/portfolio_positions.py`。

---

## 🛠️ 运维指南

7×24 稳定运行会遇到两个 IBKR 不太提的运维问题。一次解决，以后不再操心。

### 问题 1：手机 App 会踢掉你的 Gateway 会话

**IBKR 同用户名只允许一个活跃会话。** 如果你的脚本在 Mac 上跑 IB Gateway，然后打开 IBKR Mobile 看组合，**手机登录会把 Gateway 踢下线** —— 你所有脚本都失败，直到重新登录 Gateway。

**解决：创建第二个用户（免费）**

API 用一个用户名（Gateway），手机/TWS 用另一个。它们共享同一个账户、看同一份持仓，但各自有独立会话。

**步骤：**

1. 用主用户名登录 [IBKR Client Portal](https://www.interactivebrokers.com/sso/Login)
2. 右上角头像 → **Settings**
3. **Account Settings** 下找到 **Users & Access Rights**
4. 点 **+** 添加用户
5. 在 *"Is this a secondary user for the primary account holder?"* 选 **"Yes"**
6. 填好表格（第二用户可只读，也可有交易权限 —— 自己选）
7. 提交。IBKR 通常 1 个工作日内批准
8. 退出，用新副用户名登录一次设置密码
9. **IB Gateway 用副用户名**；主用户名留给手机 App

**免费**，副用户对同一账户拥有完整只读权限。

**参考：** [Adding a Second User on IBKR](https://help.piranhaprofits.com/knowledge/how-to-create-a-second-user-why-do-i-need-it)

---

### 问题 2：Gateway 半夜挂掉，脚本 9 点失败

IB Gateway 每天会自动退出登录（IBKR 出于安全强制要求），有时连续运行几周也会崩。如果你依赖 cron 或晨间例行流程，需要它常驻。

**解决：用 launchd（macOS）或 systemd（Linux）自动重启**

#### macOS — launchd

创建 `~/Library/LaunchAgents/com.user.ibgateway.plist`：

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.user.ibgateway</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Applications/IB Gateway 10.30/ibgateway.app/Contents/MacOS/JavaApplicationStub</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/ibgateway.out.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/ibgateway.err.log</string>
</dict>
</plist>
```

加载：

```bash
launchctl load ~/Library/LaunchAgents/com.user.ibgateway.plist
```

Gateway 挂掉就自动重启。想停：`launchctl unload ~/Library/LaunchAgents/com.user.ibgateway.plist`。

> **注意：** Gateway 仍需通过 IBKR Mobile 每日 2FA。自动重启处理崩溃但处理不了每天那次登录提示 —— 设[Gateway 内自动重启](#enable-auto-restart-inside-gateway)（见下文）可跳过 7 天 2FA。

#### 在 Gateway 内开启自动重启

IB Gateway 里：**Configure → Lock and Exit → Auto Restart**。选一个每日重启时间（如 03:00 ET）。这样 Gateway 最多 7 天不需要 2FA。7 天后必须手动登录一次。

#### Linux — systemd

创建 `~/.config/systemd/user/ibgateway.service`：

```ini
[Unit]
Description=IB Gateway
After=network.target

[Service]
ExecStart=/opt/ibgateway/ibgateway
Restart=always
RestartSec=30

[Install]
WantedBy=default.target
```

```bash
systemctl --user enable --now ibgateway
```

---

### 问题 3：`Warning 2105: ushmds connection broken`

如果 `market_quote.py` 或 `technical_indicators.py` 卡住，Gateway 日志里看到这个，**说明 IBKR 的美股历史数据农场宕机**。服务端问题，通常 5–30 分钟自愈。

**你会看到：**

```
reqHistoricalData: Timeout for Stock(...)
RuntimeError: Historical data returned empty
```

**用日志诊断：**

```python
from ib_async import util
util.logToConsole()
# 看：Warning 2105, reqId -1: 历史市场数据场连接中断:ushmds
```

**`ushmds` 中断时仍可用的功能：**
- `options_chain.py`、`portfolio_positions.py`、`options_daily.py` —— 用实时行情（`hfarm`），不依赖历史数据
- `market_quote.py`、`technical_indicators.py` —— 依赖 `ushmds`，会超时

**变通方案：**
- 等（5–30 分钟，IBKR 通常自动恢复）
- 重启 IB Gateway，强制重连到另一个农场端点
- 自动化场景下，脚本应把历史数据错误当作软失败处理 —— 工具已经抛出明确的 `RuntimeError`，上层可捕获

---

## 🔐 安全模型

本工具会连接真实券商会话，安全边界值得明确写出来。绝大多数行为都是设计本意 —— 关键在于知道信任边界落在哪里。

### 工具能做什么

| 能力 | 涉及脚本 | 默认状态 |
|------|---------|---------|
| 读 IBKR 账户（持仓、余额、盈亏、行情） | 全部脚本 | **开** —— 任何分析都需要 |
| 下单 / 撤单 / 查单 | 仅 `trade.py` | **关** —— 需同时 `IBKR_TRADING_ENABLED=1` **且** `--confirm-trade` |
| 访问 `api.nasdaq.com`（设了 `FINNHUB_API_KEY` 时还有 `finnhub.io`） | `earnings_calendar.py` | 开 —— 公开 HTTPS，不会传输 IBKR 凭据 |
| 读写 `~/.ibkr_wheel_journal.json`、`~/.ibkr_alerts.yaml`、`~/.ibkr_flex/*.csv` | Wheel / alerts / Flex 相关 | 开 —— 都在 `$HOME` 下你自己的文件 |
| 执行配置文件里的任意代码 | **没有** | `alerts_monitor.py` 用 `ast.parse` 严格白名单解析条件，无 `eval`、无 `__import__`、无属性访问 |

16 个只读脚本调用 IBKR 用 `readonly=True`；只有 `trade.py` 用 `readonly=False`，且要双闸门都打开才生效。

### 信任边界

工具的权限由**你能控制的两层**决定，不是工具自己说了算：

1. **IB Gateway 登录** —— 当前会话登录的是哪个账户，工具能访问的就是哪个。想进一步缩小爆炸半径，可以用 paper 账户（`IBKR_PORT=4002`）或者专门给只读用的 IBKR 子用户。把 Gateway 的 "Read-Only API" 勾上，`trade.py` 即使两层软闸门都打开也下不出单。
2. **`trade.py` 内部的两道软闸门** —— `IBKR_TRADING_ENABLED`（环境变量）和 `--confirm-trade`（命令行 flag）。少任何一道，脚本就打印一份 dry-run payload 然后退出，根本不会连券商。额外的护栏拒绝超大单（名义 > $100k、期权 > 1000 张、股票 > 10000 股需 `--allow-large`），并支持 `IBKR_TRADING_BLOCKLIST` 拉黑标的。

### 工具输出的数据

只读脚本会把 JSON 打到 stdout（或 `--output FILE`），内容包括你的持仓、盈亏历史、Greeks、Flex 报表等券商衍生数据 —— 这就是工具的目的，Claude（或其他 agent）正是读这份 JSON 来推理你的组合。把这些输出当券商对账单对待：

- 不要把它贴到不信任的对话里，不要公开分享 `--output` 文件
- 工作时 agent 上下文窗口里也会有同样的数据 —— 那段对话本身也保持私密
- 工具本身不会上传任何东西，只跟 IBKR Gateway 和（可选的）Nasdaq / Finnhub 公开端点通信

### 推荐设置

- 在个人机器上跑，不要放共享基础设施
- IB Gateway 的 "Allow connections from localhost only" 保持勾上
- `IBKR_HOST=127.0.0.1` 默认值就够用，除非你有特殊远程 Gateway 需求
- 第一次跑用 paper 账户
- 不主动要用下单功能时，`trade.py` 的两道闸门都保持关闭

---

## 🤖 Claude Code 集成

本仓库带 `SKILL.md`，Claude Code 可以直接用。两种安装方式：

### 方式 A —— 软链接（推荐开发使用）

```bash
mkdir -p ~/.claude/skills
ln -s "$(pwd)" ~/.claude/skills/ibkr-options-assistant
```

重启 Claude Code。问：*"SPY 现在多少？"* —— Claude 会触发 `market_quote.py`，而不是去 web 搜索。

### 方式 B —— 插件

如果你用 Claude Code 插件系统，把 marketplace 指向这个仓库，并从插件管理器装 `ibkr-options-assistant`。

### 触发短语

Skill 描述（见 `SKILL.md`）经过调校，提到以下任一就会触发：期权策略、仓位风险、Greeks、IV、wheel、财报对期权影响、P&L 分析、股价。通常不需要说 "用 IBKR"。

---

## 📖 命令参考

所有脚本自动读取 `.env` 并支持 `--help`。每个脚本把 JSON 打到 stdout、日志打到 stderr —— 把 stdout 管道给 `jq` 或者用 `--output file.json`。

| 脚本 | 一句话 | 示例 |
|---|---|---|
| `market_quote.py` | 单标的实时报价 | `python scripts/market_quote.py SPY` |
| `options_chain.py` | 含 Greeks 的期权链 | `python scripts/options_chain.py AAPL --dte-min 7 --dte-max 45` |
| `portfolio_positions.py` | 实时持仓 + Greeks | `python scripts/portfolio_positions.py` |
| `options_analyzer.py` | 策略推荐 | `python scripts/options_analyzer.py SPY --outlook bullish --iv-context` |
| `options_daily.py` | 收盘后期权日报 | `python scripts/options_daily.py --output ~/daily.json` |
| `pnl_analytics.py` | 已实现盈亏汇总 | `python scripts/pnl_analytics.py --days 30 --by symbol` |
| `earnings_calendar.py` | 下次财报 + DTE | `python scripts/earnings_calendar.py AAPL ARM MU --days 30` |
| `risk_simulator.py` | 交易前 Greeks 预览 | `python scripts/risk_simulator.py --add "AAPL 200 2026-06-26 P SELL 2"` |
| `technical_indicators.py` | RSI / MA / BB / ATR | `python scripts/technical_indicators.py NVDA --indicators rsi,ma,bb` |
| `wheel_tracker.py` | Wheel 周期日志 | `python scripts/wheel_tracker.py --summary` |
| `alerts_monitor.py` | 阈值告警 | `python scripts/alerts_monitor.py --config ~/.ibkr_alerts.yaml` |
| `contracts.py` | （库）合约解析器 | 被其他脚本 import |
| `ib_client.py` | （库）共享连接 | 被其他脚本 import |

### 常用范式

**先保存期权链再离线分析**（避免重复打 IBKR）：

```bash
python scripts/options_chain.py AAPL --output /tmp/aapl_chain.json
python scripts/options_analyzer.py AAPL --outlook neutral \
       --chain-file /tmp/aapl_chain.json --iv-context
```

**Cron 每日告警检查**（工作日 9:33）：

```cron
33 9 * * 1-5 cd /path/to/ibkr-options-assistant && \
    .venv/bin/python scripts/alerts_monitor.py >> ~/.ibkr_alerts.log 2>&1
```

**交易前做风险检查**：

```bash
python scripts/risk_simulator.py \
    --add "SPY 600 2026-06-19 P SELL 1" \
    --add "SPY 580 2026-06-19 P BUY 1"
```

---

## 🔧 配置

所有配置在 `.env`（从 `.env.example` 复制而来）。

| 变量 | 默认 | 作用 |
|---|---|---|
| `IBKR_HOST` | `127.0.0.1` | Gateway 主机。基本上总是 localhost。 |
| `IBKR_PORT` | `4001` | `4001` Gateway live · `4002` Gateway paper · `7496` TWS live · `7497` TWS paper |
| `IBKR_CLIENT_ID_BASE` | `11` | 脚本在此基础上加偏移（7–16）；最终 clientId 必须在你所有 app 中唯一。 |
| `IBKR_MARKET_DATA_TYPE` | `3` | `1` 实时 · `2` 冻结 · `3` 延迟（默认 —— 订阅后自动升级实时）· `4` 延迟冻结 |
| `FINNHUB_API_KEY` | *(未设置)* | 可选。当 `yahoo-earnings-calendar` 不可用时降级使用。<https://finnhub.io> 免费申请。 |
| `IBKR_FLEX_TOKEN` | *(未设置)* | 可选。IBKR Flex Web Service token，用于完整历史 P&L（超出约 2 天的成交窗口）。 |
| `IBKR_FLEX_QUERY_ID` | *(未设置)* | 可选。Flex Query ID。 |

### ClientId 偏移

每个脚本预留一个唯一偏移，可并存：

```
market_quote.py        offset 7   → clientId = base + 7
options_chain.py       offset 8
portfolio_positions.py offset 9
options_analyzer.py    offset 10
options_daily.py       offset 11
pnl_analytics.py       offset 12
risk_simulator.py      offset 13
technical_indicators   offset 14
wheel_tracker.py       offset 15
alerts_monitor.py      offset 16
```

`IBKR_CLIENT_ID_BASE=11`（默认）时，`market_quote.py` 用 clientId `18`。如果你在 TWS/Gateway 上跑**另一个**应用刚好占用 `18`，把 base 调高。

### 用户数据（不在仓库内）

这些文件在你的家目录、不入仓库：

- `~/.ibkr_wheel_journal.json` —— wheel 周期记录
- `~/.ibkr_alerts.yaml` —— 告警规则
- `~/.ibkr_flex/*.csv` —— Flex Statement 导出

---

## ❓ 故障排查

完整指南：[`references/troubleshooting.md`](references/troubleshooting.md)。覆盖 90% 首次运行问题的五个错误：

### 1. `clientId X already in use`

两个脚本（或一个脚本的两份拷贝）用同一个 clientId 连到 IB Gateway。要么：
- 等前一个脚本断开（通常几秒），**或**
- 把 `IBKR_CLIENT_ID_BASE` 调高到没人用的值，**或**
- 确认你不同时跑 TWS 和 Gateway，且 clientId 不重叠。

### 2. `Error 200: No security definition has been found`

合约没解析出来。原因：
- 代码拼错（`SPYY` → `SPY`）。
- 过期的期权日期。
- 行权价不存在（如 `599.5`，但只挂了 `599` 和 `600`）。
- 交易所路由 —— 某些代码需要传 `--exchange ARCA` 而非 `SMART`。

### 3. `Error 10091: subscription required`

你没有该交易所的实时行情订阅。两种修复：
- 切到延迟：`.env` 里 `IBKR_MARKET_DATA_TYPE=3`。
- 订阅（Account Management → Settings → Market Data Subscriptions）。

### 4. Connection refused / `TimeoutError`

Gateway 不可达。检查清单：
- Gateway 在跑且**已登录**？（未登录的 Gateway 不接受连接。）
- `.env` 里的端口和 Gateway `API → Settings → Socket port` 一致？
- `127.0.0.1` 在 **Trusted IPs** 里？
- 改完 API 设置后重启了 Gateway？（不重启不生效。）

### 5. `modelGreeks is None`

市场休市且没有缓存的延迟 Greeks 快照。要么等下次开盘，要么设 `IBKR_MARKET_DATA_TYPE=4`（延迟冻结）再试 —— 延迟冻结提供上次 session 的最后延迟快照。

---

## 📚 进阶

| 主题 | 文档 |
|---|---|
| 完整策略库（20+ McMillan/Overby 策略，含构造、IV 偏好、盈亏图） | [`references/strategies.md`](references/strategies.md) |
| Greeks 入门（Delta、Gamma、Vega、Theta、Rho —— 实务解读） | [`references/greeks_primer.md`](references/greeks_primer.md) |
| Wheel 策略深度（行权价/DTE 选择，roll-vs-assign 决策树） | [`references/wheel_strategy.md`](references/wheel_strategy.md) |
| 全部已知错误与修复 | [`references/troubleshooting.md`](references/troubleshooting.md) |

---

## 🤝 贡献

欢迎 PR 和 issue。保持简洁：

- 一个 PR 只做一件事。
- 新脚本要把 JSON 输出到 stdout、日志到 stderr，并预留唯一的 `CLIENT_ID_OFFSET`。
- 不硬编码路径 —— 从 `os.getenv()` 读配置。
- 脚本里不内置买卖建议；工具产出**数据**，由用户（或 Claude）做决策。

---

## 📜 许可证

[MIT](LICENSE)。随便用、fork、发布。

---

## ⚠️ 免责声明

**本软件仅用于教育和个人用途，不构成投资建议。**

- 工具是 **只读设计**：查询数据、做 Greeks 计算；不下单。仓库永远不调用 `placeOrder()`。
- 所有交易决策由你自己负责。期权交易包含可观亏损风险，并非适合所有投资者。
- `options_analyzer.py` 的推荐是从 outlook + 风险偏好到策略模板的教育性映射。它不考虑你的个人情况、资金或税务地位。
- `pnl_analytics.py` 展示的历史业绩不预测未来。
- IBKR 连接、行情质量、第三方 API（Yahoo、Finnhub）都可能失败。下单前关键数字以经纪商 UI 为准。

使用本软件即表示你同意：作者和贡献者不对任何交易损失、漏单或数据错误承担责任。


path: references/greeks_primer.md
content: # Greeks Primer — Practical Interpretation

The "Greeks" measure how an option's price moves when something else moves. The toolkit reports them per-position and aggregates them across the portfolio in `portfolio_positions.py`. This is a working interpretation, not a textbook derivation.

## The five Greeks at a glance

| Greek | Measures | Per-unit move | Sign for long call | Sign for long put |
|---|---|---|---|---|
| **Delta** | Price sensitivity to underlying | $1 move in underlying | + (0 → +1) | − (0 → −1) |
| **Gamma** | How fast delta changes | $1 move in underlying | + | + |
| **Vega** | Price sensitivity to implied vol | 1 vol-point (1% IV) | + | + |
| **Theta** | Time decay | 1 calendar day | − | − |
| **Rho** | Sensitivity to interest rates | 1 percentage point | + | − |

Short positions flip the sign of all Greeks (short call: negative delta, negative gamma, negative vega, positive theta).

---

## Delta — the directional one

**What it tells you:** *"If the stock moves $1, my option moves $delta."* For a 0.30-delta call: stock +$1 → call +$0.30 (× 100 shares = $30 per contract).

**Common rules of thumb:**

- Delta is also a rough probability of finishing ITM at expiry. A 0.30-delta put has ≈30% probability of expiring ITM — this is what wheel sellers use to pick strikes.
- ATM options sit near ±0.50 delta. Deep ITM approach ±1.00. Deep OTM approach 0.
- Stock has delta 1.00 per share (100 per round lot).

**Portfolio level (`portfolio_positions.py`'s `net_delta`):**

> *"Net delta = +1,200" means your account moves like +1,200 shares of the underlying basket.* If SPY drops $1, you lose ≈$1,200. Always reconcile this with your sizing.

**When delta matters most:**

- Directional trades — it's literally your directional exposure.
- Wheel selection — pick the strike whose delta matches your acceptable assignment probability (typical wheel: 0.20–0.30 delta short put).

---

## Gamma — delta's accelerator

**What it tells you:** *"Delta itself isn't constant. Gamma is how much delta changes per $1 move."*

Long options have **positive gamma**: a good thing — your delta increases when the move goes your way and decreases when it goes against you. Short options have **negative gamma**: brutal in fast moves.

**Where it bites:**

- **Gamma scalping** is the upside of long options.
- **Gamma risk** on short options near expiry is the downside: a 0.20-delta short put can turn into a 0.70-delta short put overnight on an earnings gap.

**Rule:** gamma is highest for **ATM options close to expiry**. If you're short premium with under a week to expiry, the gamma is screaming and a single bad day can blow through weeks of theta.

---

## Vega — the IV gauge

**What it tells you:** *"For every 1 percentage point increase in implied vol, the option price changes by $vega."* Long options are long vega; short options are short vega.

**Worked example:**

> If your portfolio shows `net_vega = +500`, then a 1% IV drop costs you $500. A 1% IV rise gains you $500. Across earnings, IV often drops 20–40% — a long-vega position can lose $10,000+ in seconds even if the stock goes the right way ("IV crush").

**When vega matters most:**

- **Holding through earnings:** check vega before, not after.
- **Buying premium when IV is high:** you're paying up — even if you're right on direction you can lose to IV mean-reversion.
- **Selling premium when IV is low:** you're getting nothing — and a vol spike will hurt.

`options_analyzer.py --iv-context` reports the IV-to-HV ratio so you can avoid this trap.

---

## Theta — time decay

**What it tells you:** *"My option loses $theta in value each calendar day, holding everything else constant."* Theta is the **rent** the long pays the short for keeping the optionality alive.

**Signs:**

- Long options: theta < 0 (you pay).
- Short options: theta > 0 (you collect).

**Worked example:**

> A short put with theta = +18 collects ≈$18/day. Over 30 days = $540 — assuming nothing else moves (which is the catch).

**When theta matters most:**

- **Premium-selling strategies** (CSP, covered call, iron condor, wheel): theta is your income; everything else is a risk against it.
- **Long-premium directional bets**: you're fighting theta every day. Stocks need to move enough, in the right direction, fast enough.
- **Theta accelerates as expiry approaches** — most theta decay happens in the last 30 days, and the very last week is dramatic.

**Practical:** for a wheel, picking 30–45 DTE balances "enough theta per day" against "still room to roll if it goes against me."

---

## Rho — the one you mostly ignore

**What it tells you:** *"For every 1% change in interest rates, my option changes by $rho."*

For typical 30–60 DTE retail options on US equities, rho is small enough to ignore — usually under $5 per contract per 1% rate move. It starts to matter for:

- **LEAPS** (long-dated options, 1+ years). 
- **Cash-settled index options** in a rate-cut/hike cycle.
- **Synthetic stock positions** (long call + short put at same strike) — rho is the carry cost.

Most retail wheel and short-premium traders can treat rho as decoration.

---

## Portfolio-level Greeks

`portfolio_positions.py` sums Greeks across all positions, normalized to share-equivalents:

```json
{
  "portfolio_greeks": {
    "net_delta": +1240.5,
    "net_gamma": -18.2,
    "net_vega": +452.0,
    "net_theta": -84.1
  }
}
```

**How to read this:**

| Greek | Interpretation |
|---|---|
| `net_delta = +1240` | Account moves like +1,240 shares of the underlying mix. SPY −$1 ≈ −$1,240. |
| `net_gamma = −18` | For every $1 the underlying moves, my delta moves against me by 18. Risk concentrates near short strikes close to expiry. |
| `net_vega = +452` | Long vega: +1% IV = +$452. -1% IV = −$452. |
| `net_theta = −84` | Net long premium overall — I'm paying ≈$84/day in time decay. |

**A net-vega-positive portfolio that you didn't intend to build is a common mistake** — easy to drift into by buying too many long calls. Run `portfolio_positions.py` weekly.

---

## When each Greek matters most

| Scenario | Watch |
|---|---|
| Picking a wheel strike | **Delta** (0.20–0.30 target) |
| Holding short premium through earnings | **Vega** (and don't) |
| 0–7 DTE positions | **Gamma** (high — re-check intra-day) |
| Premium-selling P&L attribution | **Theta** (income) + **Vega** (risk) |
| LEAPS | **Rho** + Theta + Vega |
| Stock + protective put | **Delta** of put (target around −0.20 to −0.30) |

---

## One last warning

Greeks are model outputs (Black-Scholes-ish). They are accurate in normal markets and wrong in fast/illiquid markets. They depend on an IV input that itself moves. Treat them as **direction and rough magnitude**, not as a guarantee — especially for short-dated and far-OTM options where modelGreeks can return `None` or stale values.


path: references/options_book_summary.md
content: # Options Book Summary — Operational Rules

A lookup of operational rules distilled from four canonical options books, written as decision-ready heuristics rather than theory. Use this when reasoning about strategy selection, position sizing, adjustment, or risk.

**Sources cited per rule:**
- **(McMillan)** — Lawrence McMillan, *Options as a Strategic Investment*, 5th ed.
- **(Overby)** — Brian Overby, *The Options Playbook* (TastyTrade lineage).
- **(Natenberg)** — Sheldon Natenberg, *Option Volatility & Pricing*, 2nd ed.
- **(Sinclair)** — Euan Sinclair, *Volatility Trading*, 2nd ed.

This is a **rule book**, not a textbook. For mechanics of Greeks see [`greeks_primer.md`](greeks_primer.md); for the strategy catalog see [`strategies.md`](strategies.md).

---

## Table of Contents

1. [IV Environment Playbook](#1-iv-environment-playbook)
2. [Strike Selection Rules](#2-strike-selection-rules)
3. [DTE Selection Rules](#3-dte-selection-rules)
4. [Adjustment Decision Tree](#4-adjustment-decision-tree)
5. [Position Sizing](#5-position-sizing)
6. [Skew Interpretation](#6-skew-interpretation)
7. [Earnings IV Crush](#7-earnings-iv-crush)
8. [Volatility Estimation](#8-volatility-estimation)
9. [Greeks-vs-Greeks Relationships](#9-greeks-vs-greeks-relationships)
10. [Common Mistakes (each book's "don't")](#10-common-mistakes)

---

## 1. IV Environment Playbook

The single most important question before opening an options trade: **is implied volatility rich or cheap?** Get this wrong and a directionally correct view still loses money.

### Core rule

> **Rule (McMillan):** When current IV is in the bottom 20% of its trailing-1-year range, **buy** premium (long straddle, long calendar, long single leg). When in the top 20%, **sell** premium (short strangle, iron condor, credit spread). Middle 60%: use **spreads** — debit spreads when you have directional conviction at low IV, credit spreads when you have directional conviction at high IV. (McMillan)

### IV percentile vs IV rank — use both

| Metric | Definition | When it helps |
|---|---|---|
| **IV rank** | (current IV − 52w low) / (52w high − 52w low) | Quick sense of where IV sits in its full year range |
| **IV percentile** | % of trading days in the past year where IV was below today's | More robust to single-day spikes (e.g. earnings) |

> **Rule (Sinclair):** Prefer IV percentile to IV rank in symbols with episodic volatility spikes (earnings, biotech catalysts). A single-day Vol spike inflates IV rank but barely moves IV percentile. (Sinclair)

### Strategy → IV environment matrix

| IV environment | Bullish | Bearish | Neutral | Volatile (expect a move) |
|---|---|---|---|---|
| **Low IV** (≤20%ile) | Long call, call debit spread, call ratio backspread | Long put, put debit spread | Long calendar, long butterfly | Long straddle, long strangle |
| **Mid IV** (20–80%ile) | Bull call spread | Bear put spread | Iron condor (mild), short strangle (wide) | Long strangle |
| **High IV** (≥80%ile) | Cash-secured put, bull put spread, jade lizard | Bear call spread | Short strangle, iron condor, iron butterfly | Avoid — wait for IV mean-reversion |

> **Rule (Overby):** Don't sell premium for the sake of "income" when IV percentile is below 30. The premium you collect doesn't compensate for the gamma risk near expiry. Wait for IV to expand or switch to a defined-risk spread. (Overby)

### Term structure

Implied volatility varies by expiry. The shape tells you what the market is pricing.

| Term structure | What it means | Trade idea |
|---|---|---|
| **Contango** (front IV < back IV) | Calm now, uncertainty later. The normal state in low-vol regimes. | Long calendars, short front / long back |
| **Backwardation** (front IV > back IV) | Imminent event priced into front-month (earnings, FOMC, war). | Short front / long back ("event vol harvest"); never long front-month in this regime |
| **Flat** | No view; spreads will be near fair value | Spreads, no edge for calendars |

> **Rule (Natenberg):** A 10% drop in IV reduces a 30-DTE ATM option's price by roughly vega × 10. For typical equity ATM options that's ~30% of premium. This is why selling front-month into earnings backwardation is the **highest-Sharpe** vol trade — IV crush is mechanical, not random. (Natenberg)

---

## 2. Strike Selection Rules

Choose strikes by **delta**, not by absolute price. Delta is comparable across symbols, expiries, and IV regimes; absolute strikes are not.

### Delta targets by strategy

| Strategy | Short-leg target delta | Notes |
|---|---|---|
| Cash-secured put | 0.20 – 0.30 | 0.30 = ~30% chance of assignment. Lower delta = lower premium but lower assignment risk. |
| Covered call | 0.20 – 0.30 | 0.30 means ~30% chance the stock gets called away. |
| Short strangle | 0.16 – 0.20 each side | 0.16 ≈ 1-σ OTM; standard TastyTrade default. |
| Iron condor (short legs) | 0.16 – 0.30 each side | Tighten when IV is mid-range. |
| Bull put / bear call (short leg) | 0.25 – 0.35 | Higher delta = higher premium but more touches. |
| Long debit spread (long leg) | 0.50 – 0.70 | ITM legs hold more intrinsic value; less theta decay. |
| Long debit spread (short leg) | 0.20 – 0.30 | Sells the OTM wing to cheapen the long. |
| Protective put | 0.20 – 0.30 (5–10% OTM) | Insurance. Don't over-protect. |

> **Rule (Overby):** A 0.30-delta short option roughly equals a 30% probability of being ITM at expiration. Use this as your "win-rate envelope" — never trade a strategy whose long-run win-rate doesn't beat the assignment cost. (Overby)

### When to bend the rule

> **Rule (McMillan):** In a stock you'd happily own at the strike, you can use a higher-delta short put (up to 0.40) — the "loss" of assignment is acquiring stock at a discount. In a stock you'd hate to own (poor balance sheet, secular decline), drop to 0.10–0.15 delta and accept lower premium. (McMillan)

### Strike spacing rule

> **Rule (Natenberg):** In a put credit spread, the optimal width is **1 to 1.5 standard deviations** of the underlying's expected move over the DTE. Wider spreads collect more premium but have worse risk/reward; tighter spreads have better R:R but are too narrow for normal noise. (Natenberg)

---

## 3. DTE Selection Rules

Theta decay is not linear. Knowing the curve picks the sweet spot.

### The theta-decay curve

| DTE | Theta behavior | Trade implication |
|---|---|---|
| > 90 | Slow, linear theta. Vega dominates. | LEAPS for synthetic stock; calendars; long verticals when IV is low. |
| 45–90 | Theta accelerating. Vega still meaningful. | The classic "premium-selling" window. Best risk/reward for short strangles, iron condors, cash-secured puts. |
| 21–45 | Theta near maximum, gamma rising. | Most active management window — adjust here. |
| 7–21 | Theta still high but **gamma is dangerous**. | Roll, close, or convert to a defined-risk spread. Don't open new naked short premium in this window. |
| < 7 | Gamma extreme. A 1% underlying move = huge P&L swing. | Close. Period. Unless you're explicitly running 0-DTE on intraday timeframes. |

> **Rule (Overby):** **45 DTE is the canonical short-premium entry point.** You collect 50–70% of the maximum premium before gamma turns on you. Close at 21 DTE (or 50% of max profit, whichever comes first). (Overby — TastyTrade convention)

> **Rule (McMillan):** Calendars want **30–60 DTE on the long leg and 15–30 on the short leg.** The short leg decays faster (good); the long leg holds vega exposure to benefit from IV rise. Avoid calendars in front of expected IV drops (earnings IV crush will hurt the long leg). (McMillan)

### Weekly vs monthly

| Type | When to use |
|---|---|
| **Weeklies** | Only for: (a) 0–7 DTE income strategies you actively manage intraday, (b) hedging known events, (c) low-cost lottery tickets on news catalysts. Never sell weekly premium "to collect theta" — gamma will eat you. |
| **Monthlies** | The default for credit spreads, iron condors, calendar spreads. More liquid, tighter bid/ask. |
| **Quarterlies / LEAPS (60+ DTE)** | Synthetic stock, poor man's covered calls, long-vol bets ahead of high-IV regimes. |

---

## 4. Adjustment Decision Tree

The single hardest skill in options trading. Get this wrong and small losses become catastrophic; get it right and short-premium becomes durable.

### Roll vs cut vs add: the three-question filter

Ask in order:

1. **Has the original thesis changed?** If yes → **CUT.** A roll on a broken thesis is throwing good money after bad.
2. **Is there enough premium in the next expiry to re-establish the position at improved strikes?** If no → **CUT.** Rolling for a tiny credit just defers the problem.
3. **Are you under 21 DTE and >50% of strike threatened?** If yes → **ROLL.** This is the canonical roll window.

### Rolling a short put

> **Rule (McMillan):** Roll **out and down** (further DTE, lower strike) only if you can do so for a **net credit**. If the roll requires a debit, you're better off closing and re-deploying capital. Track total credits across rolls — if cumulative credit drops below 0, you've extended the position too far. Cap at 2 rolls maximum. (McMillan)

### Rolling a short call (when called-away risk is rising)

> **Rule (Overby):** Roll **out and up** (further DTE, higher strike). If the underlying has rallied past your strike by more than 5%, ask: would I keep selling calls at this new strike if I weren't already in the trade? If no, let it be called away. The wheel's whole point is participating in upside that escapes your strike. (Overby)

### Iron condor adjustments

| Threat | Action |
|---|---|
| One side breached (e.g. underlying through put-side short strike) | Roll the **untested** side closer to current price for additional credit. Don't move the breached side — wait for mean-reversion or close. |
| Both sides threatened (volatility expansion) | Close. The iron condor's edge depends on price staying inside the range; if it's gone, no roll fixes it. |
| Underlying pinned at a short strike near expiry | Close 1 day before expiry; gamma assignment risk is too high. |

### When to **add** (rarely)

> **Rule (McMillan):** Add to a winning position only at structurally better prices, never to "average down." If a short strangle is profitable and IV has expanded since entry, you can layer a second strangle at wider strikes for compounded premium. Never add to a losing strangle — that's catching a falling vol knife. (McMillan)

### The "Texas Hedge" warning

> **Rule (McMillan):** A "Texas Hedge" is a position that **adds risk in the same direction as the existing exposure** while pretending to hedge. Example: long stock + long call (both delta-positive). If your portfolio is already long-delta, buying more calls is not a hedge — it's leverage. Real hedges reduce net Greeks; Texas Hedges concentrate them. (McMillan)

---

## 5. Position Sizing

Most options blowups are sizing problems, not strategy problems.

### Max loss per trade

> **Rule (Overby):** **No single defined-risk trade should risk more than 1–2% of account capital.** For an iron condor with $400 max loss and a $40,000 account, that's exactly one contract. (Overby)

> **Rule (McMillan):** For undefined-risk trades (naked short strangle, short straddle), use a **margin-based 5% rule**: the maintenance margin requirement should not exceed 5% of account equity per position, with total undefined-risk margin capped at 25% of account. (McMillan)

### Kelly-fraction caveats

> **Rule (Sinclair):** Kelly suggests sizing positions by edge/variance. In options, **edge is hard to measure** and **variance is fat-tailed**. Use a **fractional Kelly** of 0.25–0.5 of the full Kelly bet. Going full-Kelly assumes you've correctly estimated both edge and variance; one bad estimate and you're ruined. (Sinclair)

### Concentration

> **Rule (Overby):** No single underlying should account for more than **20% of total portfolio delta**. Even if you have high conviction, a single-stock blow-up event (fraud, surprise miss, regulatory action) at 20%+ concentration is account-threatening. (Overby)

### Defined vs undefined risk allocation

| Account size | Max % in undefined-risk options |
|---|---|
| < $25k | 0% — use only defined-risk spreads |
| $25k – $100k | ≤ 10% of net liq |
| $100k – $500k | ≤ 20% of net liq |
| > $500k | ≤ 30%, with diversification across uncorrelated underlyings |

> **Rule (Natenberg):** The professional trader's mental sizing question is not "how much will I make?" but "what's the worst-case Greeks exposure if vol expands 50% overnight?" If you can't answer in dollars, your position is too large. (Natenberg)

---

## 6. Skew Interpretation

Put skew = the market's price of crash insurance. Reading it tells you what professional dealers think.

### What skew measures

| Skew shape | Reading |
|---|---|
| **Steep put skew** (OTM puts much more expensive than OTM calls) | Crash risk priced in. Common in equities, especially after a recent selloff. Buying OTM puts here is **expensive insurance** — consider put spreads instead. |
| **Flat skew** | Market sees symmetric risk. Rare in single stocks; common in FX and some commodities. |
| **Call skew** (OTM calls more expensive than OTM puts) | Squeeze risk / takeover speculation / commodity scarcity. Common in GME-style situations and in some commodities (natural gas summer). |
| **Smile** (both wings expensive) | Big move expected but direction unclear. Pre-event positioning. |

> **Rule (Natenberg):** In equity index options, **put skew is the norm, not the anomaly**. A flat skew in SPX is itself a signal — usually that complacency has reached a top. The 1987 crash was preceded by historically flat skew. (Natenberg)

### Trading the skew

> **Rule (Sinclair):** When equity put skew steepens past its 1-year 90th percentile, **sell put spreads** (sell the high-IV near-money put, buy the lower-IV further-OTM put). The skew premium is your edge. When skew flattens to the 10th percentile, **buy puts outright** — the protection is cheap relative to history. (Sinclair)

### Risk reversal as a skew gauge

A 25-delta risk reversal (= 25Δ call IV − 25Δ put IV) is the cleanest single number for equity skew.

| 25Δ RR | Interpretation |
|---|---|
| Strongly negative | Heavy put skew; put-buyers dominant; crash priced |
| Near zero | Symmetric pricing |
| Strongly positive | Call premium; squeeze/takeover priced |

---

## 7. Earnings IV Crush

Earnings is the single most predictable IV event in equity options. Use it.

### The mechanic

Before an earnings release, IV in the front-month expands to price the expected move (usually 5–15% for individual stocks). Within minutes after the release, IV collapses ("crush") back toward the longer-dated term-structure baseline. Whether the stock moves or not, **the option premium gets cut roughly in half**.

### The trade-offs

> **Rule (McMillan):** Long premium into earnings is a **−EV trade** unless your move estimate exceeds the option-implied move by a meaningful margin. The implied move is priced *exactly* to make long premium fair — and after crush, even a directionally correct move can lose money. (McMillan)

> **Rule (Natenberg):** Selling premium into earnings is a **positive-vega-decay bet that the move will be smaller than implied**. Historical analysis: in liquid US equity options, the realized move beats the implied move only ~40% of the time, meaning sellers win ~60% of the time. **But the loser-tail can be very large** (gap moves >2σ happen 5–10% of the time). Size accordingly. (Natenberg)

### Strategies by earnings stance

| View | Strategy | Why |
|---|---|---|
| "Stock moves less than implied" | Iron condor or short strangle straddling the expected move, expire after the report | Captures full IV crush |
| "Stock moves more than implied" | Long straddle/strangle, **close before earnings** | Long premium peaks the day before earnings; sell into the IV expansion |
| "Stock moves a specific amount in a specific direction" | Calendar spread or directional debit spread | Pure direction bet, less vega risk |
| "I have no view, but I want IV crush exposure with limited risk" | Short iron condor 1σ wide | Defined risk; profit if stock stays in range |

> **Rule (Overby):** Never hold long options through earnings unless you're prepared to lose 50–70% of premium even if you're directionally right. The implied move is calibrated to your detriment. (Overby)

### Pre-earnings IV ramp

> **Rule (Sinclair):** Front-month IV typically rises ~30–60% in the 5 trading days before earnings (relative to non-earnings baseline IV). Selling 7-DTE puts 5 days before earnings → closing the day before earnings can harvest a portion of that ramp **without** taking event risk. (Sinclair)

### Implied move vs straddle price (quick estimate)

The market's implied move for an event is approximated by the **ATM straddle price ÷ stock price**, multiplied by ~85% (a small correction because the straddle slightly over-prices the breakeven distance).

> **Rule (Sinclair):** Implied move ≈ 0.85 × (ATM call price + ATM put price) / underlying price. Example: AAPL at $200 with ATM straddle worth $12 → implied move ≈ 0.85 × $12 / $200 = 5.1%. Compare against historical post-earnings moves: if AAPL's 8-quarter median post-earnings move is 3.5%, the market is overpricing the event by ~46% — that's a vol-selling setup. (Sinclair)

---

## 8. Volatility Estimation

Which estimator should you use?

### The four ways volatility shows up

> **Rule (Natenberg):** Four distinct measurements of volatility, all important:
> 1. **Historical (realized) volatility** — what the underlying actually did. Backwards-looking.
> 2. **Implied volatility** — what options are priced for. Forward-looking.
> 3. **Implied skew** — how IV varies by strike. Tells you who's hedging what.
> 4. **Term structure** — how IV varies by expiry. Tells you the timing of expected risk.
>
> A trader who reads only one of these is trading blind. (Natenberg)

### Choosing a historical-vol estimator

| Estimator | Pros | Cons | Use when |
|---|---|---|---|
| **Close-to-close** | Simplest; matches statistical theory | Ignores intraday range; high variance estimate | You need a quick baseline; have only daily closes |
| **Parkinson** | Uses high-low range; ~5x more efficient than close-to-close | Assumes no drift, no overnight gaps | Pure intraday-vol estimation |
| **Garman-Klass** | Uses OHLC; more efficient than Parkinson | Still no overnight gap handling | Liquid markets without significant overnight moves |
| **Rogers-Satchell** | Handles drift | More complex; still no jump handling | Trending markets |
| **Yang-Zhang** | Handles drift AND overnight gaps; ~14x more efficient than close-to-close | More complex calculation | The default for modern equity-vol research |
| **GARCH(1,1)** | Models vol clustering and mean reversion | Parameters drift; needs refitting | Forecasting next-period vol, not just measurement |

> **Rule (Sinclair):** Use **Yang-Zhang** as the default historical-vol estimator for daily equity data — it dominates simpler estimators for any series with overnight risk. Use **GARCH(1,1)** when you need a forecast, not just a measurement, but accept that GARCH systematically under-predicts large moves. (Sinclair)

### Vol-of-vol

The volatility of volatility itself. High vol-of-vol → vol mean-reverts faster; low vol-of-vol → vol persists.

> **Rule (Sinclair):** When vol-of-vol is high (VVIX rising), short-vol strategies become more dangerous: even small underlying moves trigger big IV swings, which means defined-risk spreads should be preferred over naked short premium. Conversely, low vol-of-vol regimes (calm-market, summer 2017-style) favor short premium because IV won't whip you. (Sinclair)

### Variance swap intuition

A variance swap pays the difference between **realized** variance and **strike** variance over a period. The variance-swap strike is computed from an option-strip portfolio.

> **Rule (Sinclair):** The 30-day variance-swap strike is roughly **VIX squared**, scaled to daily. When VIX is 16, the market is pricing about 16% annualized realized vol over the next 30 days. If your forecast (from GARCH, recent history, or a view on macro) says realized vol will be ≤14, sell variance (short VIX/short strangle); if ≥18, buy variance. (Sinclair)

---

## 9. Greeks-vs-Greeks Relationships

The non-obvious interactions that catch new traders.

### Gamma scalping math

A delta-hedged long-gamma position profits when realized volatility exceeds implied volatility.

> **Rule (Natenberg):** The expected daily P&L of a delta-hedged long-gamma position is:
>
>     P&L ≈ 0.5 × Γ × S² × (σ_realized² − σ_implied²) / 252
>
> where Γ is dollar gamma, S is stock price. Positive only when realized vol exceeds implied. This is **the entire reason** delta-hedged long-straddle positions exist as a trade — you're betting realized > implied over the holding period. (Natenberg)

### Delta-Vega coupling

> **Rule (Natenberg):** ATM options have the **highest vega**. OTM options have **lower vega but higher percentage IV sensitivity**. A 1-point IV move on an ATM call moves the price more in dollars, but a 1-point move on a 20-delta call moves the price more in % terms — important when sizing tail-vol trades. (Natenberg)

### Theta vs Gamma trade-off

> **Rule (Overby):** Every options trade is a bet on the relationship: **realized vol vs implied vol** is **theta vs gamma**. If you're collecting theta (short premium), you're paying gamma — meaning you lose if realized vol > implied. If you're paying theta (long premium), you're collecting gamma — meaning you win if realized vol > implied. There's no free lunch. (Overby)

### Vega and DTE

> **Rule (Natenberg):** Vega scales roughly with **√DTE**. A 90-DTE option has ~√3 ≈ 1.73x the vega of a 30-DTE option at the same strike. So when you want vega exposure (long volatility view), use longer DTE; when you want to avoid vega risk (short premium views), use shorter DTE — but trade off against gamma risk. (Natenberg)

### Position-level vs single-leg Greeks

> **Rule (McMillan):** Never think of an iron condor as "selling premium." Think of it as a position with **net negative vega, net negative gamma, net positive theta, and bounded delta**. The decision to enter is not "do I want to sell premium" — it's "do I want all four of those exposures right now?" (McMillan)

### Charm and color (the second-order Greeks)

- **Charm** = dδ/dt = rate at which delta decays toward 0 (OTM) or ±1 (ITM) as time passes.
- **Color** = dγ/dt = rate at which gamma changes over time.

> **Rule (Natenberg):** Near expiry, charm and color **dominate** P&L for short-premium positions. A 1-DTE short straddle's gamma can triple intraday from a single 0.5% move. Manage 0–7 DTE positions in **delta terms hourly**, not daily. (Natenberg)

### Delta-equivalent share thinking

Convert option deltas to "equivalent shares" for portfolio-level risk:
- A short put with 0.30 delta and quantity 1 contract = `+0.30 × 100 = +30 delta-equivalent shares`.
- A long call with 0.70 delta and quantity 2 contracts = `+0.70 × 100 × 2 = +140 delta-equivalent shares`.

> **Rule (McMillan):** Compute portfolio-level **net delta** as a single number. If it's +5000 on a $100k account, you have leverage of 50× the underlying — a 2% move costs $10k. Most retail blowups happen when net delta drifts past 1× capital without the trader noticing. (McMillan)

---

## 10. Common Mistakes

Each book has its "don't" list. The overlap is instructive — these are the canonical retail-options errors.

### McMillan's list

1. **Buying out-of-the-money options "because they're cheap."** Low premium ≠ high expected value. OTM options have low probability of profit and high theta percentage decay.
2. **Holding losers and selling winners.** Standard prospect-theory error. Have a cut-loss plan **before** entry.
3. **Selling naked options without margin awareness.** A 10x margin spike during a vol event can force liquidation at the worst time.
4. **Ignoring early-assignment risk on ITM short calls before ex-dividend.** If the dividend exceeds the time value remaining, you will be assigned.
5. **Trading illiquid options.** Wide bid/ask spreads can cost 20–30% on round-trip.

### Overby's "5 mistakes new options traders make"

1. **Trading too big.** New traders typically risk 5–10% per trade; they should risk 1–2%.
2. **Trading too short DTE.** Weeklies feel exciting but gamma punishes mistakes immediately. Start at 45 DTE.
3. **Not knowing the risk graph before entry.** If you can't sketch the payoff diagram from memory, you'll panic when the position moves.
4. **Trading in low-liquidity underlyings.** Bid/ask spreads eat all the edge in obscure tickers. Stick to liquid names.
5. **Never closing winners early.** A short option at 50% of max profit has used 80% of the time but holds 50% of the remaining risk. Close at 50% and redeploy.

> **Rule (Overby):** **Never trade a strategy you can't draw on paper.** If you can't sketch the risk graph from memory, you'll panic when the position moves against you. (Overby)

### Natenberg's list

1. **Confusing IV with HV.** They measure different things. Sell premium when IV >> HV; buy premium when IV << HV.
2. **Underestimating skew.** OTM puts in equities are systematically more expensive than OTM calls. Pretending they're symmetric leads to mispriced credit spreads.
3. **Ignoring early-exercise risk on American options.** ITM puts can be optimally exercised early when interest rates are high; calls before dividends.
4. **Believing Black-Scholes prices the wings correctly.** It doesn't. The model assumes lognormal returns; reality has fat tails. Always check fat-tail-adjusted models for OTM wings.
5. **Trading vol without a vol forecast.** "Vol is high" is not a trade; "vol will be 18% over the next 30 days versus 22% priced" is a trade.

> **Rule (Natenberg):** A 30-DTE ATM option's price is approximately `S × σ × √(T/365) × 0.4`. Memorize this — it lets you sanity-check option quotes mentally. If a quote diverges from the back-of-envelope estimate by >30%, something's off (wrong contract, illiquidity, mispriced vol). (Natenberg)

### Sinclair's list

1. **Over-fitting GARCH on small samples.** Vol forecasts from <2 years of data are essentially noise.
2. **Sizing by intuition rather than Kelly fraction.** Most retail traders are 2-3x too large.
3. **Trading low-edge structures (ATM straddles) as if they're high-edge.** ATM straddles are nearly market-neutral on vol; the edge is small.
4. **Holding through earnings on long premium positions.** IV crush dominates direction.
5. **Ignoring transaction costs.** Bid/ask spreads + commission + slippage often eat 15–25% of expected edge on retail options trades.

> **Rule (Sinclair):** **Volatility trading is statistics, not directional speculation.** If your trade thesis doesn't include a number (forecast vol, expected variance, IV percentile threshold), you're not vol trading — you're directional trading with options on top. (Sinclair)

---

## Appendix: Quick decision card

When the user asks "should I trade options on X?" — run this checklist:

| Step | Check | Source |
|---|---|---|
| 1 | What's the IV percentile? (≤20 → buy; ≥80 → sell; 20–80 → spread) | McMillan |
| 2 | What's the term structure? (Backwardation → upcoming event; contango → calm) | Natenberg |
| 3 | What's the put skew? (Steep → sell put spreads; flat → buy puts outright) | Sinclair |
| 4 | Earnings inside DTE? (If yes → assume IV crush will dominate) | McMillan |
| 5 | What target delta for short legs? (Match the strategy: see table §2) | Overby |
| 6 | What DTE? (45 default for short premium; 60–90 for calendars) | Overby |
| 7 | Position size: ≤2% account on defined-risk; ≤5% margin on undefined | McMillan/Overby |
| 8 | Can I draw the risk graph from memory? If no → don't trade it | Overby |
| 9 | What's my exit rule (% profit and % loss) before entry? | All books |
| 10 | What adjustment will I make if it moves against me, and at what trigger? | McMillan |

If you can answer all 10 with concrete numbers, the trade is well-specified. If any answer is "I don't know" — wait, study, or skip the trade.

---

## 11. Pricing Model Limitations

When does Black-Scholes lie? Often. Knowing where the model breaks tells you where the edge lives.

### Where Black-Scholes is reliable

| Condition | BS quality |
|---|---|
| ATM, liquid, 30–90 DTE | Excellent — model and market agree to within bid/ask |
| Far OTM puts in equities | **Poor** — market prices fat-tail risk that BS understates |
| Far OTM calls in equities | Often overpriced vs BS (squeeze/takeover premium) |
| Short-dated (<7 DTE) | **Poor** — jump-diffusion effects dominate Brownian motion assumption |
| Right before earnings | **Poor** — market prices an event-jump that BS can't represent |
| In high-rates / dividend regimes | OK for European; poor for American without early-exercise adjustment |

> **Rule (Natenberg):** Black-Scholes assumes (a) lognormal returns, (b) no jumps, (c) constant volatility, (d) constant interest rate, (e) continuous trading, (f) no transaction costs. Real markets violate every assumption. The model is most-wrong at the tails (far OTM) and around events. **The model's "implied volatility" is not a forecast — it's the volatility that makes the (wrong) BS formula match the (right) market price.** (Natenberg)

### The volatility smile = BS error map

A volatility smile or skew is literally a map of where Black-Scholes is wrong. If BS were correct, all strikes on the same expiry would have the same IV.

> **Rule (Sinclair):** A useful mental model: BS-implied IV at strike K is a **probability-weighted average** of vol scenarios in which the underlying ends near K. Steep put skew → market assigns high probability to crash scenarios. Flat skew → market assigns symmetric outcome probabilities. **Don't trade against the skew without understanding why it's there.** (Sinclair)

### When to use jump-diffusion or stochastic-vol models

> **Rule (Natenberg):** For OTM wings and binary-event-pricing (earnings, FDA approvals, court rulings), use a **jump-diffusion model** (Merton, SVJ) or a **Heston-style stochastic-vol model**. BS systematically under-prices tail risk by 30–60% in equities. The retail trader's cheap edge: **buying OTM tail wings when implied skew is at the 10th percentile.** (Natenberg)

---

## 12. The Trading Plan Template

Before opening any options trade, fill in these blanks. If you can't, don't trade.

| Field | Example |
|---|---|
| Underlying | SPY |
| Strategy | Iron condor |
| Outlook | Range-bound between 590 and 620 over next 30 days |
| IV environment | IV percentile = 62 (mid-range, slight premium-sell bias) |
| Earnings inside DTE? | No earnings until Q2 next quarter |
| Strikes | -2σ put / +2σ call (each ~0.16 delta) |
| DTE | 38 |
| Net credit | $1.20 |
| Max loss | $3.80 (= $5 wing − $1.20 credit) |
| Max profit | $1.20 |
| Win rate (probability of full credit) | ~68% (1 − 2 × 0.16) |
| Position size (contracts) | 1 (= 1% of $38k account at max loss) |
| Profit-take exit | Close at 50% of max profit ($0.60) |
| Stop-loss exit | Close at 2x credit loss ($2.40) |
| Adjustment trigger | If one side reaches ~30 delta, roll untested side for credit |
| Roll cap | 1 roll maximum |
| Time-stop | Close at 21 DTE regardless of P&L |

> **Rule (McMillan):** Writing this down is not bureaucracy — it's the difference between a trade and a gamble. If you can't fill in the **stop-loss** and **time-stop** lines, your "trade idea" is actually an open-ended bet, which is the structural cause of most account drawdowns. (McMillan)

---

## 13. Behavioral Pitfalls (cross-cutting)

The four traps that recur in all four books.

### 1. Anchoring to entry price

Once a trade is open, your reference price should be **current market** — not your entry. A short put at $1.20 credit, now trading $0.40 to close, is the same risk-reward as opening a new short put at $0.40 with $0.40 max profit, $4.60 max loss. You'd never open that trade fresh. Close it.

> **Rule (Overby):** **The 50% profit-take rule (close at half of max profit) is the single highest-Sharpe behavior change a short-premium trader can adopt.** It exploits the asymmetry that early profits are easy to capture, while the last 20–30% of premium carries 50%+ of the remaining gamma risk. (Overby)

### 2. Doubling down on losers

> **Rule (McMillan):** "Averaging down" works for value investors who can hold indefinitely. In options it is **the textbook way to blow up an account**. Each leg's max loss is bounded; each addition adds new max loss; theta works against you on all of them. Cut, don't average. (McMillan)

### 3. Selling cheap options for "safety"

Selling a 0.05-delta put feels safe — 95% probability of profit. But the premium is tiny, and one black-swan event wipes out 20+ "winning" trades. The math: 95% × $10 + 5% × $-500 = +$9.50 − $25 = **−$15.50 expected value.** Negative-EV with high win rate is the most common trap in options.

> **Rule (Sinclair):** Win rate is meaningless without expected value. A 99% win rate strategy is a disaster if the 1% loss is 200x the average win. Calculate expected value, not win rate. (Sinclair)

### 4. Trading because you're bored

> **Rule (Overby):** The single most expensive psychological state in options is **boredom**. There is no penalty for sitting in cash. There is enormous penalty for trading sub-optimal setups to feel productive. (Overby)

---

## Cross-references

- For the strategy catalog (long call, iron condor, jade lizard, etc.) with payoff structures: [`strategies.md`](strategies.md)
- For Greeks interpretation (delta, gamma, vega, theta, rho): [`greeks_primer.md`](greeks_primer.md)
- For the wheel-strategy roll-vs-assign decision tree: [`wheel_strategy.md`](wheel_strategy.md)
- For IBKR/connectivity errors: [`troubleshooting.md`](troubleshooting.md)


path: references/strategies.md
content: # Options Strategy Library

Combined library from Larry McMillan's *Options as a Strategic Investment* and Brian Overby's *The Options Playbook*. The `options_analyzer.py` script selects from this set based on **outlook × risk_profile × IV environment**.

## Table of Contents

- [How to read this doc](#how-to-read-this-doc)
- [IV environment guidance](#iv-environment-guidance)
- [Selection matrix](#selection-matrix-outlook--risk_profile)
- [Tier 1 — Rookie (single-leg)](#tier-1--rookie-single-leg)
- [Tier 2 — Intermediate (two-leg spreads)](#tier-2--intermediate-two-leg-spreads)
- [Tier 3 — Advanced (3–4 leg)](#tier-3--advanced-34-leg)
- [Tier 4 — Expert (ratio & synthetic)](#tier-4--expert-ratio--synthetic)

---

## How to read this doc

Each strategy entry has:

| Field | Meaning |
|---|---|
| **Name (EN / CN)** | English / Chinese name. |
| **Direction** | `bullish` / `bearish` / `neutral` / `volatile` market view. |
| **Construction** | Legs (`BUY`/`SELL`, `Call`/`Put`, strike offset relative to ATM). |
| **When to use** | The setup this strategy is built for. |
| **IV preference** | `low` (buy premium) · `high` (sell premium) · `neutral` (spreads). |
| **Max profit / loss** | Per 1 contract; multiply by 100 for $ on US equity options. |
| **Risk profile** | `conservative` / `moderate` / `aggressive` — who should consider it. |

Strike offsets are measured in **strike steps** (the gap between adjacent listed strikes). `0` = ATM, `+2` = two strikes OTM call side, `-2` = two strikes OTM put side, etc.

---

## IV environment guidance

`options_analyzer.py --iv-context` compares the current average IV across the chain to 20-day realized volatility and returns one of three regimes:

| Regime | Trigger | Bias |
|---|---|---|
| **IV high** | `IV / HV > 1.3` | Sell premium — credit spreads, CSPs, covered calls, iron condors. |
| **IV neutral** | `0.8 ≤ IV / HV ≤ 1.3` | Spread-driven — directional debit/credit spreads. |
| **IV low** | `IV / HV < 0.8` | Buy premium — long options, straddles/strangles, ratio backspreads. |

The analyzer re-ranks candidates inside a given outlook × risk_profile cell so that IV-matched strategies come first.

---

## Selection matrix (outlook × risk_profile)

This is the literal mapping inside `options_analyzer.py` — the first strategy in each cell is the default suggestion.

| Outlook | Conservative | Moderate | Aggressive |
|---|---|---|---|
| **Bullish** | Covered Call · Cash-Secured Put · Collar | Bull Call Spread · Bull Put Spread | Long Call · Call Ratio Backspread · Risk Reversal |
| **Bearish** | Protective Put · Bear Call Spread | Bear Put Spread | Long Put · Put Ratio Backspread |
| **Neutral** | Covered Call · Iron Condor | Iron Condor · Iron Butterfly · Jade Lizard | Short Straddle · Short Strangle |
| **Volatile** | Long Strangle | Long Straddle · Long Strangle | Long Straddle · Call Ratio Backspread |

> **Note on "Aggressive Bearish":** selling unhedged calls (Short Call) is intentionally excluded because of unlimited upside risk. Use a defined-risk bear call spread instead.

---

## Tier 1 — Rookie (single-leg)

### Long Call · 买入看涨

- **Direction:** bullish · aggressive.
- **Construction:** BUY 1 Call ATM.
- **When to use:** strong directional conviction, expecting a sharp move up before expiry.
- **IV preference:** low (you're long vega).
- **Max profit:** unlimited.
- **Max loss:** premium paid.
- **Breakeven:** strike + premium.

### Long Put · 买入看跌

- **Direction:** bearish · aggressive.
- **Construction:** BUY 1 Put ATM.
- **When to use:** strong downside conviction.
- **IV preference:** low.
- **Max profit:** strike − premium (down to zero).
- **Max loss:** premium paid.
- **Breakeven:** strike − premium.

### Cash-Secured Put (CSP) · 现金担保卖出看跌

- **Direction:** bullish (or neutral-to-bullish) · conservative / moderate.
- **Construction:** SELL 1 Put, typically 2 strikes OTM; reserve cash to buy 100 shares at the strike.
- **When to use:** willing to own the stock at a discount; collect premium otherwise. **Stage 1 of the wheel.**
- **IV preference:** high.
- **Max profit:** premium collected.
- **Max loss:** (strike − premium) × 100 if the stock goes to zero.
- **Breakeven:** strike − premium.

### Covered Call · 备兑看涨

- **Direction:** bullish (mildly) · conservative.
- **Construction:** OWN 100 shares + SELL 1 OTM Call.
- **When to use:** generate yield on existing stock; OK with capping upside. **Stage 3 of the wheel.**
- **IV preference:** high.
- **Max profit:** (call_strike − cost_basis + premium) × 100.
- **Max loss:** stock fall − premium (downside is the same as owning the stock, minus what you collected).
- **Breakeven:** cost_basis − premium.

### Protective Put · 保护性看跌

- **Direction:** neutral (insurance) · conservative.
- **Construction:** OWN 100 shares + BUY 1 OTM Put.
- **When to use:** worried about a near-term drawdown but don't want to sell.
- **IV preference:** low.
- **Max profit:** unlimited (stock upside) − premium.
- **Max loss:** capped at (cost_basis − put_strike + premium) × 100.

---

## Tier 2 — Intermediate (two-leg spreads)

### Bull Call Spread · 牛市看涨价差

- **Direction:** bullish · moderate.
- **Construction:** BUY Call at ATM, SELL Call 3 strikes higher.
- **When to use:** moderate upside view; want to cap cost vs. long call.
- **IV preference:** neutral.
- **Max profit:** (width − net_debit) × 100.
- **Max loss:** net_debit × 100.
- **Breakeven:** long_strike + net_debit.

### Bear Put Spread · 熊市看跌价差

- **Direction:** bearish · moderate.
- **Construction:** BUY Put at ATM, SELL Put 3 strikes lower.
- **When to use:** moderate downside view; cheaper than long put.
- **IV preference:** neutral.
- **Max profit:** (width − net_debit) × 100.
- **Max loss:** net_debit × 100.
- **Breakeven:** long_strike − net_debit.

### Bull Put Spread (credit) · 牛市看跌价差

- **Direction:** bullish · moderate.
- **Construction:** SELL Put 1 strike OTM, BUY Put 4 strikes OTM.
- **When to use:** bullish AND high IV — get paid to be right.
- **IV preference:** high.
- **Max profit:** net_credit × 100.
- **Max loss:** (width − net_credit) × 100.
- **Breakeven:** short_put_strike − net_credit.

### Bear Call Spread (credit) · 熊市看涨价差

- **Direction:** bearish · moderate (conservative-friendly).
- **Construction:** SELL Call 1 strike OTM, BUY Call 4 strikes OTM.
- **When to use:** bearish-to-neutral AND high IV.
- **IV preference:** high.
- **Max profit:** net_credit × 100.
- **Max loss:** (width − net_credit) × 100.
- **Breakeven:** short_call_strike + net_credit.

### Long Straddle · 买入跨式

- **Direction:** volatile · moderate / aggressive.
- **Construction:** BUY Call ATM + BUY Put ATM.
- **When to use:** expecting a big move, unsure direction (e.g. binary event).
- **IV preference:** low (vega risk — IV crush on earnings kills it).
- **Max profit:** unlimited (up); large (down).
- **Max loss:** total premium paid.
- **Breakeven:** strike ± total_premium.

### Short Straddle · 卖出跨式

- **Direction:** neutral · aggressive (unlimited risk).
- **Construction:** SELL Call ATM + SELL Put ATM.
- **When to use:** expecting tight range; high IV that will collapse.
- **IV preference:** high.
- **Max profit:** total premium received.
- **Max loss:** unlimited.
- **Breakeven:** strike ± total_premium.

### Long Strangle · 买入宽跨式

- **Direction:** volatile · moderate.
- **Construction:** BUY OTM Call (+2) + BUY OTM Put (−2).
- **When to use:** big move expected, cheaper than straddle but needs larger move.
- **IV preference:** low.
- **Max profit:** unlimited.
- **Max loss:** total premium.
- **Breakeven:** call_strike + prem · put_strike − prem.

### Short Strangle · 卖出宽跨式

- **Direction:** neutral · moderate / aggressive.
- **Construction:** SELL OTM Call (+3) + SELL OTM Put (−3).
- **When to use:** range-bound with high IV; wider safety than short straddle.
- **IV preference:** high.
- **Max profit:** total premium.
- **Max loss:** unlimited (both sides).
- **Breakeven:** call_strike + prem · put_strike − prem.

### Collar · 领口策略

- **Direction:** neutral (hedged) · conservative.
- **Construction:** OWN stock + BUY OTM Put (−2) + SELL OTM Call (+2). Often near zero cost.
- **When to use:** lock in a gain; protect against a drop without paying for the put.
- **IV preference:** neutral.
- **Max profit:** capped at call_strike − cost_basis ± net_credit.
- **Max loss:** capped at cost_basis − put_strike ∓ net_credit.

---

## Tier 3 — Advanced (3–4 leg)

### Iron Condor · 铁秃鹰

- **Direction:** neutral · conservative / moderate.
- **Construction:** SELL OTM Put + BUY further OTM Put **AND** SELL OTM Call + BUY further OTM Call.
- **When to use:** range-bound stock, want defined risk on a short strangle.
- **IV preference:** high.
- **Max profit:** net_credit × 100.
- **Max loss:** (max(put_width, call_width) − net_credit) × 100.
- **Breakeven:** short_put − net_credit · short_call + net_credit.

### Iron Butterfly · 铁蝶式

- **Direction:** neutral · moderate.
- **Construction:** SELL ATM Put + SELL ATM Call + BUY OTM Put (−3) + BUY OTM Call (+3).
- **When to use:** stock will pin near ATM by expiry; want bigger credit than iron condor.
- **IV preference:** high.
- **Max profit:** net_credit × 100 (at ATM at expiry).
- **Max loss:** (wing_width − net_credit) × 100.

### Long Call Butterfly · 买入蝶式

- **Direction:** neutral · moderate (defined risk).
- **Construction:** BUY 1 ITM Call (−3) + SELL 2 ATM Calls + BUY 1 OTM Call (+3).
- **When to use:** pin play, low cost. Loves time decay if price stays near middle strike.
- **IV preference:** neutral.
- **Max profit:** at middle strike at expiry — (wing_width − net_debit) × 100.
- **Max loss:** net_debit × 100.

### Jade Lizard · 翡翠蜥蜴

- **Direction:** bullish (with upside protection) · moderate.
- **Construction:** SELL OTM Put + SELL OTM Call + BUY further OTM Call. Net credit ≥ call spread width → **no upside risk**.
- **When to use:** bullish AND high IV, but want a hedge in case of upside gap.
- **IV preference:** high.
- **Max profit:** net_credit × 100 (in the no-touch range).
- **Max loss:** put-side: strike_put − net_credit. Upside loss zero by construction.

---

## Tier 4 — Expert (ratio & synthetic)

### Call Ratio Backspread · 看涨比率反向价差

- **Direction:** bullish (sharp move) · aggressive.
- **Construction:** SELL 1 Call near ATM + BUY 2 Calls 3 strikes OTM.
- **When to use:** convex upside bet; profits on big rallies, small loss on tight range.
- **IV preference:** low (long vega net).
- **Max profit:** unlimited.
- **Max loss:** between strikes (zone of pain). Bounded.

### Put Ratio Backspread · 看跌比率反向价差

- **Direction:** bearish (sharp drop) · aggressive.
- **Construction:** SELL 1 Put near ATM + BUY 2 Puts 3 strikes OTM.
- **When to use:** crash protection / convex downside bet.
- **IV preference:** low.
- **Max profit:** large (down to zero).
- **Max loss:** between strikes.

### Risk Reversal · 风险反转

- **Direction:** bullish · aggressive.
- **Construction:** BUY OTM Call (+2) + SELL OTM Put (−2). Often near zero cost.
- **When to use:** strong upside conviction, willing to be assigned at the put strike. Synthetic long stock with a gap.
- **IV preference:** neutral.
- **Max profit:** unlimited.
- **Max loss:** put_strike × 100 (if stock goes to zero, minus net credit).

---

## Cheat sheet — choose by intent

| If you want to… | Look at |
|---|---|
| Get paid to wait for a buy entry | Cash-Secured Put |
| Earn yield on stock you own | Covered Call |
| Hedge a winning stock position | Collar / Protective Put |
| Bet on direction cheaply | Long Call / Long Put |
| Bet on direction with capped cost | Bull/Bear Call/Put Spreads |
| Get paid for range-bound view (defined risk) | Iron Condor / Iron Butterfly |
| Get paid for range-bound view (max premium) | Short Straddle / Strangle |
| Bet on a big move (direction unknown) | Long Straddle / Strangle |
| Convex bullish or bearish bet | Ratio Backspread |
| Bullish with disaster hedge upside | Jade Lizard |


path: references/trading.md
content: # Trading Mode — `scripts/trade.py`

> **⚠️ WARNING — This script CAN move real money.**
>
> The rest of the toolkit is read-only by design. `trade.py` is the **only** script
> that calls `ib.placeOrder()`. Before you point it at a live account, test it
> against a **paper account** (`IBKR_PORT=4002`). Lose money on the paper account
> first so you don't lose it on the real one.

---

## How to enable trading

By default the toolkit is read-only. To send orders you must perform three
one-time setup steps, then satisfy two gates on every invocation.

### One-time setup

1. **Disable Gateway's Read-Only API toggle**

   In IB Gateway:

   ```
   Configure → Settings → API → Settings
   ```

   - **UN**check **"Read-Only API"**
   - Keep **"Enable ActiveX and Socket Clients"** checked
   - Keep **"Allow connections from localhost only"** checked (safer)
   - Click **OK** and **restart Gateway**

   If Read-Only API stays on, IBKR rejects orders with Error 2105. `trade.py`
   surfaces this clearly under `checks.gateway_readonly_off: false` and refuses
   to place an order.

2. **Use a username with trading rights**

   If you use a secondary user (recommended — see Operations Guide in the main
   README), make sure the secondary user has **trading rights**, not just
   view-only. You set this when creating the secondary user in Client Portal.

3. **Set `IBKR_TRADING_ENABLED=1` in your shell**

   ```bash
   export IBKR_TRADING_ENABLED=1
   ```

   Or add to your `.env` file (`.env.example` documents it). This is Gate 1.

---

## Two-gate safety design

Every order command checks **both gates**. Either gate failing → dry-run preview
only, no `placeOrder()` call. This is by design.

| Gate | What | Where it lives |
|---|---|---|
| 1 | `IBKR_TRADING_ENABLED=1` env var | Set once per shell session (or in `.env`) |
| 2 | `--confirm-trade` CLI flag | Pass per invocation. Must be re-typed each time. |

Why two? Gate 1 prevents accidentally running a trade command while you only
*meant* to query data. Gate 2 prevents a copy-pasted command from an old shell
history (where the env var is still set) from firing an order without your
explicit per-call consent.

### Dry-run output

When gates fail (or you're testing), you get JSON like:

```json
{
  "mode": "dry_run",
  "order": { ... },
  "contract": { ... },
  "checks": {
    "trading_env_enabled": false,
    "confirm_flag_passed": false,
    "gateway_readonly_off": true,
    ...
    "notes": [
      "Gate 1 not passed: set IBKR_TRADING_ENABLED=1 ...",
      "Gate 2 not passed: add --confirm-trade ..."
    ]
  },
  "result": "DRY_RUN_NO_ORDER_PLACED"
}
```

`mode` is `"dry_run"` or `"live"`. **Always check this field** in any
automated pipeline.

---

## Hard guardrails

The script refuses orders that match any of these unless you pass `--allow-large`:

| Guardrail | Threshold |
|---|---|
| Notional > **$100,000** | Estimated as `qty × price × multiplier` (multiplier=100 for options) |
| Stock quantity > **10,000 shares** | |
| Option quantity > **1,000 contracts** | |
| Symbol in `IBKR_TRADING_BLOCKLIST` env var (comma-separated, e.g. `TSLA,GME`) | No override — must remove from list |

Stop-loss missing on LMT/MKT orders is only a **warning** (some MKT orders are
intentional). It appears in `checks.notes`.

### Blocklist example

```bash
export IBKR_TRADING_BLOCKLIST="TSLA,GME,AMC"
```

Any symbol on this list rejects with `blocklist_ok: false`. To trade it,
remove it from the env var.

---

## Subcommand examples

### Stock

```bash
# Dry run (no env, no flag)
python scripts/trade.py stock AAPL 100 --action BUY --order-type MKT

# Live buy at limit
IBKR_TRADING_ENABLED=1 python scripts/trade.py stock AAPL 100 \
    --action BUY --order-type LMT --limit-price 250.50 --tif GTC \
    --confirm-trade
```

### Single-leg option

```bash
# Sell 2 cash-secured puts at $14.50 limit
IBKR_TRADING_ENABLED=1 python scripts/trade.py option MU 2026-06-12 720 P 2 \
    --action SELL --order-type LMT --limit-price 14.50 \
    --confirm-trade
```

`EXPIRY` accepts both `2026-06-12` and `20260612`. `RIGHT` is `C` or `P`.

### Multi-leg combo (bull put spread)

```bash
# Sell the 600 put, buy the 590 put → net credit $2.50
IBKR_TRADING_ENABLED=1 python scripts/trade.py combo \
    --leg "SPY 2026-06-26 600 P SELL 1" \
    --leg "SPY 2026-06-26 590 P BUY 1" \
    --order-type LMT --limit-price 2.50 \
    --confirm-trade
```

Each `--leg` is `"SYMBOL EXPIRY STRIKE RIGHT ACTION QTY"`. All legs must share
the same underlying. `--limit-price` is the **net** debit (positive) or credit
(negative) per spread unit.

### Futures

```bash
# Buy 1 ES front-month at market
IBKR_TRADING_ENABLED=1 python scripts/trade.py future ES 1 \
    --action BUY --order-type MKT --confirm-trade

# Specific contract month
IBKR_TRADING_ENABLED=1 python scripts/trade.py future ES 1 \
    --last-trade-month 202609 --action BUY --order-type LMT --limit-price 6200 \
    --confirm-trade
```

### Forex

```bash
IBKR_TRADING_ENABLED=1 python scripts/trade.py forex EURUSD 25000 \
    --action BUY --order-type LMT --limit-price 1.0850 \
    --confirm-trade
```

---

## Managing open orders

### List

```bash
python scripts/trade.py list-orders
```

(No gates needed — listing is read-only.)

Output:

```json
{
  "open_orders": [
    {
      "order_id": 7,
      "symbol": "AAPL",
      "sec_type": "STK",
      "action": "BUY",
      "order_type": "LMT",
      "quantity": 100,
      "limit_price": 250.50,
      "tif": "GTC",
      "status": "Submitted",
      "filled": 0,
      "remaining": 100
    }
  ],
  "count": 1
}
```

### Cancel

```bash
python scripts/trade.py cancel 7
```

(No gates needed — cancelling reduces risk.)

---

## What happens on a real `placeOrder`

After both gates pass and all pre-flight checks succeed, `trade.py`:

1. Calls `ib.placeOrder(contract, order)`
2. Waits up to 8 seconds for an initial status update
3. Returns the result block:

```json
{
  "mode": "live",
  "order": { ... },
  "contract": { ... },
  "checks": { ... },
  "result": {
    "order_id": 12,
    "perm_id": 1862341087,
    "status": "Submitted",
    "filled_qty": 0,
    "remaining_qty": 100,
    "avg_fill_price": 0,
    "log": [ ... last 5 status updates ... ]
  }
}
```

If the status is still `PreSubmitted` or `Submitted` when we return, the order
is **alive on IBKR's side** — use `list-orders` to track it, or use Gateway's
own Orders panel.

---

## Concerns & FAQ

**Q: Why `readonly=False` on the IBKR connection?**
A: To place orders. `ib_client.ib_connect()` defaults to `readonly=True`; we
explicitly override for this script and only this script. The 13 read-only
scripts all stay `readonly=True`.

**Q: What if the order partially fills?**
A: The 8-second wait returns whatever state the order is in. If `status` is
`Submitted` with `remaining > 0`, the order is still working — re-poll via
`list-orders` or cancel via `cancel ORDER_ID`.

**Q: Combo legs all on the same side?**
A: Each `ComboLeg` carries its own `action` (BUY/SELL). The outer order on the
`Bag` is always `BUY 1` of the spread — the *bag's* action is just the
direction of "buy this combination as defined by the legs". For a spread you
sell (credit spread), set the legs as documented in `parsed_legs`.

**Q: Can I cancel-all?**
A: Not as one command — pull `list-orders`, then loop `cancel ORDER_ID` per row.
This is deliberate to avoid wiping a portfolio with a typo.

---

## 中文版

> **⚠️ 警告 —— 此脚本会动真金白银。**
>
> 工具包其它脚本全部只读。`trade.py` 是**唯一**会调 `ib.placeOrder()` 的脚本。
> 在指向实盘账户之前，先用**模拟账户**（`IBKR_PORT=4002`）跑通。先在模拟盘亏过
> 钱，实盘就不会亏同一笔。

---

### 开启交易模式

工具包默认只读。要发单，需做三步一次性配置，然后每次调用满足两道闸门。

#### 一次性配置

1. **关掉 Gateway 的 "Read-Only API" 选项**

   IB Gateway 里：

   ```
   Configure → Settings → API → Settings
   ```

   - **取消勾选** **"Read-Only API"**
   - 保留勾选 **"Enable ActiveX and Socket Clients"**
   - 保留勾选 **"Allow connections from localhost only"**（更安全）
   - 点 **OK**，**重启 Gateway**

   如果 Read-Only API 还开着，IBKR 会返回 Error 2105 拒单。`trade.py` 会在
   `checks.gateway_readonly_off: false` 字段里清楚提示，并拒绝下单。

2. **使用有交易权限的用户名**

   如果你用副用户（推荐做法，见主 README 的 Operations Guide），确保副用户
   有**交易权限**而不是只读。这个在 Client Portal 创建副用户时设置。

3. **shell 里设置 `IBKR_TRADING_ENABLED=1`**

   ```bash
   export IBKR_TRADING_ENABLED=1
   ```

   或写进 `.env`（参考 `.env.example`）。这是闸门 1。

---

### 两道闸门安全设计

每次下单命令都会同时检查**两道闸门**，任一不过 → 仅 dry-run 预览，**不会**调
`placeOrder()`。这是设计如此。

| 闸门 | 内容 | 位置 |
|---|---|---|
| 1 | 环境变量 `IBKR_TRADING_ENABLED=1` | 每个 shell 会话设一次（或写在 `.env`） |
| 2 | CLI 标志 `--confirm-trade` | **每次**调用必须重新输入 |

为什么两道？闸门 1 防止你只想查数据时不小心跑了交易命令。闸门 2 防止从旧 shell
历史里粘出来的命令（环境变量还在）不经你当次明确同意就把单子发出去。

### Dry-run 输出示例

闸门不过（或你在测试）时，JSON 输出长这样：

```json
{
  "mode": "dry_run",
  "order": { ... },
  "contract": { ... },
  "checks": {
    "trading_env_enabled": false,
    "confirm_flag_passed": false,
    ...
    "notes": [
      "Gate 1 not passed: set IBKR_TRADING_ENABLED=1 ...",
      "Gate 2 not passed: add --confirm-trade ..."
    ]
  },
  "result": "DRY_RUN_NO_ORDER_PLACED"
}
```

`mode` 取 `"dry_run"` 或 `"live"`。**自动化流程一定要检查这个字段**。

---

### 硬性风控

下列任一条件触发即拒单，除非加 `--allow-large`：

| 风控项 | 阈值 |
|---|---|
| 名义金额 > **$100,000** | 估算 `数量 × 价 × 乘数`（期权乘数 = 100） |
| 股票数量 > **10,000 股** | |
| 期权数量 > **1,000 合约** | |
| 标的在 `IBKR_TRADING_BLOCKLIST`（逗号分隔，比如 `TSLA,GME`） | 不可强行覆盖，必须从列表移除 |

LMT/MKT 单子没设止损只会**警告**（有些 MKT 是有意为之），不会拒单。警告会在
`checks.notes` 里出现。

#### 黑名单示例

```bash
export IBKR_TRADING_BLOCKLIST="TSLA,GME,AMC"
```

名单内标的会 `blocklist_ok: false`。要交易就从环境变量里删掉。

---

### 子命令示例

#### 股票

```bash
# 干跑（既没设环境变量也没加 flag）
python scripts/trade.py stock AAPL 100 --action BUY --order-type MKT

# 实盘限价买
IBKR_TRADING_ENABLED=1 python scripts/trade.py stock AAPL 100 \
    --action BUY --order-type LMT --limit-price 250.50 --tif GTC \
    --confirm-trade
```

#### 单腿期权

```bash
# 卖 2 张现金担保 put，限价 $14.50
IBKR_TRADING_ENABLED=1 python scripts/trade.py option MU 2026-06-12 720 P 2 \
    --action SELL --order-type LMT --limit-price 14.50 \
    --confirm-trade
```

`EXPIRY` 接受 `2026-06-12` 或 `20260612`。`RIGHT` 取 `C` 或 `P`。

#### 多腿组合（牛市价差）

```bash
# 卖 600 put，买 590 put → 净收 $2.50
IBKR_TRADING_ENABLED=1 python scripts/trade.py combo \
    --leg "SPY 2026-06-26 600 P SELL 1" \
    --leg "SPY 2026-06-26 590 P BUY 1" \
    --order-type LMT --limit-price 2.50 \
    --confirm-trade
```

每个 `--leg` 格式 `"SYMBOL EXPIRY STRIKE RIGHT ACTION QTY"`。所有腿必须同标的。
`--limit-price` 是每份组合的**净**借方（正）或贷方（负）。

#### 期货

```bash
# 买 1 张 ES 主力，市价
IBKR_TRADING_ENABLED=1 python scripts/trade.py future ES 1 \
    --action BUY --order-type MKT --confirm-trade

# 指定合约月
IBKR_TRADING_ENABLED=1 python scripts/trade.py future ES 1 \
    --last-trade-month 202609 --action BUY --order-type LMT --limit-price 6200 \
    --confirm-trade
```

#### 外汇

```bash
IBKR_TRADING_ENABLED=1 python scripts/trade.py forex EURUSD 25000 \
    --action BUY --order-type LMT --limit-price 1.0850 \
    --confirm-trade
```

---

### 管理在场订单

#### 列出

```bash
python scripts/trade.py list-orders
```

（不需闸门，列单本来就只读。）

#### 撤单

```bash
python scripts/trade.py cancel 7
```

（不需闸门，撤单是降风险动作。）

---

### 实盘 `placeOrder` 发生了什么

两道闸门 + 所有 pre-flight 检查都通过后，`trade.py`：

1. 调 `ib.placeOrder(contract, order)`
2. 最多等 8 秒拿到首个状态更新
3. 返回 `result` 块（包含 `order_id`、`status`、`filled_qty` 等）

如果返回时状态还是 `PreSubmitted` 或 `Submitted`，说明订单**已经在 IBKR 那边
挂着**——用 `list-orders` 跟踪，或者直接在 Gateway 的 Orders 面板看。

---

### 常见疑问

**问：为什么连接用 `readonly=False`？**
答：因为要发单。`ib_client.ib_connect()` 默认 `readonly=True`，我们**只在这一
个脚本**显式覆盖。其它 13 个只读脚本保持 `readonly=True`。

**问：部分成交怎么办？**
答：8 秒等待返回时拿到的就是当时的状态。如果是 `Submitted` 且 `remaining > 0`，
单子还在工作——用 `list-orders` 重新查，或 `cancel ORDER_ID` 撤掉。

**问：能一键全撤吗？**
答：不能。需要先 `list-orders` 然后循环 `cancel ORDER_ID`。这是故意的，避免一
个手误把整个组合都炸了。


path: references/troubleshooting.md
content: # Troubleshooting

A reference for every error this toolkit can throw at you. Issues are grouped by category. If you hit something not listed here, please open an issue with the full stderr log.

## Table of Contents

- [Connection errors](#connection-errors)
- [Contract / market-data errors](#contract--market-data-errors)
- [Greeks and IV problems](#greeks-and-iv-problems)
- [Read-only mode (intentional)](#read-only-mode-intentional)
- [Performance & pacing](#performance--pacing)
- [Getting Gateway logs](#getting-gateway-logs)
- [IBKR-side configuration checklist](#ibkr-side-configuration-checklist)

---

## Connection errors

### `ConnectionRefusedError` / "Connection refused"

The script can't reach IB Gateway at all.

**Diagnostic order:**

1. **Is Gateway running?** Check the dock / system tray. A logged-out Gateway is **not** accepting connections — re-login.
2. **Is the port right?**
   - Gateway live: `4001`
   - Gateway paper: `4002`
   - TWS live: `7496`
   - TWS paper: `7497`
3. **Is the API enabled?** `Configure → Settings → API → Settings → Enable ActiveX and Socket Clients` must be checked.
4. **Is `127.0.0.1` in Trusted IPs?** Same panel. Add it explicitly.
5. **Did you restart Gateway after enabling the API?** API settings do not apply live — Gateway must be restarted.

Quick sanity check (Mac / Linux):

```bash
nc -zv 127.0.0.1 4001
```

If `nc` fails: the problem is Gateway, not this toolkit.

### `TimeoutError` after a long pause

Gateway is alive but not responding within `CONNECT_TIMEOUT` (10s). Causes:

- Gateway is mid-restart or mid-login (banner showing 2FA challenge).
- A previous client with the same clientId is hanging — wait 30 seconds for the server to time it out, or restart Gateway.
- Firewall (Little Snitch on Mac, Windows Defender) is blocking the socket.

### `clientId X already in use`

Two connections collided on the same clientId. Possible causes:

- You re-ran a script faster than the previous run could disconnect.
- Two scripts somehow ended up with the same offset (shouldn't happen — check that no one edited `CLIENT_ID_OFFSET` constants).
- Another application (your TWS workstation, a third-party bot) is using that clientId.

**Fix:** raise `IBKR_CLIENT_ID_BASE` in `.env` to a value that doesn't collide:

```ini
IBKR_CLIENT_ID_BASE=51
```

This shifts all script clientIds (51+7 through 51+16). Confirm no other app uses anything in that range.

---

## Contract / market-data errors

### `Error 200: No security definition has been found for the request`

The contract didn't resolve. The error from IBKR's API is unhelpful — here's how to debug.

**Causes ranked by frequency:**

1. **Typo in the symbol.** `BRK.B` should be `BRK B` (space, not dot) in some routes.
2. **Wrong asset class.** Trying to fetch an option on a ticker that doesn't have listed options.
3. **Expired option.** The expiration date is in the past.
4. **Non-standard strike.** The strike doesn't exist on that expiration — chains sometimes have $0.50 strikes near ATM but $1.00 strikes farther out.
5. **Wrong exchange.** Some tickers must be routed to a specific exchange instead of `SMART`:
   - European stocks → `--exchange LSEETF` (London) or similar
   - Some Chinese ADRs at certain times
   - Cash-settled indexes → `--exchange CBOE`
6. **Future / option on future** missing `multiplier`. CME futures options need `multiplier=50` etc.

**Debug command:**

```bash
python scripts/market_quote.py SYM       # if even the underlying fails, problem is symbol/exchange
python scripts/options_chain.py SYM      # if underlying works but chain fails, problem is expiration/strike
```

### `Error 10091: Requested market data requires additional subscription`

Self-explanatory: your IBKR account doesn't have a subscription for the exchange / data type you're requesting.

**Two fixes:**

1. **Switch to delayed data** (free, ~15 min lag). Edit `.env`:

   ```ini
   IBKR_MARKET_DATA_TYPE=3
   ```

   Restart the script. Quotes will be delayed but free.

2. **Subscribe.** Account Management → Settings → User Settings → Market Data Subscriptions. Common subscriptions:
   - **US Securities Snapshot and Futures Value Bundle** — $10/mo, real-time US stocks/ETFs.
   - **OPRA (US Option Exchanges)** — $1.50/mo (no professional), needed for option Greeks live.
   - **NASDAQ Last Sale (NLS)** — covers many Nasdaq tickers.

### `Error 354: Requested market data is not subscribed`

Variant of 10091 — same fix.

### `Error 162: Historical market data Service error message`

`reqHistoricalData` failed. Causes:

- **Pacing violation:** "more than 6 same-contract historical requests in 2 seconds". The toolkit's `req_historical_safe()` enforces a 0.35s minimum interval — but if you've also got TWS running and pulling the same data, you can exceed the cap. Wait a minute and retry.
- **Outside data range:** asking for 5-year history on a ticker that IPO'd 6 months ago.
- **Wrong `whatToShow`:** `MIDPOINT` works for FX, `TRADES` for stocks; mismatching gets you 162.

---

## Greeks and IV problems

### `modelGreeks is None`

The `options_chain.py` or `portfolio_positions.py` output shows `delta: null, iv: null, ...`.

**Why it happens:**

- The market is **closed** *and* you're using `IBKR_MARKET_DATA_TYPE=1` (realtime). Without a live tick, the server has no current Greeks to deliver. The same call during market hours returns Greeks correctly.
- Far-OTM strike with no trading activity — sometimes the server simply hasn't computed Greeks for an option no one's quoting.
- Wrong feed: deep OTM weeklies on illiquid names can lack Greeks even live.

**Fixes:**

1. Wait for the market to open (best signal quality).
2. Use **delayed-frozen** to serve the last cached delayed Greeks from the previous session:

   ```ini
   IBKR_MARKET_DATA_TYPE=4
   ```

3. Move to a more liquid strike or a more liquid expiry.

### IV looks wrong (zero, NaN, or jumping)

Stock and option ticks arrive asynchronously. Right when you connect, the chain returns an IV computed against a stale stock price. If you re-run the script 5 seconds later, IV will be sane.

`options_analyzer.py` waits ~3 seconds for ticks to settle before computing IV context. If you're calling `options_chain.py` directly, give it a moment.

---

## Read-only mode (intentional)

If you see in the stderr log:

```
🔄 Connecting to IB Gateway 127.0.0.1:4001 (clientId=18, readonly=True) ...
```

…**this is by design.** The toolkit always connects with `readonly=True` so it cannot place, modify, or cancel orders even if some downstream library tried. We additionally recommend enabling the Gateway-side **Read-Only API** setting for double safety:

- `Configure → Settings → API → Settings → Read-Only API` — **enable**.

This way, even if your `.env` got tampered with, Gateway would still refuse any order attempt.

> If you ever want to write a tool in this repo that places orders, you would need to (a) set `readonly=False` in `ib_connect()` and (b) disable Read-Only API in Gateway. The toolkit deliberately makes that hard.

---

## Performance & pacing

### Scripts feel slow

Typical baseline:

| Operation | Expected wall time |
|---|---|
| `market_quote.py SPY` | 2–4 sec (cold connect + 1 tick) |
| `options_chain.py SPY` (3 expirations) | 8–20 sec |
| `portfolio_positions.py` (20 positions) | 6–12 sec |
| `options_analyzer.py SPY --iv-context` | 10–25 sec |

If you see > 60 sec, suspect:

- Pacing throttle (too many historical requests recently).
- A laggy connection (geographic distance from IBKR data center).
- Subscription mismatch causing fallbacks.

### `Pacing violation` warnings

The toolkit enforces a 0.35s minimum interval between historical-data calls (see `req_historical_safe` in `ib_client.py`). If you still hit pacing limits, you're likely running another tool concurrently. Stop everything, wait 60 seconds, retry.

---

## Getting Gateway logs

When opening a bug report or a support ticket, Gateway logs make all the difference.

**Location:**

- macOS: `~/Jts/<gateway-version>/`, files named `api.<date>.log` and `ibgateway.<date>.log`
- Linux: `~/Jts/<gateway-version>/`
- Windows: `C:\Jts\<gateway-version>\`

**What to grep for:**

```bash
grep -E "ERROR|WARN|10091|^Error 200" ~/Jts/*/api.*.log | tail -50
```

The interesting errors are usually the last 50 lines.

**Enable verbose API logging** (helpful when contracts fail to resolve):

`Configure → Settings → API → Settings → Logging Level → Detail`. Restart Gateway.

---

## IBKR-side configuration checklist

If something feels broken in a way none of the above explains, walk this list:

- [ ] Gateway is logged in (not paused at a 2FA / "agreement updated" screen).
- [ ] `Configure → Settings → API → Settings → Enable ActiveX and Socket Clients` ✅
- [ ] `Configure → Settings → API → Settings → Read-Only API` ✅ (matches our convention)
- [ ] `Configure → Settings → API → Settings → Socket port` = value in your `.env`
- [ ] `Configure → Settings → API → Settings → Trusted IPs` includes `127.0.0.1`
- [ ] **Restart Gateway** after changing any API setting (settings are not live-applied).
- [ ] Account Management → Market Data Subscriptions includes the exchanges you query.
- [ ] No other client (a second Python script, an old TWS instance, another bot) is using a clientId in the toolkit's range (`base+7` … `base+16`).

If everything on this list is green and the toolkit still fails: open an issue with the failing command, the stderr output, and a redacted Gateway log line. Most repeat issues come back to one of the items above.


path: references/wheel_strategy.md
content: # The Wheel Strategy — Full Guide

The Wheel is a four-stage premium-selling cycle that turns a willingness to own a stock into a recurring income stream. `wheel_tracker.py` in this toolkit tracks each cycle from entry to exit.

## Table of Contents

- [What is the Wheel?](#what-is-the-wheel)
- [The four stages](#the-four-stages)
- [When the wheel works (and when it fails)](#when-the-wheel-works-and-when-it-fails)
- [Strike selection](#strike-selection)
- [DTE selection](#dte-selection)
- [Roll vs. accept assignment](#roll-vs-accept-assignment-decision-tree)
- [Cost basis math](#cost-basis-math)
- [Position sizing](#position-sizing)
- [How `wheel_tracker.py` helps](#how-wheel_trackerpy-helps)
- [Pitfalls](#pitfalls)

---

## What is the Wheel?

Pick a stock you genuinely want to own. Sell a cash-secured put. Collect premium. If the put expires worthless, sell another. If it gets assigned, take the shares and sell covered calls against them. If the calls get assigned, take the gain plus all the premium and start the next cycle.

```
   ┌────────────────────────────────────────────────────────┐
   │                                                        │
   ▼                                                        │
[Stage 1] Sell cash-secured put                            │
   │                                                        │
   │ ─ Put expires OTM ──► collect premium, back to Stage 1│
   │                                                        │
   ▼ Put expires ITM                                        │
[Stage 2] Assigned 100 shares at the put strike            │
   │                                                        │
   ▼                                                        │
[Stage 3] Sell covered call against the shares             │
   │                                                        │
   │ ─ Call expires OTM ──► collect premium, repeat Stage 3│
   │                                                        │
   ▼ Call expires ITM                                       │
[Stage 4] Called away — shares sold at the call strike     │
   │                                                        │
   └────────────────────────────────────────────────────────┘
   Cycle complete: total P&L = stock P&L + all premium collected
```

The Wheel pays the trader to wait — first to enter, then to exit.

---

## The four stages

| Stage | What's happening | Greeks profile | Risk |
|---|---|---|---|
| **1. Short Put** | Selling premium, hoping put expires worthless. | Long delta · short gamma · short vega · long theta. | Stock drops below strike → assignment at a paper loss. |
| **2. Assigned** | Hold 100 shares per contract at strike − total_premium_collected effective cost. | Long delta (100 per contract) · no option Greeks. | Stock keeps falling — full stock-ownership risk. |
| **3. Covered Call** | Selling premium against the stock you now own. | Long delta (capped) · short gamma · short vega · long theta. | Stock rips above strike → called away, capped upside. |
| **4. Called Away** | Shares sold at call_strike. Cycle ends. | Cash. | Re-entry timing — IV may be low when you want back in. |

---

## When the wheel works (and when it fails)

### Works

- **Range-bound, mildly bullish quality names** (think large-cap dividend payers, ETFs you'd hold anyway).
- **High but not extreme IV** — enough premium to be worth the risk, not so much that it signals a regime change.
- **Sufficient capital** to take assignment without forced liquidation.
- **Patience**: the wheel is slow. 1–2% per cycle is typical.

### Fails

- **Falling knives** — a stock that drops 30% leaves you with a stock position so far underwater that covered calls can't pay you enough above your cost basis to be worth selling. *You can't wheel out of a bad pick.*
- **Earnings dates inside the cycle** — IV crush hands you a paper win but also volatile assignment risk. `earnings_calendar.py` will flag these.
- **Acquisitions / spin-offs** — corporate actions break the option contracts and can leave you stuck.
- **Concentration** — wheeling one ticker with 50% of capital. One bad event ruins the entire portfolio.

**Rule:** never sell a put on a stock you wouldn't be happy to own for two years.

---

## Strike selection

The dominant convention is **delta-based strike selection**:

| Delta band | Probability of assignment | Premium | Use when |
|---|---|---|---|
| 0.15 – 0.20 | ~15–20% | low | You really don't want assignment; just want income. |
| **0.20 – 0.30** | **~20–30%** | **balanced** | **Default wheel band.** Good income, fair assignment probability. |
| 0.30 – 0.40 | ~30–40% | high | Want assignment; using the put as a buy order. |
| > 0.40 | ATM-ish | highest | Aggressive; effectively buying the stock with a discount. |

**Why delta and not "% OTM"?** Delta normalizes for IV. A 5%-OTM put on a low-IV stock is far less risky than a 5%-OTM put on a high-IV stock; their deltas reflect that.

**Strike workflow with the toolkit:**

```bash
python scripts/options_chain.py SYM --dte-min 25 --dte-max 45
# read the chain; find the put whose delta is closest to your target band
```

`options_chain.py` returns per-strike delta, so you don't have to estimate.

---

## DTE selection

| DTE band | Theta/day | Gamma risk | Roll flexibility | Notes |
|---|---|---|---|---|
| 0–14 days | high (last-week curve) | severe | low | Gamma is the enemy; one earnings gap is disaster. |
| **30–45 days** | **good** | **manageable** | **good** | **Sweet spot for most wheelers.** |
| 60–90 days | moderate | low | great | Tied up longer; useful if VIX is elevated and you want to lock in vol. |
| > 90 days | low | minimal | maximum | Effectively a synthetic long position; rare for wheel. |

**Default:** 30–45 DTE puts, roll/close at 21 DTE or 50% max profit, whichever comes first. This is the tastytrade-popularized convention; it concentrates theta in the steepest part of the curve while leaving room to react.

---

## Roll vs. accept assignment (decision tree)

When a short put is ITM and approaching expiry:

```
Short put ITM at 5 DTE?
│
├── Q1: Is the thesis on the stock still intact?
│   │
│   ├── No  → Close the position. Take the loss. Move on.
│   │         (Wheeling a broken story is throwing good money after bad.)
│   │
│   └── Yes → continue to Q2
│
├── Q2: Can I roll for a net credit, to a later date, at the same or lower strike?
│   │
│   ├── Yes → Roll. Document the new strike/DTE/credit in wheel_tracker.
│   │         Typical: roll 30 days out, same strike, collect more premium.
│   │
│   └── No  → continue to Q3
│
├── Q3: Am I willing to own 100 shares at this strike given my current portfolio?
│   │
│   ├── Yes → Accept assignment. Begin Stage 2. Switch to covered calls.
│   │
│   └── No  → Close the put at market. Move on. Don't roll defensively into a worse position.
```

**Rolling rules:**

- **Never roll for a debit.** If you can't collect more premium, the trade is signaling that you should close.
- **Never roll inverted** (strike above current price for a put). It feels like a hedge but it locks in a loss with extra obligation.
- **Cap rolls at 2.** If a position has been rolled twice and is still in trouble, the thesis is wrong.

---

## Cost basis math

This is what new wheelers miss: your effective cost is **not** the strike, it's the strike minus all premium ever collected on that ticker in this cycle.

```
Effective cost basis = strike - Σ (all puts and calls collected in this cycle)
```

**Example:**

| Trade | Premium |
|---|---|
| Sold 30 DTE put, strike 100 | +$1.50 |
| Rolled out 30 days, strike 100 | +$0.80 |
| Assigned at 100 | — |
| Sold 30 DTE covered call at 105 | +$1.20 |
| Called away at 105 | — |

```
Effective cost basis = 100 - 1.50 - 0.80 - 1.20 = 96.50
Final P&L per share = 105 (call away) - 96.50 = +8.50  ≈ +8.8%
```

`wheel_tracker.py summary` does this math automatically per cycle.

---

## Position sizing

Hard rules:

- **No single wheel > 10% of account.** A single position blowing up shouldn't be portfolio-defining.
- **No single sector > 30%.** Energy and tech can correlate to near-1 in a sell-off.
- **Total wheel margin usage < 50% of buying power.** Leaves room to respond to assignments without forced sells.

If you're full on wheels, the answer to "should I wheel this great new ticker?" is "close an existing one first."

---

## How `wheel_tracker.py` helps

The script reads `~/.ibkr_wheel_journal.json` (your manual entries) and cross-references live positions from IBKR to figure out which stage each wheel is in.

**Add a new wheel entry:**

```bash
python scripts/wheel_tracker.py add-entry SYM STRIKE EXPIRY PREMIUM
# e.g.
python scripts/wheel_tracker.py add-entry MU 100 2026-06-19 1.45
```

**Get a summary:**

```bash
python scripts/wheel_tracker.py summary
```

Output (one row per active wheel):

```json
[
  {
    "symbol": "MU",
    "stage": "short_put",
    "current_strike": 100,
    "current_dte": 32,
    "premium_collected": 1.45,
    "effective_cost_basis": 98.55,
    "days_in_cycle": 8,
    "annualized_yield_pct": 16.5
  }
]
```

Use this weekly to spot wheels that are stuck (no progress in 60+ days) or that have under-collected premium relative to the time they've consumed.

---

## Pitfalls

| Pitfall | Why it hurts | Fix |
|---|---|---|
| Wheeling into earnings without realizing | IV crush + gap risk on assignment | `earnings_calendar.py` before every new put |
| Picking high-IV junk for premium | The premium is high because the stock is broken | Quality > yield. Filter by fundamentals first. |
| Rolling defensively forever | You compound a bad position | Cap rolls at 2; then close or accept. |
| Selling covered calls below cost basis | Locks in a loss when called away | Always set the call strike ≥ effective cost basis. |
| No exit plan at 50% max profit | Letting winners turn into losers | Close shorts at 50% of max profit on rolls of comparable strikes/DTEs. |
| Concentration in one ticker | One earnings miss = catastrophic | Position-size limits above. |

---

The wheel is a slow, mechanical strategy. Done badly it's a way to compound mistakes. Done well it's a 10–15% annualized strategy with defined exits at every stage. Use the tools, log every cycle, and let the math be the decider — not the urge to "make back" a losing leg.


path: requirements.txt
content: ib_async==2.1.0
pandas>=2.2,<3
numpy>=2.0,<3
pyyaml>=6.0
python-dotenv>=1.0
requests>=2.31
ibflex>=0.16


path: scripts/alerts_monitor.py
content: """
Alerts monitor — reads rules from ~/.ibkr_alerts.yaml, fetches quotes/option data per symbol, and evaluates condition expressions.

Variables available inside a condition expression:
  delta            option delta (position-level sum, or proposed contract)
  iv               implied volatility
  price            underlying spot price
  dte              days to expiration (taken from a matched position)
  unrealized_pnl   unrealized P&L of this symbol's portfolio leg

Condition syntax (parsed via ast, no eval):
  - numeric literals and the variables above
  - comparisons: < <= > >= == !=
  - boolean: and / or / not
  - arithmetic: + - * /  (and unary -)
  - functions: abs() min() max() round()
  Anything else (attribute access, indexing, other function calls,
  comprehensions, lambdas) is rejected at parse time.

Example ~/.ibkr_alerts.yaml:
  - symbol: AAPL
    condition: "price < 180 or unrealized_pnl < -500"
    on_trigger: "Consider rolling or closing the short put"
  - symbol: SPY
    condition: "iv > 0.25"
    on_trigger: "Premium-selling opportunity"
  - symbol: NVDA
    condition: "abs(delta) > 100 and dte < 14"
    on_trigger: "Delta too large near expiry"

Usage:
  python alerts_monitor.py
  python alerts_monitor.py --config ~/.ibkr_alerts.yaml --output /tmp/alerts.json
"""

import argparse
import ast
import json
import operator
import os
import sys
from datetime import datetime, date
from pathlib import Path

from contracts import resolve
from ib_client import ib_connect, log, qualify, req_historical_safe
from portfolio_positions import fetch_positions

CLIENT_ID_OFFSET = 16
DEFAULT_CONFIG = Path(os.path.expanduser("~/.ibkr_alerts.yaml"))


def _load_yaml(path: Path) -> list[dict]:
    if not path.exists():
        log(f"⚠️  Config file not found: {path}")
        return []
    try:
        import yaml  # type: ignore
        with open(path, encoding="utf-8") as f:
            data = yaml.safe_load(f) or []
        return data if isinstance(data, list) else []
    except ImportError:
        # Minimal fallback: each rule is three lines "- symbol: X" / "  condition: ..." / "  on_trigger: ..."
        rules: list[dict] = []
        cur: dict = {}
        for line in path.read_text(encoding="utf-8").splitlines():
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            if stripped.startswith("- "):
                if cur:
                    rules.append(cur)
                cur = {}
                stripped = stripped[2:]
            if ":" in stripped:
                k, _, v = stripped.partition(":")
                cur[k.strip()] = v.strip().strip('"').strip("'")
        if cur:
            rules.append(cur)
        return rules


def _spot_price(ib, symbol: str) -> float | None:
    try:
        contract = resolve(symbol)
        q = qualify(ib, contract)
        bars = req_historical_safe(
            ib, q,
            endDateTime="",
            durationStr="2 D",
            barSizeSetting="1 day",
            whatToShow="TRADES",
            useRTH=True,
            formatDate=1,
        )
        if bars:
            return round(float(bars[-1].close), 4)
    except Exception as e:
        log(f"  {symbol} spot fetch failed: {e}")
    return None


def gather_values(ib, symbol: str, portfolio: dict) -> dict:
    """Aggregate price / delta / iv / dte / unrealized_pnl for this symbol."""
    sym_positions = [p for p in portfolio.get("positions", [])
                     if p.get("symbol") == symbol]
    opt_positions = [p for p in sym_positions if p.get("sec_type") == "OPT"]

    # delta: sum of position deltas
    delta = 0.0
    iv_vals = []
    min_dte = None
    for p in opt_positions:
        pg = p.get("position_greeks") or {}
        if pg.get("delta") is not None:
            delta += pg["delta"]
        g = p.get("greeks") or {}
        if g.get("iv") is not None:
            iv_vals.append(g["iv"])
        exp = p.get("expiration")
        try:
            exp_date = datetime.strptime(exp, "%Y%m%d").date()
            dte = (exp_date - date.today()).days
            min_dte = dte if min_dte is None else min(min_dte, dte)
        except Exception:
            pass

    # stock delta is included
    for p in sym_positions:
        if p.get("sec_type") == "STK":
            delta += float(p.get("position") or 0)

    iv = round(sum(iv_vals) / len(iv_vals), 4) if iv_vals else None

    unrealized = sum((p.get("unrealized_pnl") or 0) for p in sym_positions)

    price = None
    for p in opt_positions:
        g = p.get("greeks") or {}
        if g.get("und_price"):
            price = g["und_price"]
            break
    if price is None:
        price = _spot_price(ib, symbol)

    return {
        "price": price,
        "delta": round(delta, 2),
        "iv": iv,
        "dte": min_dte,
        "unrealized_pnl": round(unrealized, 2),
    }


_BIN_OPS = {
    ast.Add: operator.add, ast.Sub: operator.sub,
    ast.Mult: operator.mul, ast.Div: operator.truediv,
}
_CMP_OPS = {
    ast.Lt: operator.lt, ast.LtE: operator.le,
    ast.Gt: operator.gt, ast.GtE: operator.ge,
    ast.Eq: operator.eq, ast.NotEq: operator.ne,
}
_ALLOWED_FUNCS = {"abs": abs, "min": min, "max": max, "round": round}


def _eval_node(node: ast.AST, vars_: dict):
    if isinstance(node, ast.Expression):
        return _eval_node(node.body, vars_)
    if isinstance(node, ast.Constant):
        if isinstance(node.value, (int, float, bool)):
            return node.value
        raise ValueError(f"unsupported literal: {type(node.value).__name__}")
    if isinstance(node, ast.Name):
        if node.id not in vars_:
            raise ValueError(f"unknown variable: {node.id}")
        return vars_[node.id]
    if isinstance(node, ast.UnaryOp):
        v = _eval_node(node.operand, vars_)
        if isinstance(node.op, ast.USub):
            return -v
        if isinstance(node.op, ast.UAdd):
            return +v
        if isinstance(node.op, ast.Not):
            return not v
        raise ValueError(f"unsupported unary op: {type(node.op).__name__}")
    if isinstance(node, ast.BinOp):
        op = _BIN_OPS.get(type(node.op))
        if op is None:
            raise ValueError(f"unsupported binary op: {type(node.op).__name__}")
        return op(_eval_node(node.left, vars_), _eval_node(node.right, vars_))
    if isinstance(node, ast.BoolOp):
        vals = [_eval_node(v, vars_) for v in node.values]
        if isinstance(node.op, ast.And):
            return all(vals)
        if isinstance(node.op, ast.Or):
            return any(vals)
        raise ValueError(f"unsupported bool op: {type(node.op).__name__}")
    if isinstance(node, ast.Compare):
        left = _eval_node(node.left, vars_)
        for op_node, right_node in zip(node.ops, node.comparators):
            op = _CMP_OPS.get(type(op_node))
            if op is None:
                raise ValueError(f"unsupported comparison: {type(op_node).__name__}")
            right = _eval_node(right_node, vars_)
            if not op(left, right):
                return False
            left = right
        return True
    if isinstance(node, ast.Call):
        if not isinstance(node.func, ast.Name) or node.func.id not in _ALLOWED_FUNCS:
            raise ValueError("only abs/min/max/round are callable")
        if node.keywords:
            raise ValueError("keyword arguments not allowed")
        args = [_eval_node(a, vars_) for a in node.args]
        return _ALLOWED_FUNCS[node.func.id](*args)
    raise ValueError(f"unsupported expression: {type(node).__name__}")


def evaluate(condition: str, values: dict) -> tuple[bool, str | None]:
    safe_vars = {k: (v if v is not None else float("nan")) for k, v in values.items()}
    try:
        tree = ast.parse(condition, mode="eval")
        return bool(_eval_node(tree, safe_vars)), None
    except Exception as e:
        return False, f"{type(e).__name__}: {e}"


def main() -> int:
    parser = argparse.ArgumentParser(description="Alerts monitor")
    parser.add_argument("--config", default=str(DEFAULT_CONFIG),
                        help="rules file path (default ~/.ibkr_alerts.yaml)")
    parser.add_argument("--output", help="output file path (default stdout)")
    args = parser.parse_args()

    config_path = Path(os.path.expanduser(args.config))
    rules = _load_yaml(config_path)
    if not rules:
        log(f"❌ No usable rules: {config_path}")
        result = {
            "checked_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "rules_evaluated": 0,
            "triggers": [],
        }
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return 0

    log(f"🔄 Evaluating {len(rules)} rule(s) ...")

    triggers = []
    evaluated = 0
    try:
        with ib_connect(client_id_offset=CLIENT_ID_OFFSET) as ib:
            portfolio = fetch_positions(ib)
            cache: dict[str, dict] = {}
            for rule in rules:
                sym = rule.get("symbol")
                cond = rule.get("condition")
                if not sym or not cond:
                    continue
                if sym not in cache:
                    cache[sym] = gather_values(ib, sym, portfolio)
                values = cache[sym]

                fired, err = evaluate(cond, values)
                evaluated += 1
                if err:
                    log(f"  ⚠️  {sym} rule error: {err}")
                    continue
                if fired:
                    triggers.append({
                        "symbol": sym,
                        "condition": cond,
                        "current_values": values,
                        "message": rule.get("on_trigger", "triggered"),
                    })
    except Exception as e:
        log(f"❌ Failed: {e}")
        return 1

    result = {
        "checked_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "config_path": str(config_path),
        "rules_evaluated": evaluated,
        "triggers": triggers,
    }

    json_str = json.dumps(result, ensure_ascii=False, indent=2)
    if args.output:
        tmp = args.output + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(json_str)
        os.rename(tmp, args.output)
        log(f"📁 Saved to {args.output}")
    else:
        print(json_str)

    log(f"✅ Done: {len(triggers)}/{evaluated} triggered")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/concentration.py
content: """
Portfolio concentration risk analysis.

Three concentration metrics that simple position-list browsing misses:

  1. Herfindahl-Hirschman Index (HHI)
       sum of squared position weights (in pct, 0-100).
       Scale: 0  = perfectly diversified
              <1500  = unconcentrated
              1500-2500 = moderate concentration
              >2500  = high concentration
              10000  = single holding

  2. Sector concentration (% of equity per GICS sector)
       Uses a small in-file ticker -> sector map for the ~50 most common
       tickers. Unknown tickers are bucketed as "Unknown" with a warning
       if the bucket grows large.

  3. Top-N concentration (top 3 / top 5 holdings, % of equity)

Only STK positions count toward the weights. OPT (derivative) positions are
excluded -- they will surface via the dedicated greeks/exposure scripts.

Usage:
    python concentration.py
    python concentration.py --portfolio-file /tmp/portfolio.json
    python concentration.py --output /tmp/concentration.json
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path

from ib_client import ib_connect, log
from portfolio_positions import fetch_positions

CLIENT_ID_OFFSET = 18

# Hardcoded ticker -> sector map. Kept small and inline by design --
# anything missing falls into "Unknown" and we warn if Unknown is large.
TICKER_SECTOR: dict[str, str] = {
    # Semiconductors (separated from broader Technology for finer-grained warnings)
    "NVDA": "Semiconductors", "AMD": "Semiconductors", "INTC": "Semiconductors",
    "MU": "Semiconductors", "MRVL": "Semiconductors", "ARM": "Semiconductors",
    "TSM": "Semiconductors", "AVGO": "Semiconductors", "QCOM": "Semiconductors",
    "ASML": "Semiconductors", "AMAT": "Semiconductors", "LRCX": "Semiconductors",
    "KLAC": "Semiconductors", "ON": "Semiconductors", "TXN": "Semiconductors",
    "MCHP": "Semiconductors", "SMCI": "Semiconductors",
    # Software / Hardware / Internet (Technology)
    "AAPL": "Technology", "MSFT": "Technology", "GOOGL": "Technology",
    "GOOG": "Technology", "AMZN": "Consumer Discretionary", "META": "Technology",
    "NFLX": "Communication Services", "ORCL": "Technology", "CRM": "Technology",
    "ADBE": "Technology", "NOW": "Technology", "SHOP": "Technology",
    "PLTR": "Technology", "SNOW": "Technology", "CRWD": "Technology",
    "PANW": "Technology", "NET": "Technology", "DDOG": "Technology",
    # EV / Automotive
    "TSLA": "Consumer Discretionary", "RIVN": "Consumer Discretionary",
    "LCID": "Consumer Discretionary", "F": "Consumer Discretionary",
    "GM": "Consumer Discretionary",
    # Financials
    "JPM": "Financials", "GS": "Financials", "BAC": "Financials",
    "MS": "Financials", "WFC": "Financials", "C": "Financials",
    "BLK": "Financials", "SCHW": "Financials", "V": "Financials",
    "MA": "Financials", "AXP": "Financials", "PYPL": "Financials",
    "COIN": "Financials",
    # Healthcare
    "UNH": "Healthcare", "JNJ": "Healthcare", "LLY": "Healthcare",
    "PFE": "Healthcare", "ABBV": "Healthcare", "MRK": "Healthcare",
    "TMO": "Healthcare", "ABT": "Healthcare", "NVO": "Healthcare",
    # Energy
    "XOM": "Energy", "CVX": "Energy", "COP": "Energy", "SLB": "Energy",
    # Consumer Staples
    "WMT": "Consumer Staples", "COST": "Consumer Staples", "PG": "Consumer Staples",
    "KO": "Consumer Staples", "PEP": "Consumer Staples",
    # Industrials
    "BA": "Industrials", "CAT": "Industrials", "GE": "Industrials",
    "RTX": "Industrials", "LMT": "Industrials", "UPS": "Industrials",
    # Diversified ETFs (sector-agnostic; counted toward concentration as their own bucket)
    "SPY": "Diversified", "QQQ": "Diversified", "IWM": "Diversified",
    "VOO": "Diversified", "VTI": "Diversified", "DIA": "Diversified",
    # Crypto-adjacent
    "MSTR": "Technology", "MARA": "Technology", "RIOT": "Technology",
}


def hhi_interpretation(hhi: float) -> str:
    if hhi < 1500:
        return "low concentration"
    if hhi < 2500:
        return "moderate concentration"
    if hhi < 5000:
        return "high concentration"
    return "extreme concentration"


def lookup_sector(symbol: str) -> str:
    return TICKER_SECTOR.get(symbol.upper(), "Unknown")


def compute_concentration(positions: list[dict]) -> dict:
    """Compute HHI, sector and top-N concentration from a list of positions.

    Only STK positions count toward the weights.
    """
    stk = [p for p in positions
           if p.get("sec_type") == "STK"
           and p.get("market_value") not in (None, 0)
           and (p.get("position") or 0) > 0]

    total = sum(float(p["market_value"]) for p in stk)
    if total <= 0:
        return {
            "total_equity_value": 0.0,
            "hhi": 0.0,
            "hhi_interpretation": "no equity holdings",
            "top_holdings": [],
            "top_3_pct": 0.0,
            "top_5_pct": 0.0,
            "sectors": {},
            "warnings": ["No equity holdings to analyze"],
        }

    enriched = []
    for p in stk:
        mv = float(p["market_value"])
        weight_pct = mv / total * 100.0
        enriched.append({
            "symbol": p["symbol"],
            "market_value": round(mv, 2),
            "weight_pct": round(weight_pct, 2),
            "sector": lookup_sector(p["symbol"]),
        })

    enriched.sort(key=lambda x: x["market_value"], reverse=True)

    hhi = sum(p["weight_pct"] ** 2 for p in enriched)

    top_3_pct = round(sum(p["weight_pct"] for p in enriched[:3]), 2)
    top_5_pct = round(sum(p["weight_pct"] for p in enriched[:5]), 2)

    sectors: dict[str, float] = {}
    for p in enriched:
        sectors[p["sector"]] = sectors.get(p["sector"], 0.0) + p["weight_pct"]
    sectors = {k: round(v, 2) for k, v in
               sorted(sectors.items(), key=lambda kv: kv[1], reverse=True)}

    warnings: list[str] = []
    if top_3_pct > 30:
        warnings.append(
            f"Top 3 holdings = {top_3_pct}% of equity (risk concentration)"
        )
    if top_5_pct > 50:
        warnings.append(
            f"Top 5 holdings = {top_5_pct}% of equity"
        )
    for sector, pct in sectors.items():
        if sector == "Unknown" and pct > 15:
            warnings.append(
                f"Unknown sector = {pct}% -- consider mapping these tickers"
            )
            continue
        if sector == "Diversified":
            continue
        if pct > 20:
            warnings.append(
                f"{sector} = {pct}% -- single-sector exposure"
            )
    if enriched and enriched[0]["weight_pct"] > 20:
        top = enriched[0]
        warnings.append(
            f"Single position {top['symbol']} = {top['weight_pct']}% of equity"
        )

    return {
        "total_equity_value": round(total, 2),
        "hhi": round(hhi, 2),
        "hhi_interpretation": hhi_interpretation(hhi),
        "top_holdings": enriched,
        "top_3_pct": top_3_pct,
        "top_5_pct": top_5_pct,
        "sectors": sectors,
        "warnings": warnings,
    }


def load_portfolio_file(path: Path) -> list[dict]:
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
    return data.get("positions", [])


def _write_output(result: dict, output: str | None) -> None:
    json_str = json.dumps(result, ensure_ascii=False, indent=2)
    if output:
        tmp = output + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(json_str)
        os.rename(tmp, output)
        log(f"saved to {output}")
    else:
        print(json_str)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Portfolio concentration risk (HHI + sector + top-N)"
    )
    parser.add_argument("--portfolio-file",
                        help="Read positions from this JSON instead of live IBKR fetch")
    parser.add_argument("--output", help="Output file path (default stdout)")
    args = parser.parse_args()

    if args.portfolio_file:
        log(f"reading positions from {args.portfolio_file}")
        positions = load_portfolio_file(Path(args.portfolio_file))
    else:
        log("fetching live positions from IB Gateway ...")
        try:
            with ib_connect(client_id_offset=CLIENT_ID_OFFSET) as ib:
                portfolio = fetch_positions(ib)
                positions = portfolio["positions"]
        except Exception as e:
            log(f"ERROR: {e}")
            return 1

    metrics = compute_concentration(positions)
    result = {
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        **metrics,
    }

    _write_output(result, args.output)
    log(
        f"done: HHI={result['hhi']} ({result['hhi_interpretation']}); "
        f"top3={result['top_3_pct']}%; warnings={len(result['warnings'])}"
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/contracts.py
content: """
IBKR 合约映射表 —— 所有 OpenClaw 金融脚本共用的"单一数据源"。

用法：
    from contracts import resolve, INDICES, SECTOR_ETFS, FUTURES, US_TICKERS, HK_TICKERS

    contract = resolve("^GSPC")        # → Index("SPX", "CBOE", "USD")
    contract = resolve("AAPL")         # → Stock("AAPL", "SMART", "USD")
    contract = resolve("0700.HK")      # → Stock("700", "SEHK", "HKD")
    contract = resolve("EURUSD")       # → Forex("EURUSD")

命名规则：
  - Yahoo 风格的 ^XXX 指数、XXX.HK 港股、USD/ZAR 汇率都能 resolve
  - 板块 ETF、资金流 ETF、期货、外汇都有固定 key，见各 dict
"""

from typing import Optional

from ib_async import ContFuture, Forex, Index, Option, Stock


# ─────────────────────────────────────────────────────────────
# 美股指数（CBOE Indices 订阅必须开）
# ─────────────────────────────────────────────────────────────
# Yahoo key → (IBKR symbol, exchange, 中文名, 是否为收益率指数)
INDICES: dict[str, tuple[str, str, str, str, bool]] = {
    "^GSPC":  ("SPX",   "CBOE",   "USD", "标普 500",    False),
    "^DJI":   ("INDU",  "CME",    "USD", "道琼斯",      False),
    "^IXIC":  ("COMP",  "NASDAQ", "USD", "纳斯达克综合", False),
    "^HSI":   ("HSI",   "HKFE",   "HKD", "恒生指数",    False),
    "^VIX":   ("VIX",   "CBOE",   "USD", "VIX 波动率",  False),
    # 美债收益率指数（数值单位 = %，不是价格）
    "^TNX":   ("TNX",   "CBOE",   "USD", "10Y 美债收益率", True),
    "^TYX":   ("TYX",   "CBOE",   "USD", "30Y 美债收益率", True),
    "^FVX":   ("FVX",   "CBOE",   "USD", "5Y 美债收益率",  True),
    "^IRX":   ("IRX",   "CBOE",   "USD", "13W 美债收益率", True),
}

# ─────────────────────────────────────────────────────────────
# 板块 ETF（美股 SMART 路由，delayed 免费）
# ─────────────────────────────────────────────────────────────
SECTOR_ETFS: dict[str, str] = {
    "XLK":  "科技",
    "XLF":  "金融",
    "XLY":  "非必需消费",
    "XLV":  "医疗",
    "XLE":  "能源",
    "XLU":  "公用事业",
    "XLI":  "工业",
    "XLP":  "必需消费",
    "XLB":  "材料",
    "XLRE": "房地产",
}

# 细分行业 ETF（日报用，比宽板块更精准地反映资金流向）
INDUSTRY_ETFS: dict[str, str] = {
    "SMH":  "半导体",
    "IGV":  "软件/SaaS",
    "HACK": "网络安全",
    "XBI":  "生物科技",
    "KRE":  "区域银行",
    "XHB":  "房屋建筑",
    "XRT":  "零售",
    "JETS": "航空",
    "OIH":  "油服",
    "ITA":  "国防/航空",
    "TAN":  "太阳能",
    "GDX":  "黄金矿业",
    "ARKK": "创新科技",
    "KWEB": "中概互联",
    "XME":  "金属/矿业",
}

# ─────────────────────────────────────────────────────────────
# 资金流 / 避险 / 信用 ETF
# ─────────────────────────────────────────────────────────────
MARKET_ETFS: dict[str, str] = {
    "SPY": "标普 ETF",
    "QQQ": "纳指 ETF",
    "IWM": "小盘股 ETF",
}
SAFE_HAVEN_ETFS: dict[str, str] = {
    "TLT": "长债 ETF(20Y)",
    "GLD": "黄金 ETF",
}
CREDIT_ETFS: dict[str, str] = {
    "HYG": "高收益债 ETF",
    "LQD": "投资级债 ETF",
}

# ─────────────────────────────────────────────────────────────
# 国际股指 ETF（avoid 开多国交易所订阅）
# ─────────────────────────────────────────────────────────────
INTL_ETFS: dict[str, str] = {
    "EWJ":  "日本 iShares",
    "EWG":  "德国 iShares",
    "EWU":  "英国 iShares",
    "MCHI": "中国 iShares",
    "INDA": "印度 iShares",
    "EWZ":  "巴西 iShares",
}

# ─────────────────────────────────────────────────────────────
# 期货连续合约（各交易所订阅必须开）
# ─────────────────────────────────────────────────────────────
# key → (IBKR symbol, exchange, 中文名)
FUTURES: dict[str, tuple[str, str, str]] = {
    "VX": ("VIX", "CFE",   "VIX 波动率期货"),
    "ES": ("ES",  "CME",   "标普期货"),
    "NQ": ("NQ",  "CME",   "纳指期货"),
    "ZN": ("ZN",  "CBOT",  "10Y 美债期货"),
    "CL": ("CL",  "NYMEX", "WTI 原油"),
    "GC": ("GC",  "COMEX", "黄金"),
    "SI": ("SI",  "COMEX", "白银"),
}

# ─────────────────────────────────────────────────────────────
# 外汇（IDEALPRO 免费）
# ─────────────────────────────────────────────────────────────
FX_PAIRS: list[str] = [
    "EURUSD",
    "USDJPY",
    "GBPUSD",
    "USDCNH",
    "AUDUSD",
]

# ─────────────────────────────────────────────────────────────
# 股票清单（从旧 fetch_top_movers.py 迁移）
# ─────────────────────────────────────────────────────────────
US_TICKERS: list[str] = [
    "AAPL", "MSFT", "AMZN", "NVDA", "GOOGL", "META", "TSLA", "BRK B",
    "UNH", "XOM", "JNJ", "JPM", "V", "PG", "MA", "AVGO", "HD", "CVX",
    "MRK", "ABBV", "LLY", "COST", "PEP", "KO", "WMT", "BAC", "MCD",
    "TMO", "CSCO", "CRM", "ACN", "ABT", "DHR", "ADBE", "TXN", "NFLX",
    "CMCSA", "AMD", "PM", "NEE", "WFC", "INTC", "UNP", "RTX", "ORCL",
    "IBM", "QCOM", "LOW", "INTU", "SPGI", "GE", "AMAT", "ISRG", "CAT",
    "BLK", "AXP", "BKNG", "SYK", "MDLZ", "ADI", "GILD", "VRTX",
    "MMC", "LRCX", "DE", "ADP", "AMT", "REGN", "GS", "CB", "SCHW",
    "PANW", "MU", "KLAC", "SHW", "FI", "SNPS", "CDNS", "ICE", "BSX",
    "SO", "DUK", "CME", "CL", "MCO", "PGR", "APD", "EQIX", "TJX",
    "NOC", "EMR", "USB", "PNC", "ABNB", "MELI", "FTNT", "CRWD",
    "PYPL", "UBER", "COIN", "RIVN", "PLTR", "SNOW", "DDOG", "ZS",
    "NET", "ARM", "SMCI", "MRVL", "ON", "ENPH", "FSLR", "SEDG",
    "NKE", "DIS", "SBUX", "F", "GM", "UAL", "DAL", "LUV",
    "PFE", "BMY", "MRNA", "BIIB",
]
# BRK-B 在 IBKR 用空格（BRK B）

# 港股（旧脚本 .HK 格式 → IBKR 去掉前导 0 的 local symbol）
# IBKR 港股接受的 symbol 是去前导 0 的数字，如 "700"、"9988"
HK_TICKERS_YAHOO: list[str] = [
    "0700.HK", "9988.HK", "9618.HK", "3690.HK", "9999.HK",
    "1810.HK", "2382.HK", "0992.HK", "6690.HK", "0268.HK",
    "1211.HK", "0175.HK", "2015.HK", "0968.HK",
    "0005.HK", "0388.HK", "2318.HK", "2628.HK", "1299.HK", "3988.HK",
    "2020.HK", "9961.HK", "6862.HK", "0291.HK",
    "0883.HK", "0857.HK", "1088.HK",
    "0016.HK", "0001.HK", "0003.HK",
    "1177.HK", "6160.HK",
    "0027.HK", "1928.HK",
    "0941.HK", "0762.HK",
]

# 中文公司名（从旧 fetch_top_movers.py 迁）
COMPANY_NAMES: dict[str, str] = {
    # 美股
    "AAPL": "苹果", "MSFT": "微软", "AMZN": "亚马逊", "NVDA": "英伟达",
    "GOOGL": "谷歌", "META": "Meta", "TSLA": "特斯拉", "BRK B": "伯克希尔",
    "UNH": "联合健康", "XOM": "埃克森美孚", "JNJ": "强生", "JPM": "摩根大通",
    "V": "Visa", "PG": "宝洁", "MA": "万事达", "AVGO": "博通",
    "HD": "家得宝", "CVX": "雪佛龙", "MRK": "默沙东", "ABBV": "艾伯维",
    "LLY": "礼来", "COST": "Costco", "PEP": "百事", "KO": "可口可乐",
    "WMT": "沃尔玛", "BAC": "美国银行", "MCD": "麦当劳", "NFLX": "Netflix",
    "AMD": "AMD", "INTC": "英特尔", "QCOM": "高通", "ORCL": "甲骨文",
    "CRM": "Salesforce", "ADBE": "Adobe", "INTU": "Intuit",
    "GS": "高盛", "SCHW": "嘉信理财",
    "NKE": "耐克", "DIS": "迪士尼", "SBUX": "星巴克",
    "PFE": "辉瑞", "BMY": "百时美施贵宝", "MRNA": "Moderna",
    "F": "福特", "GM": "通用汽车", "UBER": "Uber",
    "COIN": "Coinbase", "PLTR": "Palantir", "SNOW": "Snowflake",
    "CRWD": "CrowdStrike", "PANW": "Palo Alto", "ARM": "Arm",
    "SMCI": "超微电脑", "MRVL": "Marvell", "MU": "美光",
    "PYPL": "PayPal", "ABNB": "Airbnb", "MELI": "MercadoLibre",
    "RIVN": "Rivian", "ENPH": "Enphase", "FSLR": "First Solar",
    "NET": "Cloudflare", "DDOG": "Datadog", "ZS": "Zscaler",
    # 港股（用 Yahoo key 查询更稳，resolve 时会转换）
    "9988.HK": "阿里巴巴", "0700.HK": "腾讯", "3690.HK": "美团",
    "9618.HK": "京东", "1211.HK": "比亚迪", "2020.HK": "安踏",
    "9999.HK": "网易", "0941.HK": "中国移动", "1810.HK": "小米",
    "2318.HK": "中国平安", "0005.HK": "汇丰", "0388.HK": "港交所",
    "2628.HK": "中国人寿", "0003.HK": "中华煤气", "1299.HK": "友邦保险",
    "0883.HK": "中海油", "0016.HK": "新鸿基", "0027.HK": "银河娱乐",
    "2382.HK": "舜宇光学", "0968.HK": "信义光能", "0992.HK": "联想",
    "6690.HK": "海尔智家", "0268.HK": "金蝶国际", "0175.HK": "吉利汽车",
    "2015.HK": "理想汽车", "3988.HK": "中国银行", "9961.HK": "携程",
    "6862.HK": "海底捞", "0291.HK": "华润啤酒", "0857.HK": "中石油",
    "1088.HK": "中国神华", "0001.HK": "长和", "1177.HK": "中国生物制药",
    "6160.HK": "百济神州", "1928.HK": "金沙中国", "0762.HK": "中国联通",
}


# ─────────────────────────────────────────────────────────────
# 解析函数
# ─────────────────────────────────────────────────────────────

def _hk_yahoo_to_ibkr(yahoo_code: str) -> str:
    """'0700.HK' → '700'（IBKR 港股 symbol 去前导 0）"""
    return yahoo_code.split(".")[0].lstrip("0") or "0"


# 个别美股在 IBKR 里 ticker/primaryExchange 与 Yahoo 不一致（歧义/老代码）
# Yahoo ticker → (IBKR symbol, primaryExchange)
US_TICKER_ALIASES: dict[str, tuple[str, str]] = {
    "MMC": ("MRSH", "NYSE"),   # Marsh & McLennan：IBKR 仍挂老 ticker MRSH (conId 9705)
    "FI":  ("FISV", "NASDAQ"), # Fiserv：Yahoo 改名 FI，IBKR 仍是 FISV (conId 269315)
}


def us_stock(ticker: str):
    """构造美股 Stock，处理 IBKR 老 ticker / 模糊歧义。"""
    t = ticker.strip().upper()
    if t in US_TICKER_ALIASES:
        sym, primary = US_TICKER_ALIASES[t]
        return Stock(sym, "SMART", "USD", primaryExchange=primary)
    return Stock(t, "SMART", "USD")


def resolve(symbol: str):
    """把任意代码映射成 ib_async Contract。

    支持：
      - Yahoo 指数  ^GSPC / ^TNX / ^VIX
      - Yahoo 港股  0700.HK
      - 外汇       EURUSD / USD/ZAR / GBP-JPY（统一成 6 字符无分隔）
      - 期货 key   ES / NQ / VX (从 FUTURES dict)
      - 其他       视为美股 SMART 路由
    """
    sym = symbol.strip().upper()

    # 1. 指数
    if sym in INDICES:
        ibkr_sym, exchange, currency, _, _ = INDICES[sym]
        return Index(ibkr_sym, exchange, currency)

    # 2. 港股
    if sym.endswith(".HK"):
        return Stock(_hk_yahoo_to_ibkr(sym), "SEHK", "HKD")

    # 3. 期货连续合约
    if sym in FUTURES:
        ibkr_sym, exchange, _ = FUTURES[sym]
        return ContFuture(ibkr_sym, exchange)

    # 4. 外汇（USDJPY / USD/JPY / USD-JPY 都接受）
    fx_norm = sym.replace("/", "").replace("-", "")
    if len(fx_norm) == 6 and fx_norm.isalpha():
        return Forex(fx_norm)

    # 5. 美元指数（Yahoo: DX-Y.NYB）
    if sym in ("DX-Y.NYB", "DXY", "DX"):
        # NYBOT 的美元指数期货连续合约（现货 IND 未在 IBKR 免费列表）
        return ContFuture("DX", "NYBOT")

    # 6. 默认当美股
    return Stock(sym, "SMART", "USD")


def company_name(symbol: str) -> str:
    """返回中文公司名，没有则返回原 symbol。"""
    return COMPANY_NAMES.get(symbol.upper(), symbol)


path: scripts/cost_basis.py
content: """
Effective cost basis calculator for wheel-strategy positions.

The IBKR broker-reported average cost (`avg_cost`) does not subtract option
premiums collected during a wheel cycle. For a trader who sells cash-secured
puts and covered calls, the effective cost basis (and thus the true breakeven)
is materially lower than what the broker shows. This script computes the
effective cost basis by:

  1. Reading current STK positions (avg_cost, shares held) via ib.positions().
  2. Pulling option fills for that underlying from the current session
     (ib.fills(), ~2 days of history).
  3. Optionally augmenting with older history from Flex Statement CSVs
     (~/.ibkr_flex/*.csv).

Premium accounting convention:
  - Selling an option (BOT side = "SLD") -> credit (premiums_collected += |price * qty * 100| - commission)
  - Buying back an option (BOT side = "BOT") -> debit (premiums_paid_back  += |price * qty * 100| + commission)

Effective cost basis = (broker_total_cost - net_premium_credit) / shares_held.

Usage:
    python cost_basis.py MU
    python cost_basis.py MU NVDA AAPL
    python cost_basis.py --portfolio-file /tmp/portfolio.json
    python cost_basis.py MU --flex-dir ~/.ibkr_flex --output /tmp/cb.json
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys
from datetime import datetime
from pathlib import Path

from ib_client import ib_connect, log

CLIENT_ID_OFFSET = 17


def _is_sell(side: str) -> bool:
    return (side or "").upper() in ("SLD", "SELL", "SS")


def _is_buy(side: str) -> bool:
    return (side or "").upper() in ("BOT", "BUY")


def fetch_session_option_fills(ib, symbols: set[str]) -> list[dict]:
    """Read option fills for the given symbols from the current IBKR session."""
    ib.reqExecutions()
    ib.sleep(1)
    fills = ib.fills()

    out: list[dict] = []
    for fill in fills:
        c = fill.contract
        if c.secType != "OPT":
            continue
        if c.symbol not in symbols:
            continue
        e = fill.execution
        cr = fill.commissionReport
        out.append({
            "symbol": c.symbol,
            "right": c.right,
            "strike": float(c.strike) if c.strike else None,
            "expiration": c.lastTradeDateOrContractMonth,
            "side": e.side,
            "qty": float(e.shares),
            "price": float(e.price),
            "commission": float(getattr(cr, "commission", 0.0) or 0.0),
            "source": "session",
        })
    return out


def fetch_flex_option_fills(flex_dir: Path, symbols: set[str]) -> list[dict]:
    """Read option fills for the given symbols from Flex Statement CSVs."""
    if not flex_dir.exists():
        return []

    out: list[dict] = []
    for csv_path in sorted(flex_dir.glob("*.csv")):
        try:
            with open(csv_path, encoding="utf-8") as f:
                reader = csv.DictReader(f)
                for row in reader:
                    sec_type = (row.get("AssetClass") or row.get("SecType") or "").upper()
                    if sec_type and sec_type != "OPT":
                        continue
                    sym = (row.get("UnderlyingSymbol") or row.get("Symbol") or "").upper()
                    if not sym or sym not in symbols:
                        continue
                    right = (row.get("Put/Call") or row.get("Right") or "").upper()
                    if right not in ("P", "C"):
                        # Skip non-options rows when AssetClass column is missing
                        if not sec_type:
                            continue
                    try:
                        qty = float(row.get("Quantity") or row.get("Qty") or 0)
                        price = float(row.get("TradePrice") or row.get("Price") or 0)
                        commission = float(row.get("IBCommission") or 0)
                    except ValueError:
                        continue
                    out.append({
                        "symbol": sym,
                        "right": right or None,
                        "strike": float(row["Strike"]) if row.get("Strike") else None,
                        "expiration": row.get("Expiry") or row.get("Expiration"),
                        "side": row.get("Buy/Sell") or row.get("Side") or "",
                        "qty": qty,
                        "price": price,
                        "commission": commission,
                        "source": f"flex:{csv_path.name}",
                    })
        except Exception as e:
            log(f"  skip {csv_path.name}: {e}")
    return out


def compute_cost_basis(symbol: str, stk_positions: list[dict],
                       option_fills: list[dict]) -> dict:
    """Build the effective cost-basis report for a single symbol."""
    sym = symbol.upper()
    stk = next((p for p in stk_positions if p.get("symbol") == sym), None)
    shares_held = float(stk["position"]) if stk else 0.0
    broker_avg = float(stk["avg_cost"]) if stk else 0.0
    broker_total = round(shares_held * broker_avg, 2)

    sym_fills = [f for f in option_fills if f["symbol"] == sym]

    premiums_collected = 0.0
    premiums_paid_back = 0.0
    counts = {"puts_sold": 0, "puts_bought_back": 0,
              "calls_sold": 0, "calls_bought_back": 0}

    for f in sym_fills:
        notional = abs(f["price"] * f["qty"]) * 100.0  # one contract = 100 shares
        commission = abs(f["commission"])
        right = (f.get("right") or "").upper()
        if _is_sell(f["side"]):
            premiums_collected += notional - commission
            if right == "P":
                counts["puts_sold"] += 1
            elif right == "C":
                counts["calls_sold"] += 1
        elif _is_buy(f["side"]):
            premiums_paid_back += notional + commission
            if right == "P":
                counts["puts_bought_back"] += 1
            elif right == "C":
                counts["calls_bought_back"] += 1

    net_credit = premiums_collected - premiums_paid_back

    if shares_held > 0:
        effective_total = broker_total - net_credit
        effective_basis = round(effective_total / shares_held, 4)
    else:
        effective_basis = None  # only premium accumulation, no shares

    return {
        "symbol": sym,
        "shares_held": shares_held,
        "broker_avg_cost": round(broker_avg, 4) if stk else None,
        "broker_total_cost": broker_total if stk else 0.0,
        "premiums_collected": round(premiums_collected, 2),
        "premiums_paid_back": round(premiums_paid_back, 2),
        "net_premium_credit": round(net_credit, 2),
        "effective_cost_basis": effective_basis,
        "effective_breakeven": effective_basis,
        "premium_count": counts,
    }


def load_portfolio_file(path: Path) -> list[dict]:
    """Read positions from a portfolio_positions.py JSON dump."""
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
    return data.get("positions", [])


def _write_output(result: dict, output: str | None) -> None:
    json_str = json.dumps(result, ensure_ascii=False, indent=2)
    if output:
        tmp = output + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(json_str)
        os.rename(tmp, output)
        log(f"saved to {output}")
    else:
        print(json_str)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Effective cost basis (broker cost - net option premium)"
    )
    parser.add_argument("symbols", nargs="*",
                        help="One or more symbols. Omit when using --portfolio-file.")
    parser.add_argument("--portfolio-file",
                        help="JSON from portfolio_positions.py; auto-discover all STK symbols.")
    parser.add_argument("--flex-dir", default="~/.ibkr_flex",
                        help="Flex Statement CSV directory (default ~/.ibkr_flex)")
    parser.add_argument("--output", help="Output file path (default stdout)")
    args = parser.parse_args()

    flex_dir = Path(os.path.expanduser(args.flex_dir))

    if not args.symbols and not args.portfolio_file:
        log("ERROR: provide one or more SYMBOLs, or --portfolio-file")
        return 1

    # Resolve target symbols + STK positions
    stk_positions: list[dict] = []
    target_symbols: set[str] = {s.upper() for s in args.symbols}

    if args.portfolio_file:
        positions = load_portfolio_file(Path(args.portfolio_file))
        stk_positions = [p for p in positions if p.get("sec_type") == "STK"]
        if not args.symbols:
            target_symbols = {p["symbol"] for p in stk_positions}
        log(f"loaded {len(stk_positions)} STK positions from {args.portfolio_file}")

    log(f"target symbols: {sorted(target_symbols)}")

    # Pull session fills (and live STK positions if we didn't get them from file)
    session_fills: list[dict] = []
    try:
        with ib_connect(client_id_offset=CLIENT_ID_OFFSET) as ib:
            if not args.portfolio_file:
                ib.reqPositions()
                ib.sleep(1)
                for pos in ib.positions():
                    if pos.contract.secType == "STK" and pos.contract.symbol in target_symbols:
                        stk_positions.append({
                            "symbol": pos.contract.symbol,
                            "sec_type": "STK",
                            "position": pos.position,
                            "avg_cost": pos.avgCost,
                        })
            session_fills = fetch_session_option_fills(ib, target_symbols)
            log(f"session option fills: {len(session_fills)}")
    except Exception as e:
        log(f"  warning: IBKR session unavailable: {e}")

    flex_fills = fetch_flex_option_fills(flex_dir, target_symbols)
    log(f"flex option fills: {len(flex_fills)}")

    option_fills = session_fills + flex_fills
    source_window = "session"
    if flex_fills:
        source_window += f" + flex({len([f for f in flex_fills])} rows)"

    reports = []
    for sym in sorted(target_symbols):
        report = compute_cost_basis(sym, stk_positions, option_fills)
        report["source_window"] = source_window
        reports.append(report)

    result = {
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "flex_dir": str(flex_dir),
        "source_window": source_window,
        "symbols": reports,
    }

    _write_output(result, args.output)
    log(f"done: {len(reports)} symbol(s)")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/earnings_calendar.py
content: """
Earnings calendar lookup — fetches the next earnings date for given symbols, optionally merging with portfolio data to flag at-risk option positions.

Data sources (by priority):
  - Nasdaq /api/calendar/earnings (public, no API key, queried by date)
  - Finnhub /calendar/earnings (fallback, if FINNHUB_API_KEY is set)

Usage:
  python earnings_calendar.py AAPL MSFT NVDA
  python earnings_calendar.py AAPL --days 14
  python earnings_calendar.py --portfolio-file /tmp/portfolio.json --output /tmp/earn.json
"""

import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
from datetime import datetime, date, timedelta


def log(msg: str) -> None:
    print(msg, file=sys.stderr, flush=True)


def _parse_date(val) -> date | None:
    if val is None:
        return None
    if isinstance(val, (int, float)):
        try:
            return datetime.utcfromtimestamp(val).date()
        except Exception:
            return None
    if isinstance(val, datetime):
        return val.date()
    if isinstance(val, date):
        return val
    s = str(val)
    for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ",
                "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
        try:
            return datetime.strptime(s, fmt).date()
        except Exception:
            continue
    return None


def fetch_nasdaq_range(days: int) -> dict[str, date]:
    """Fetch all earnings in the next N days; returns {symbol: earnings_date}."""
    today = date.today()
    result: dict[str, date] = {}
    for offset in range(days + 1):
        d = today + timedelta(days=offset)
        url = f"https://api.nasdaq.com/api/calendar/earnings?date={d.isoformat()}"
        req = urllib.request.Request(url, headers={
            "User-Agent": "Mozilla/5.0",
            "Accept": "application/json",
        })
        try:
            data = json.loads(urllib.request.urlopen(req, timeout=10).read())
        except Exception as e:
            log(f"  Nasdaq {d}: {e}")
            continue
        rows = (data.get("data") or {}).get("rows") or []
        for row in rows:
            sym = (row.get("symbol") or "").strip().upper()
            if sym and sym not in result:
                result[sym] = d
    return result


def fetch_finnhub_one(symbol: str, days: int) -> dict | None:
    [REDACTED_SECRET]"FINNHUB_API_KEY")
    if not [REDACTED_SECRET] None
    today = date.today()
    cutoff = today + timedelta(days=days)
    params = urllib.parse.urlencode({
        "symbol": symbol,
        "from": today.isoformat(),
        "to": cutoff.isoformat(),
        "token": api_key,
    })
    url = f"https://finnhub.io/api/v1/calendar/earnings?{params}"
    try:
        with urllib.request.urlopen(url, timeout=10) as resp:
            data = json.loads(resp.read())
    except Exception as e:
        log(f"  Finnhub {symbol}: {e}")
        return None

    items = data.get("earningsCalendar", [])
    if not items:
        return None
    items.sort(key=lambda i: i.get("date", ""))
    item = items[0]
    edate = _parse_date(item.get("date"))
    if not edate:
        return None
    return {
        "symbol": symbol,
        "next_earnings_date": edate.isoformat(),
        "days_until": (edate - today).days,
        "fiscal_period": f"{item.get('year')}Q{item.get('quarter')}",
        "source": "finnhub",
    }


def at_risk_positions(portfolio: dict, earnings: list[dict]) -> list[dict]:
    """Find option positions whose DTE straddles the earnings date."""
    earn_map = {e["symbol"]: e for e in earnings if e.get("days_until") is not None}
    at_risk = []
    for pos in portfolio.get("positions", []):
        if pos.get("sec_type") != "OPT":
            continue
        sym = pos.get("symbol")
        info = earn_map.get(sym)
        if not info:
            continue

        exp = pos.get("expiration")
        try:
            if exp and "-" in exp:
                exp_date = datetime.strptime(exp, "%Y-%m-%d").date()
            elif exp:
                exp_date = datetime.strptime(exp, "%Y%m%d").date()
            else:
                continue
        except Exception:
            continue

        dte = (exp_date - date.today()).days
        if 0 <= info["days_until"] <= dte:
            at_risk.append({
                "symbol": sym,
                "strike": pos.get("strike"),
                "right": pos.get("right"),
                "expiration": exp_date.isoformat(),
                "dte": dte,
                "earnings_date": info["next_earnings_date"],
                "earnings_days_until": info["days_until"],
                "position": pos.get("position"),
            })
    return at_risk


def main() -> int:
    parser = argparse.ArgumentParser(description="Earnings calendar lookup")
    parser.add_argument("symbols", nargs="*", help="list of tickers")
    parser.add_argument("--days", type=int, default=30, help="next N days (default 30)")
    parser.add_argument("--portfolio-file", help="path to portfolio JSON file")
    parser.add_argument("--output", help="output file path (default stdout)")
    args = parser.parse_args()

    symbols = set(args.symbols)

    portfolio = None
    if args.portfolio_file:
        try:
            with open(args.portfolio_file, encoding="utf-8") as f:
                portfolio = json.load(f)
            for pos in portfolio.get("positions", []):
                if pos.get("symbol"):
                    symbols.add(pos["symbol"])
        except Exception as e:
            log(f"⚠️  Could not read {args.portfolio_file}: {e}")

    if not symbols:
        log("❌ No symbols to query")
        return 1

    log(f"🔄 Querying earnings for {len(symbols)} symbols ({args.days}-day window) ...")
    today = date.today()

    log("  fetching Nasdaq earnings calendar ...")
    nasdaq_map = fetch_nasdaq_range(args.days)
    log(f"  Nasdaq returned {len(nasdaq_map)} upcoming earnings")

    earnings = []
    for sym in sorted(symbols):
        sym_u = sym.upper()
        edate = nasdaq_map.get(sym_u)
        if edate:
            earnings.append({
                "symbol": sym,
                "next_earnings_date": edate.isoformat(),
                "days_until": (edate - today).days,
                "fiscal_period": None,
                "source": "nasdaq",
            })
            continue
        # Fallback: Finnhub
        info = fetch_finnhub_one(sym, args.days)
        if info:
            earnings.append(info)
        else:
            earnings.append({
                "symbol": sym,
                "next_earnings_date": None,
                "days_until": None,
                "fiscal_period": None,
                "source": None,
            })

    risk = at_risk_positions(portfolio, earnings) if portfolio else []

    result = {
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "window_days": args.days,
        "symbols": earnings,
        "at_risk_positions": risk,
    }

    json_str = json.dumps(result, ensure_ascii=False, indent=2)
    if args.output:
        tmp = args.output + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(json_str)
        os.rename(tmp, args.output)
        log(f"📁 Saved to {args.output}")
    else:
        print(json_str)

    found = sum(1 for e in earnings if e["next_earnings_date"])
    log(f"✅ Done: {found}/{len(earnings)} earnings dates found, {len(risk)} at-risk option positions")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/flex_import.py
content: """
IBKR Flex Statement importer.

`ib.fills()` only returns ~2 days of execution history. To work with longer
windows, IBKR provides Flex Queries (Account Management -> Reports ->
Flex Queries) which export trade history as XML or CSV. This script parses
Flex CSVs into a unified, sorted trade list that other toolkit scripts
(pnl_analytics.py, cost_basis.py, wheel_tracker.py) can consume.

Parsing strategy:
  1. Prefer the `ibflex` library if installed (handles XML + edge cases).
  2. Fall back to plain CSV parsing of the common Flex Trade columns:
        Symbol, UnderlyingSymbol, AssetClass, TradeDate, Buy/Sell,
        Quantity, TradePrice, IBCommission, FifoPnlRealized, Description,
        Put/Call, Strike, Expiry

No IBKR network connection is required. All input is local files.

Usage:
    python flex_import.py
    python flex_import.py --flex-dir ~/.ibkr_flex
    python flex_import.py --since 2026-01-01 --symbol MU
    python flex_import.py --output /tmp/trades.json
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys
from datetime import date, datetime
from pathlib import Path

# stderr log helper (no IBKR connection -> we inline it to keep the script
# usable even if ib_client.py is in a slightly different layout, but we
# still import from ib_client for consistency with the rest of the toolkit).
from ib_client import log

try:
    import ibflex  # type: ignore
    _HAS_IBFLEX = True
except ImportError:
    _HAS_IBFLEX = False


def _norm_date(raw: str | None) -> str | None:
    """Accept YYYYMMDD or YYYY-MM-DD; return YYYY-MM-DD or None."""
    if not raw:
        return None
    s = raw.strip()
    if not s:
        return None
    # Strip trailing time component if present
    s = s.split(";")[0].split(" ")[0]
    try:
        return datetime.strptime(s[:8], "%Y%m%d").date().isoformat()
    except ValueError:
        pass
    try:
        return datetime.strptime(s, "%Y-%m-%d").date().isoformat()
    except ValueError:
        return None


def _classify_action(side: str, quantity: float, sec_type: str, right: str | None) -> str:
    """
    Translate (side, qty) into one of:
      BUY_OPEN, BUY_CLOSE, SELL_OPEN, SELL_CLOSE
    For options, OPEN/CLOSE is ambiguous without the open/close indicator;
    Flex sometimes exposes an "Open/CloseIndicator" column we use if found.
    Falls back to the conservative mapping based on direction alone.
    """
    s = (side or "").upper()
    if s in ("BOT", "BUY", "B"):
        return "BUY_OPEN" if quantity >= 0 else "BUY_CLOSE"
    if s in ("SLD", "SELL", "S", "SS"):
        return "SELL_OPEN" if quantity <= 0 else "SELL_CLOSE"
    return "BUY_OPEN" if quantity >= 0 else "SELL_OPEN"


def _safe_float(raw: str | None) -> float | None:
    if raw is None or raw == "":
        return None
    try:
        return float(raw)
    except ValueError:
        return None


def parse_flex_csv(csv_path: Path) -> list[dict]:
    """Parse one Flex CSV file into normalized trade dicts."""
    trades: list[dict] = []
    try:
        with open(csv_path, encoding="utf-8") as f:
            reader = csv.DictReader(f)
            for row in reader:
                sym = (row.get("Symbol") or "").strip()
                und = (row.get("UnderlyingSymbol") or "").strip()
                sec_type = (row.get("AssetClass") or row.get("SecType") or "").upper()
                right = (row.get("Put/Call") or row.get("Right") or "").upper() or None
                strike = _safe_float(row.get("Strike"))
                expiration = _norm_date(row.get("Expiry") or row.get("Expiration"))
                trade_date = _norm_date(row.get("TradeDate") or row.get("Date"))
                qty = _safe_float(row.get("Quantity") or row.get("Qty"))
                price = _safe_float(row.get("TradePrice") or row.get("Price"))
                commission = _safe_float(row.get("IBCommission")) or 0.0
                realized = _safe_float(
                    row.get("FifoPnlRealized")
                    or row.get("RealizedPL")
                    or row.get("Realized")
                )
                side = (row.get("Buy/Sell") or row.get("Side") or "").upper()
                description = row.get("Description") or None

                if not (sym or und) or qty is None or price is None:
                    continue

                # Choose canonical symbol: underlying for options, trade symbol for stock.
                if right or sec_type == "OPT":
                    symbol = und or sym
                    sec_type = "OPT"
                else:
                    symbol = sym or und
                    sec_type = sec_type or "STK"

                trades.append({
                    "date": trade_date,
                    "symbol": symbol,
                    "sec_type": sec_type,
                    "right": right,
                    "strike": strike,
                    "expiration": expiration,
                    "action": _classify_action(side, qty, sec_type, right),
                    "side": side,
                    "quantity": qty,
                    "price": price,
                    "commission": commission,
                    "realized_pnl": realized,
                    "description": description,
                    "source_file": csv_path.name,
                })
    except Exception as e:
        log(f"  skip {csv_path.name}: {e}")
    return trades


def parse_with_ibflex(csv_path: Path) -> list[dict] | None:
    """Use the ibflex library when available (XML format).

    Returns None if the file isn't an ibflex-parseable XML statement;
    falls back to plain CSV in that case.
    """
    if not _HAS_IBFLEX:
        return None
    try:
        statement = ibflex.parser.parse(str(csv_path))
    except Exception:
        return None

    trades: list[dict] = []
    for stmt in getattr(statement, "FlexStatements", []) or []:
        for t in getattr(stmt, "Trades", []) or []:
            symbol = getattr(t, "underlyingSymbol", None) or getattr(t, "symbol", None)
            sec_type = (getattr(t, "assetCategory", "") or "").upper() or "STK"
            right = getattr(t, "putCall", None)
            right = right.value if hasattr(right, "value") else right
            strike = getattr(t, "strike", None)
            expiration = getattr(t, "expiry", None)
            quantity = float(getattr(t, "quantity", 0) or 0)
            price = float(getattr(t, "tradePrice", 0) or 0)
            commission = float(getattr(t, "ibCommission", 0) or 0)
            realized = getattr(t, "fifoPnlRealized", None)
            realized = float(realized) if realized is not None else None
            side = getattr(t, "buySell", None)
            side = side.value if hasattr(side, "value") else (side or "")
            trade_date = getattr(t, "tradeDate", None)
            trade_date = trade_date.isoformat() if hasattr(trade_date, "isoformat") else trade_date

            trades.append({
                "date": trade_date,
                "symbol": symbol,
                "sec_type": "OPT" if right else sec_type,
                "right": right,
                "strike": float(strike) if strike is not None else None,
                "expiration": expiration.isoformat() if hasattr(expiration, "isoformat") else expiration,
                "action": _classify_action(side, quantity, sec_type, right),
                "side": side,
                "quantity": quantity,
                "price": price,
                "commission": commission,
                "realized_pnl": realized,
                "description": getattr(t, "description", None),
                "source_file": csv_path.name,
            })
    return trades


def load_all_trades(flex_dir: Path) -> list[dict]:
    if not flex_dir.exists():
        log(f"WARNING: flex dir does not exist: {flex_dir}")
        return []

    all_trades: list[dict] = []
    file_count = 0
    # Look at both .csv and .xml so ibflex-style XML is picked up
    for path in sorted(list(flex_dir.glob("*.csv")) + list(flex_dir.glob("*.xml"))):
        file_count += 1
        parsed: list[dict] | None = None
        if path.suffix.lower() == ".xml":
            parsed = parse_with_ibflex(path)
            if parsed is None:
                log(f"  {path.name}: ibflex parse failed, skipping XML")
                continue
        else:
            # Try ibflex first (CSV exported from XML can also be parsed by it
            # sometimes), then fall back to CSV.
            parsed = parse_with_ibflex(path) if _HAS_IBFLEX else None
            if parsed is None:
                parsed = parse_flex_csv(path)
        log(f"  {path.name}: {len(parsed)} trades")
        all_trades.extend(parsed)

    if not _HAS_IBFLEX:
        log("INFO: ibflex not installed (pip install ibflex>=0.16); using plain CSV parser")

    log(f"parsed {file_count} file(s); {len(all_trades)} trade(s) total")
    return all_trades


def filter_trades(trades: list[dict], *, since: date | None,
                  symbol: str | None) -> list[dict]:
    out = []
    sym_filter = symbol.upper() if symbol else None
    for t in trades:
        if sym_filter and (t.get("symbol") or "").upper() != sym_filter:
            continue
        if since and t.get("date"):
            try:
                if datetime.strptime(t["date"], "%Y-%m-%d").date() < since:
                    continue
            except ValueError:
                pass
        out.append(t)
    out.sort(key=lambda t: (t.get("date") or "", t.get("symbol") or ""))
    return out


def _write_output(result: dict, output: str | None) -> None:
    json_str = json.dumps(result, ensure_ascii=False, indent=2)
    if output:
        tmp = output + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(json_str)
        os.rename(tmp, output)
        log(f"saved to {output}")
    else:
        print(json_str)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Import IBKR Flex Statement CSVs to a unified trade-history JSON"
    )
    parser.add_argument("--flex-dir", default="~/.ibkr_flex",
                        help="Flex Statement directory (default ~/.ibkr_flex)")
    parser.add_argument("--since", help="Filter to trades on/after this date (YYYY-MM-DD)")
    parser.add_argument("--symbol", help="Filter to a single underlying symbol")
    parser.add_argument("--output", help="Output file path (default stdout)")
    args = parser.parse_args()

    flex_dir = Path(os.path.expanduser(args.flex_dir))

    since_date: date | None = None
    if args.since:
        try:
            since_date = datetime.strptime(args.since, "%Y-%m-%d").date()
        except ValueError:
            log(f"ERROR: --since must be YYYY-MM-DD, got {args.since!r}")
            return 1

    trades = load_all_trades(flex_dir)
    trades = filter_trades(trades, since=since_date, symbol=args.symbol)

    dates = [t["date"] for t in trades if t.get("date")]
    date_range = [min(dates), max(dates)] if dates else [None, None]

    result = {
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "flex_dir": str(flex_dir),
        "ibflex_available": _HAS_IBFLEX,
        "trades_count": len(trades),
        "date_range": date_range,
        "filters": {"since": args.since, "symbol": args.symbol},
        "trades": trades,
    }

    _write_output(result, args.output)
    log(f"done: {len(trades)} trade(s), range {date_range[0]}..{date_range[1]}")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/ib_client.py
content: """
IB Gateway shared connection manager.

All scripts use `ib_connect(client_id_offset)` instead of `IB().connect()` to ensure:
  1. Read-only double safety (readonly=True + Gateway "Read-Only API")
  2. Unique clientId per script (avoid Gateway conflicts)
  3. Configurable host/port/market data type via environment variables
  4. Auto-disconnect on exception

Environment variables (all optional, see .env.example):
  IBKR_HOST              default 127.0.0.1
  IBKR_PORT              default 4001 (IB Gateway live; 7497 for TWS paper)
  IBKR_CLIENT_ID_BASE    default 11; each script adds an offset
  IBKR_MARKET_DATA_TYPE  default 1 (realtime); 3 = delayed (free)

ClientId offsets (script-to-offset mapping):
  market_quote.py        offset 7   → client_id = base + 7
  options_chain.py       offset 8
  portfolio_positions.py offset 9
  options_analyzer.py    offset 10
  options_daily.py       offset 11
  pnl_analytics.py       offset 12
  risk_simulator.py      offset 13
  technical_indicators   offset 14
  wheel_tracker.py       offset 15
  alerts_monitor.py      offset 16
  cost_basis.py          offset 17
  concentration.py       offset 18
  (flex_import.py has no IBKR connection)
"""

import os
import sys
import time
from contextlib import contextmanager

from ib_async import IB

HOST = os.getenv("IBKR_HOST", "127.0.0.1")
PORT = int(os.getenv("IBKR_PORT", "4001"))
DEFAULT_MARKET_DATA_TYPE = int(os.getenv("IBKR_MARKET_DATA_TYPE", "3"))
CLIENT_ID_BASE = int(os.getenv("IBKR_CLIENT_ID_BASE", "11"))
CONNECT_TIMEOUT = 10

# Pacing: same contract + same tick type, ≤6 requests per 2s → min interval 0.35s
HIST_MIN_INTERVAL_SEC = 0.35


def log(msg: str) -> None:
    print(msg, file=sys.stderr, flush=True)


@contextmanager
def ib_connect(
    client_id_offset: int,
    *,
    market_data_type: int = None,
    readonly: bool = True,
    verbose: bool = True,
):
    """Connect to IB Gateway with configurable client_id offset.

    Usage:
        with ib_connect(client_id_offset=7) as ib:
            bars = ib.reqHistoricalData(...)

    Client ID = CLIENT_ID_BASE + client_id_offset (configurable via env var).
    """
    if market_data_type is None:
        market_data_type = DEFAULT_MARKET_DATA_TYPE
    client_id = CLIENT_ID_BASE + client_id_offset

    ib = IB()
    try:
        if verbose:
            log(f"🔄 Connecting to IB Gateway {HOST}:{PORT} "
                f"(clientId={client_id}, readonly={readonly}) ...")
        ib.connect(HOST, PORT, clientId=client_id,
                   readonly=readonly, timeout=CONNECT_TIMEOUT)
        if verbose:
            log(f"✅ Connected (server v{ib.client.serverVersion()})")
        if market_data_type:
            ib.reqMarketDataType(market_data_type)
            if verbose:
                log(f"   marketDataType={market_data_type} "
                    f"(1=realtime, 3=delayed, 4=delayed-frozen)")
        yield ib
    finally:
        if ib.isConnected():
            ib.disconnect()
            if verbose:
                log("👋 Disconnected")


_last_hist_call_ts: float = 0.0


def req_historical_safe(ib: IB, contract, **kwargs):
    """Pacing-aware wrapper around ib.reqHistoricalData.

    kwargs are passed through to ib.reqHistoricalData (endDateTime, durationStr,
    barSizeSetting, whatToShow, useRTH, formatDate, etc.).
    """
    global _last_hist_call_ts
    elapsed = time.monotonic() - _last_hist_call_ts
    if elapsed < HIST_MIN_INTERVAL_SEC:
        time.sleep(HIST_MIN_INTERVAL_SEC - elapsed)
    _last_hist_call_ts = time.monotonic()
    return ib.reqHistoricalData(contract, **kwargs)


def qualify(ib: IB, contract):
    """Thin wrapper around qualifyContracts with a clear error on failure."""
    qualified = ib.qualifyContracts(contract)
    if not qualified:
        raise RuntimeError(
            f"qualifyContracts failed: {getattr(contract, 'symbol', contract)} "
            f"(secType={getattr(contract, 'secType', '?')}, "
            f"exchange={getattr(contract, 'exchange', '?')})"
        )
    return qualified[0]


path: scripts/market_quote.py
content: """
market_quote.py — single-point quote (IBKR).

Usage:
  python market_quote.py AAPL
  python market_quote.py ^GSPC
  python market_quote.py EURUSD
  python market_quote.py 0700.HK
  python market_quote.py ES              # continuous futures contract

Output JSON schema (stdout):
  {
    "symbol":   "AAPL",
    "kind":     "stock" | "index" | "fx" | "future",
    "price":    263.4,
    "currency": "USD",
    "asof":     "2026-04-17 13:30:05",
    "source":   "IBKR via ib_async (delayed)",
    "extra":    {exchange, primaryExchange, conId, contract_name, ...}
  }
"""

import argparse
import json
import sys
from datetime import datetime

from contracts import resolve
from ib_async import ContFuture, Forex, Index, Stock
from ib_client import ib_connect, log, qualify, req_historical_safe

CLIENT_ID_OFFSET = 7


def _kind_of(contract) -> str:
    if isinstance(contract, Index):
        return "index"
    if isinstance(contract, Forex):
        return "fx"
    if isinstance(contract, ContFuture):
        return "future"
    return "stock"


def _fetch_last_close(ib, contract) -> dict:
    q = qualify(ib, contract)
    what = "MIDPOINT" if isinstance(contract, Forex) else "TRADES"
    bars = req_historical_safe(
        ib, q,
        endDateTime="",
        durationStr="5 D",
        barSizeSetting="1 day",
        whatToShow=what,
        useRTH=True,
        formatDate=1,
    )
    if not bars:
        raise RuntimeError("Historical data returned empty (missing subscription or permissions)")

    latest = bars[-1]
    return {
        "price": round(latest.close, 6),
        "asof_date": str(latest.date),
        "qualified": q,
        "last_bar": {
            "open":   round(latest.open, 6),
            "high":   round(latest.high, 6),
            "low":    round(latest.low, 6),
            "close":  round(latest.close, 6),
            "volume": int(latest.volume) if latest.volume else 0,
        },
    }


def quote(ib, symbol: str) -> dict:
    contract = resolve(symbol)
    kind = _kind_of(contract)
    result = _fetch_last_close(ib, contract)
    q = result["qualified"]

    return {
        "symbol":   symbol,
        "kind":     kind,
        "price":    result["price"],
        "currency": q.currency,
        "asof":     result["asof_date"],
        "source":   "IBKR via ib_async (delayed)",
        "extra": {
            "ibkr_symbol":     q.symbol,
            "secType":         q.secType,
            "exchange":        q.exchange,
            "primaryExchange": getattr(q, "primaryExchange", ""),
            "conId":           q.conId,
            "last_bar":        result["last_bar"],
        },
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="IBKR single-point quote")
    parser.add_argument("symbol", nargs="+", help="ticker / index / FX pair / future key (one or more)")
    args = parser.parse_args()

    with ib_connect(client_id_offset=CLIENT_ID_OFFSET, verbose=False) as ib:
        results = []
        for sym in args.symbol:
            try:
                results.append(quote(ib, sym))
            except Exception as e:
                results.append({
                    "symbol": sym,
                    "error": f"{type(e).__name__}: {e}",
                    "source": "IBKR via ib_async",
                })

    output = results[0] if len(results) == 1 else results
    print(json.dumps(output, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/options_chain.py
content: """
Option chain fetcher — fetches the option chain near the underlying's ATM, with Greeks.

Output JSON: symbol, underlying_price, chain[{expiration, dte, calls[...], puts[...]}]
Each call/put: strike, bid, ask, last, volume, open_interest, iv, delta, gamma, vega, theta

Usage:
  python options_chain.py SPY
  python options_chain.py AAPL --strikes 15 --dte-min 14 --dte-max 90
  python options_chain.py SPY --max-expirations 5 --output /tmp/spy_chain.json
"""

import argparse
import json
import math
import os
import sys
import time
from datetime import datetime, date

from ib_async import Option

from contracts import resolve
from ib_client import ib_connect, log, qualify

CLIENT_ID_OFFSET = 8
BATCH_SIZE = 50
BATCH_SLEEP = 2.0


def _parse_expiration(exp_str: str) -> date:
    return datetime.strptime(exp_str, "%Y%m%d").date()


def _pick_expirations(
    all_expirations: list[str],
    dte_min: int,
    dte_max: int,
    max_count: int,
) -> list[str]:
    today = date.today()
    candidates = []
    for exp_str in sorted(all_expirations):
        exp_date = _parse_expiration(exp_str)
        dte = (exp_date - today).days
        if dte_min <= dte <= dte_max:
            candidates.append((dte, exp_str))
    if not candidates:
        return []
    if len(candidates) <= max_count:
        return [e for _, e in candidates]
    if max_count == 1:
        return [candidates[len(candidates) // 2][1]]
    step = (len(candidates) - 1) / (max_count - 1)
    indices = [round(i * step) for i in range(max_count)]
    return [candidates[i][1] for i in sorted(set(indices))]


def _pick_strikes(
    all_strikes: list[float],
    atm_price: float,
    num_strikes: int,
) -> list[float]:
    if not all_strikes:
        return []
    sorted_strikes = sorted(all_strikes)
    atm_idx = min(range(len(sorted_strikes)),
                  key=lambda i: abs(sorted_strikes[i] - atm_price))
    lo = max(0, atm_idx - num_strikes)
    hi = min(len(sorted_strikes), atm_idx + num_strikes + 1)
    return sorted_strikes[lo:hi]


def _batch_qualify_and_tick(ib, contracts):
    all_valid = []
    for i in range(0, len(contracts), BATCH_SIZE):
        batch = contracts[i:i + BATCH_SIZE]
        qualified = ib.qualifyContracts(*batch)
        valid = [c for c in qualified if c is not None and getattr(c, 'conId', 0) > 0]
        all_valid.extend(valid)
        if i + BATCH_SIZE < len(contracts):
            time.sleep(0.5)
    log(f"  qualify done: {len(all_valid)}/{len(contracts)} valid")
    if not all_valid:
        return {}

    for c in all_valid:
        ib.reqMktData(c, genericTickList="106", snapshot=False)

    max_wait = max(10, len(all_valid) * 0.5)
    waited = 0.0
    while waited < max_wait:
        ib.sleep(2)
        waited += 2
        got = sum(1 for c in all_valid
                  if (t := ib.ticker(c)) and t.modelGreeks is not None)
        if got >= len(all_valid):
            break
    log(f"  Greeks: {got}/{len(all_valid)} (waited {waited:.0f}s)")

    results = {}
    for c in all_valid:
        ticker = ib.ticker(c)
        if ticker is None:
            continue
        key = (c.lastTradeDateOrContractMonth, c.strike, c.right)
        greeks = ticker.modelGreeks
        results[key] = {
            "strike": c.strike,
            "bid": _safe_price(ticker.bid),
            "ask": _safe_price(ticker.ask),
            "last": _safe_price(ticker.last) or _safe_price(ticker.close),
            "volume": _safe_int(ticker.volume),
            "open_interest": None,
            "iv": round(greeks.impliedVol, 4) if greeks and greeks.impliedVol is not None else None,
            "delta": round(greeks.delta, 4) if greeks and greeks.delta is not None else None,
            "gamma": round(greeks.gamma, 6) if greeks and greeks.gamma is not None else None,
            "vega": round(greeks.vega, 4) if greeks and greeks.vega is not None else None,
            "theta": round(greeks.theta, 4) if greeks and greeks.theta is not None else None,
        }

    for c in all_valid:
        ib.cancelMktData(c)

    return results


def _safe_price(val) -> float | None:
    if val is None:
        return None
    if isinstance(val, float) and math.isnan(val):
        return None
    if val <= 0:
        return None
    return round(float(val), 2)


def _safe_int(val) -> int | None:
    if val is None or (isinstance(val, float) and math.isnan(val)):
        return None
    return int(val)


def fetch_chain(
    ib,
    symbol: str,
    num_strikes: int = 10,
    dte_min: int = 7,
    dte_max: int = 60,
    max_expirations: int = 3,
) -> dict:
    contract = resolve(symbol)
    q = qualify(ib, contract)
    log(f"  underlying conId={q.conId}, secType={q.secType}")

    tickers = ib.reqTickers(q)
    ib.sleep(2)
    t = tickers[0] if tickers else None
    if t is None:
        raise RuntimeError(f"Could not fetch current price for {symbol}")
    underlying_price = None
    for val in (t.last, t.close, t.midpoint()):
        if val is not None and not math.isnan(val) and val > 0:
            underlying_price = round(val, 2)
            break
    if underlying_price is None:
        raise RuntimeError(f"Could not fetch current price for {symbol}")
    log(f"  underlying price: {underlying_price}")

    chains = ib.reqSecDefOptParams(
        q.symbol, "", q.secType, q.conId,
    )
    if not chains:
        raise RuntimeError(f"reqSecDefOptParams returned empty: {symbol}")

    chain = max(chains, key=lambda c: len(c.strikes))
    log(f"  option chain: exchange={chain.exchange}, "
        f"{len(chain.expirations)} expirations, {len(chain.strikes)} strikes")

    expirations = _pick_expirations(
        list(chain.expirations), dte_min, dte_max, max_expirations,
    )
    if not expirations:
        raise RuntimeError(
            f"No expirations within DTE [{dte_min}, {dte_max}] "
            f"(available: {sorted(chain.expirations)[:5]}...)"
        )
    log(f"  selected expirations: {expirations}")

    strikes = _pick_strikes(list(chain.strikes), underlying_price, num_strikes)
    log(f"  selected strikes: {len(strikes)} "
        f"({strikes[0]:.1f} ~ {strikes[-1]:.1f})")

    all_contracts = []
    for exp in expirations:
        for strike in strikes:
            for right in ("C", "P"):
                all_contracts.append(Option(
                    q.symbol, exp, strike, right,
                    "SMART", currency=q.currency,
                    tradingClass=chain.tradingClass,
                ))
    log(f"  built {len(all_contracts)} option contracts, fetching in batches...")

    tick_data = _batch_qualify_and_tick(ib, all_contracts)
    log(f"  got data for {len(tick_data)} contracts")

    today = date.today()
    chain_output = []
    for exp in expirations:
        exp_date = _parse_expiration(exp)
        dte = (exp_date - today).days
        calls = []
        puts = []
        for strike in strikes:
            call_key = (exp, strike, "C")
            put_key = (exp, strike, "P")
            if call_key in tick_data:
                calls.append(tick_data[call_key])
            if put_key in tick_data:
                puts.append(tick_data[put_key])
        chain_output.append({
            "expiration": exp_date.isoformat(),
            "dte": dte,
            "calls": calls,
            "puts": puts,
        })

    return {
        "symbol": symbol,
        "underlying_price": underlying_price,
        "data_type": "realtime",
        "fetched_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "chain": chain_output,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="IBKR Options Chain Fetcher")
    parser.add_argument("symbol", help="underlying ticker (e.g. SPY, AAPL, QQQ)")
    parser.add_argument("--strikes", type=int, default=10,
                        help="number of strikes on each side of ATM (default: 10)")
    parser.add_argument("--dte-min", type=int, default=7,
                        help="minimum days to expiration (default: 7)")
    parser.add_argument("--dte-max", type=int, default=60,
                        help="maximum days to expiration (default: 60)")
    parser.add_argument("--max-expirations", type=int, default=3,
                        help="maximum number of expirations to pick (default: 3)")
    parser.add_argument("--output", help="output file path (default stdout)")
    args = parser.parse_args()

    log(f"🔄 Fetching {args.symbol} option chain...")

    try:
        with ib_connect(client_id_offset=CLIENT_ID_OFFSET) as ib:
            result = fetch_chain(
                ib,
                args.symbol,
                num_strikes=args.strikes,
                dte_min=args.dte_min,
                dte_max=args.dte_max,
                max_expirations=args.max_expirations,
            )
    except Exception as e:
        log(f"❌ Failed: {e}")
        return 1

    json_str = json.dumps(result, ensure_ascii=False, indent=2)
    if args.output:
        tmp = args.output + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(json_str)
        os.rename(tmp, args.output)
        log(f"📁 Saved to {args.output}")
    else:
        print(json_str)

    total_opts = sum(len(e["calls"]) + len(e["puts"]) for e in result["chain"])
    log(f"✅ Done: {len(result['chain'])} expirations, {total_opts} contracts")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/options_daily.py
content: """
Options daily report — after each market open, analyzes the state of current option positions and emits concrete action suggestions.

Output has three sections:
  1. Expiry warnings (DTE≤7): close vs. roll comparison
  2. IV environment for held symbols: current IV vs. 20-day historical vol — rich/cheap
  3. Concrete action suggestions: which contract, which price, max profit/loss/win-rate

Usage:
  python options_daily.py
  python options_daily.py --symbols ARM MRVL ORCL   # extra symbols to watch
  python options_daily.py --output /tmp/daily.json
"""

import argparse
import json
import math
import os
import sys
from datetime import datetime, date

from contracts import resolve
from ib_client import ib_connect, log, qualify, req_historical_safe
from options_chain import fetch_chain
from options_analyzer import compute_historical_vol, assess_iv_environment, STRATEGIES, SELECTION_MATRIX

CLIENT_ID_OFFSET = 11


# ── Portfolio reader (inlined to avoid a second connection) ─────────────

def _fetch_portfolio(ib) -> dict:
    import time
    ib.reqPositions()
    time.sleep(1)
    positions = ib.positions()

    portfolio_items = ib.portfolio()
    pnl_map = {}
    for item in portfolio_items:
        pnl_map[(item.contract.conId, item.account)] = {
            "market_price": item.marketPrice,
            "unrealized_pnl": item.unrealizedPNL,
            "average_cost": item.averageCost,
        }

    option_contracts = [p.contract for p in positions if p.contract.secType == "OPT"]
    greeks_map = {}
    if option_contracts:
        for c in option_contracts:
            ib.reqMktData(c, genericTickList="106", snapshot=False)
        ib.sleep(5)
        for c in option_contracts:
            t = ib.ticker(c)
            if t and t.modelGreeks:
                g = t.modelGreeks
                greeks_map[c.conId] = {
                    "iv": g.impliedVol,
                    "delta": g.delta,
                    "theta": g.theta,
                }
        for c in option_contracts:
            ib.cancelMktData(c)

    result = []
    for pos in positions:
        c = pos.contract
        pnl = pnl_map.get((c.conId, pos.account), {})
        entry = {
            "symbol": c.symbol,
            "sec_type": c.secType,
            "position": pos.position,
            "avg_cost": pos.avgCost,
            "market_price": pnl.get("market_price"),
            "unrealized_pnl": pnl.get("unrealized_pnl"),
        }
        if c.secType == "OPT":
            exp = c.lastTradeDateOrContractMonth
            exp_date = datetime.strptime(exp, "%Y%m%d").date()
            entry.update({
                "expiration": exp_date.isoformat(),
                "dte": (exp_date - date.today()).days,
                "strike": c.strike,
                "right": c.right,
                "multiplier": float(c.multiplier) if c.multiplier else 100.0,
                "greeks": greeks_map.get(c.conId),
            })
        result.append(entry)
    return result


# ── Expiry warning analysis ─────────────────────────────────────────────

def _expiry_warning(pos: dict, chain_data: dict) -> dict:
    dte = pos["dte"]
    strike = pos["strike"]
    right = pos["right"]
    qty = abs(pos["position"])
    mult = pos.get("multiplier", 100.0)
    is_short = pos["position"] < 0
    market_price = pos.get("market_price") or 0
    upnl = pos.get("unrealized_pnl") or 0

    # short: close_cost = cost to buy back (positive = cash you must pay)
    # long:  close_cost = sale proceeds (positive = cash you receive)
    close_cost = round(market_price * qty * mult, 2) if market_price else None

    roll_target = None
    for exp_group in chain_data.get("chain", []):
        if exp_group["dte"] >= 21:
            side = "calls" if right == "C" else "puts"
            candidates = [o for o in exp_group.get(side, [])
                          if abs(o["strike"] - strike) < 0.01 and o.get("bid")]
            if candidates:
                opt = candidates[0]
                # roll_net: net credit when rolling a short position (far-month bid - near-month buyback ask).
                # Positive = net premium received.
                roll_net = round((opt["bid"] - market_price) * qty * mult, 2) if is_short else None
                roll_target = {
                    "expiration": exp_group["expiration"],
                    "dte": exp_group["dte"],
                    "strike": opt["strike"],
                    "bid": opt.get("bid"),
                    "ask": opt.get("ask"),
                    "roll_net_credit": roll_net,
                }
                break

    urgency = "expires today" if dte <= 0 else f"DTE {dte} days"

    return {
        "urgency": urgency,
        "is_short": is_short,
        "current_price": market_price,
        "close_cost": close_cost,
        "unrealized_pnl": upnl,
        "roll_target": roll_target,
    }


# ── Concrete contract recommendations ───────────────────────────────────

def _recommend_contracts(symbol: str, chain_data: dict, iv_env: dict,
                         has_stock: bool) -> list[dict]:
    """Pick best contracts given the IV environment and produce concrete buy/sell suggestions."""
    bias = iv_env.get("iv_bias", "neutral")
    underlying = chain_data["underlying_price"]

    # IV high → favor selling; IV low → favor buying; neutral → favor spreads
    if bias == "high":
        strategies = (["covered_call"] if has_stock else []) + ["cash_secured_put", "bull_put_spread", "bear_call_spread"]
    elif bias == "low":
        strategies = ["long_call", "long_put", "long_straddle"]
    else:
        strategies = (["covered_call"] if has_stock else []) + ["bull_call_spread", "bear_put_spread"]

    recs = []
    for exp_group in chain_data.get("chain", []):
        if exp_group["dte"] < 14:
            continue

        side_data = {"C": exp_group.get("calls", []), "P": exp_group.get("puts", [])}

        for strat_key in strategies[:3]:
            strat = STRATEGIES.get(strat_key)
            if not strat or len(strat["legs"]) != 1:
                continue

            right = strat["legs"][0]["right"]
            action = strat["legs"][0]["action"]
            offset = strat["legs"][0].get("strike_offset", 0)

            # find candidate contracts near ATM
            all_opts = side_data[right]
            if not all_opts:
                continue
            atm = min(all_opts, key=lambda o: abs(o["strike"] - underlying))
            atm_idx = all_opts.index(atm)

            # offset is measured in strike steps
            target_idx = max(0, min(len(all_opts) - 1, atm_idx + offset))
            opt = all_opts[target_idx]

            bid = opt.get("bid")
            ask = opt.get("ask")
            delta = opt.get("delta")
            iv = opt.get("iv")
            if not bid or not ask:
                continue

            mid = round((bid + ask) / 2, 2)
            # Probability-of-profit approximation: seller = 1 - |delta|, buyer = |delta|
            if delta is not None:
                pop = round((1 - abs(delta)) * 100, 1) if action == "SELL" else round(abs(delta) * 100, 1)
            else:
                pop = None

            max_profit = round(mid * 100, 2) if action == "SELL" else None
            max_loss = round(-opt["strike"] * 100, 2) if action == "SELL" and right == "P" else (
                round(-mid * 100, 2) if action == "BUY" else None
            )

            recs.append({
                "strategy": strat["name"],
                "action": "SELL" if action == "SELL" else "BUY",
                "right": "Call" if right == "C" else "Put",
                "strike": opt["strike"],
                "expiration": exp_group["expiration"],
                "dte": exp_group["dte"],
                "bid": bid,
                "ask": ask,
                "mid": mid,
                "iv": round(iv, 4) if iv else None,
                "delta": round(delta, 3) if delta else None,
                "probability_of_profit": pop,
                "max_profit_per_contract": max_profit,
                "max_loss_per_contract": max_loss,
                "description": strat["description"],
            })

        if recs:
            break  # only take the nearest suitable expiration

    # sort by probability-of-profit
    recs.sort(key=lambda r: r.get("probability_of_profit") or 0, reverse=True)
    return recs[:3]


# ── Main flow ───────────────────────────────────────────────────────────

def run_daily(ib, extra_symbols: list[str]) -> dict:
    log("  reading positions...")
    positions = _fetch_portfolio(ib)

    opt_positions = [p for p in positions if p["sec_type"] == "OPT"]
    stk_positions = {p["symbol"] for p in positions if p["sec_type"] == "STK"}

    # symbols to analyze: option underlyings + stock holdings + user-supplied extras
    symbols_to_analyze = list({p["symbol"] for p in opt_positions} | set(extra_symbols))

    warnings = []
    iv_analysis = {}
    recommendations = {}

    for symbol in symbols_to_analyze:
        log(f"  analyzing {symbol}...")
        try:
            chain = fetch_chain(ib, symbol, num_strikes=8, dte_min=7, dte_max=60, max_expirations=3)
        except Exception as e:
            log(f"    chain fetch failed: {e}")
            continue

        # IV environment
        hist_vol = compute_historical_vol(ib, symbol, days=20)
        iv_env = assess_iv_environment(chain, hist_vol)
        iv_analysis[symbol] = {
            "underlying_price": chain["underlying_price"],
            "current_iv": iv_env.get("current_iv"),
            "hist_vol_20d": hist_vol,
            "iv_to_hv_ratio": iv_env.get("iv_to_hv_ratio"),
            "assessment": iv_env.get("assessment"),
            "iv_bias": iv_env.get("iv_bias"),
        }

        # expiry warnings
        sym_opts = [p for p in opt_positions if p["symbol"] == symbol]
        for pos in sym_opts:
            if pos["dte"] <= 7:
                w = _expiry_warning(pos, chain)
                w.update({
                    "symbol": symbol,
                    "strike": pos["strike"],
                    "right": pos["right"],
                    "expiration": pos["expiration"],
                    "dte": pos["dte"],
                    "position": pos["position"],
                })
                warnings.append(w)

        # concrete contract recommendations
        has_stock = symbol in stk_positions
        recs = _recommend_contracts(symbol, chain, iv_env, has_stock)
        if recs:
            recommendations[symbol] = recs

    return {
        "date": date.today().isoformat(),
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "expiry_warnings": sorted(warnings, key=lambda w: w["dte"]),
        "iv_analysis": iv_analysis,
        "recommendations": recommendations,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Options daily report")
    parser.add_argument("--symbols", nargs="*", default=[],
                        help="extra symbols to watch (option underlyings are auto-included)")
    parser.add_argument("--output", help="output file path (default stdout)")
    args = parser.parse_args()

    log("🔄 Generating options daily report...")

    try:
        with ib_connect(client_id_offset=CLIENT_ID_OFFSET) as ib:
            result = run_daily(ib, args.symbols)
    except Exception as e:
        log(f"❌ Failed: {e}")
        return 1

    json_str = json.dumps(result, ensure_ascii=False, indent=2)
    if args.output:
        tmp = args.output + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(json_str)
        os.rename(tmp, args.output)
        log(f"📁 Saved to {args.output}")
    else:
        print(json_str)

    n_warn = len(result["expiry_warnings"])
    n_rec = sum(len(v) for v in result["recommendations"].values())
    log(f"✅ Done: {n_warn} expiry warnings, {n_rec} contract recommendations")
    return 0


if __name__ == "__main__":
    sys.exit(main())


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