diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..407e6df
Binary files /dev/null and b/.DS_Store differ
diff --git a/.gitignore b/.gitignore
index 01ce690..e69de29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +0,0 @@
-.venv/
-__pycache__/
-*.pyc
-
-.env
-
-bot_state.json
-day_pnl.json
-recent_sells.json
-trades.jsonl
-trading_bot.log
-
-*.log
diff --git a/AGENTS.md b/AGENTS.md
index c2fec3f..d74c4af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,9 @@ Interactive Brokers 股票自动交易机器人(Python + ib_insync),多策
| `bars.py` | `BarManager`:每个合约一条 `keepUpToDate=True` 实时K线订阅,多策略共享;`to_completed_df` 丢弃未收盘bar;`is_market_active` 通过最新bar时效判断开闭市 |
| `state.py` | `PositionTracker`:持仓归属(哪个策略拥有哪笔仓位),持久化到 `bot_state.json`;启动/重连时 `reconcile()` 对账;每笔买卖追加写入 `trades.jsonl` 流水账本(首次运行以当前持仓为 seed) |
| `daily_report.py` / `report.sh` | 当日成交明细+盈亏报告(FIFO,数据源 `trades.jsonl`);用法 `./report.sh [YYYY-MM-DD]` |
+| `analytics.py` | 全历史往返交易分析引擎(纯离线,不连 IB):FIFO 配对成 `RoundTrip` → 胜率/期望值/盈亏比/profit factor/最大回撤/**保本胜率**,并按策略、标的、入场信号、出场原因、时段归因。`--json` 可供程序消费;`dashboard.py` 依赖它 |
+| `dashboard.py` / `dashboard.sh` | 把 `analytics` 结果渲染成自包含 HTML 看板(无 CDN、无外部资源)。图表为手写内联 SVG + 原生 JS;数据以 JSON 内嵌,筛选在浏览器端重算(`summarize()` 是 `analytics.summarize` 的 JS 镜像,**改动统计口径时两处都要改**) |
+| `test_offline.py` | 离线单元测试(不连 IB、不下单):账本字段、FIFO 配对、统计口径、指标数学。改策略后必跑 `.venv/bin/python -m unittest test_offline` |
| `orders.py` | `execute_market_order`:下单+等待成交(30s超时撤单)+防重复单(`has_open_order`,按 orderRef 匹配) |
| `strategies/` | `ma_cross`(SMA20/50+ADX)、`short_term`(EMA5/10+VWAP+max_hold)、`mean_reversion`(RSI/布林/急跌抄底)、`forex`(默认关闭) |
| `main.py` | 主循环60s;disconnectedEvent 只注册一次且有并发/关机防护;重连后 `bar_manager.reset()` + 重新 `on_start` |
@@ -38,7 +41,8 @@ Interactive Brokers 股票自动交易机器人(Python + ib_insync),多策
8. **MeanRev 趋势过滤**:仅在 `close > SMA(trend_ma_period=50)` 时允许抄底,避免下跌趋势中接飞刀。
9. **信号只用已收盘K线**:最后一根 forming bar 必须丢弃(时间戳比较法)。
10. **休市禁交易**:最新bar年龄 > `bar_seconds*5` 视为休市,跳过全部信号。收盘后约5分钟内 bar 仍"新鲜",此窗口的市价单会隔夜排队——收市停机需提前(参考 15:59 EOD 停止的做法)。
-11. **K线流量**:禁止每轮全量拉历史数据(旧版 5 小时 845MB);用 keepUpToDate 订阅(约 3.5MB/5小时)。`formatDate=2`(epoch,时区无歧义)。
+11. **账本记录归因字段(2026-08-12 起)**:`trades.jsonl` 每条记录附带 `reason`(买入=入场信号名,卖出=出场原因)与 `commission`(IB 实际佣金,`orders.trade_commission` 尽力获取,取不到则不写该字段)。出场原因取值:`SignalExit`/`SoftStop`/`HardStop`/`HardStopDuringCancel`/`HardStopDuringTrail`/`HardStopOffline`/`MaxHold`,MeanRev 另有 `RSI`/`Bollinger`/`Recovery`。**新增出场分支时必须传 reason**,否则归因图出现 `(none)` 桶。字段缺失即视为未知(不是 0),旧账本仍可解析。
+12. **K线流量**:禁止每轮全量拉历史数据(旧版 5 小时 845MB);用 keepUpToDate 订阅(约 3.5MB/5小时)。`formatDate=2`(epoch,时区无歧义)。
## ⚠️ 已修复的 bug(勿重新引入)
@@ -78,6 +82,10 @@ Interactive Brokers 股票自动交易机器人(Python + ib_insync),多策
./restart_bot.sh # 重启 bot(杀旧进程 + nohup 启动)
tail -f trading_bot.log # 运行日志
cat bot_state.json # 当前策略持仓归属与成本
+./report.sh [YYYY-MM-DD] # 单日成交明细
+.venv/bin/python analytics.py # 全历史往返统计(含保本胜率)
+./dashboard.sh # 生成并打开 HTML 复盘看板
+.venv/bin/python -m unittest test_offline # 离线测试
```
一次性定时任务示例(cron):`close_legacy_positions.sh`(平仓+自动启动bot)、`stop_bot_eod.sh`(15:59 收市前停机)。
diff --git a/README.md b/README.md
index db8bb26..2fb87ab 100644
--- a/README.md
+++ b/README.md
@@ -103,8 +103,28 @@ tail -f trading_bot.log
# 3. 查看当天成交明细与盈亏(可指定日期)
./report.sh [YYYY-MM-DD]
+
+# 4. 复盘:往返交易统计(胜率/期望值/盈亏比/保本胜率,按策略/标的/信号归因)
+.venv/bin/python analytics.py
+.venv/bin/python analytics.py --since 2026-08-01
+
+# 5. 可视化看板(单文件 HTML,含权益曲线、收益分布、完整历史表,可交互筛选)
+./dashboard.sh
+
+# 6. 离线测试(不连 IB、不下单,改动策略后必跑)
+.venv/bin/python -m unittest test_offline -v
```
+### 复盘工具说明
+
+| 工具 | 用途 |
+|------|------|
+| `daily_report.py` / `report.sh` | **单日**成交明细与盈亏(原有工具,中文文本输出) |
+| `analytics.py` | **全历史**往返交易分析:FIFO 配对 → 胜率、期望值、盈亏比、profit factor、最大回撤、以及按策略/标的/入场信号/出场原因/时段的归因。`--json` 输出可供程序消费 |
+| `dashboard.py` / `dashboard.sh` | 把上述分析渲染成自包含 HTML 看板(权益曲线+回撤带、每日盈亏、四张归因图、收益分布直方图、持仓时长散点、完整交易历史表)。支持策略/标的/时间范围交互筛选,深浅色自适应,无 CDN 依赖 |
+
+`analytics.py` 输出中最关键的一列是 **保本胜率(breakeven win rate)**:按该策略自己实现的平均盈利/平均亏损计算,需要多高的胜率才能不亏。实际胜率低于它,说明这套参数的风险收益结构本身是负期望的,调信号过滤器不会解决问题。
+
启动时如为实盘模式,日志会有醒目的 `LIVE TRADING MODE` 警示。
## ⚠️ 从旧版本迁移(重要)
diff --git a/__pycache__/analytics.cpython-312.pyc b/__pycache__/analytics.cpython-312.pyc
new file mode 100644
index 0000000..de3547b
Binary files /dev/null and b/__pycache__/analytics.cpython-312.pyc differ
diff --git a/__pycache__/analytics.cpython-314.pyc b/__pycache__/analytics.cpython-314.pyc
new file mode 100644
index 0000000..57404fd
Binary files /dev/null and b/__pycache__/analytics.cpython-314.pyc differ
diff --git a/__pycache__/bars.cpython-312.pyc b/__pycache__/bars.cpython-312.pyc
new file mode 100644
index 0000000..eb2c32b
Binary files /dev/null and b/__pycache__/bars.cpython-312.pyc differ
diff --git a/__pycache__/bars.cpython-314.pyc b/__pycache__/bars.cpython-314.pyc
new file mode 100644
index 0000000..5b4fc78
Binary files /dev/null and b/__pycache__/bars.cpython-314.pyc differ
diff --git a/__pycache__/close_legacy_positions.cpython-314.pyc b/__pycache__/close_legacy_positions.cpython-314.pyc
new file mode 100644
index 0000000..6d0af79
Binary files /dev/null and b/__pycache__/close_legacy_positions.cpython-314.pyc differ
diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc
new file mode 100644
index 0000000..4a12f86
Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ
diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc
new file mode 100644
index 0000000..4962912
Binary files /dev/null and b/__pycache__/config.cpython-314.pyc differ
diff --git a/__pycache__/connection.cpython-314.pyc b/__pycache__/connection.cpython-314.pyc
new file mode 100644
index 0000000..3215dce
Binary files /dev/null and b/__pycache__/connection.cpython-314.pyc differ
diff --git a/__pycache__/daily_report.cpython-314.pyc b/__pycache__/daily_report.cpython-314.pyc
new file mode 100644
index 0000000..104d07c
Binary files /dev/null and b/__pycache__/daily_report.cpython-314.pyc differ
diff --git a/__pycache__/dashboard.cpython-314.pyc b/__pycache__/dashboard.cpython-314.pyc
new file mode 100644
index 0000000..48ad389
Binary files /dev/null and b/__pycache__/dashboard.cpython-314.pyc differ
diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc
new file mode 100644
index 0000000..f3c3d77
Binary files /dev/null and b/__pycache__/main.cpython-314.pyc differ
diff --git a/__pycache__/orders.cpython-312.pyc b/__pycache__/orders.cpython-312.pyc
new file mode 100644
index 0000000..0d4bd4a
Binary files /dev/null and b/__pycache__/orders.cpython-312.pyc differ
diff --git a/__pycache__/orders.cpython-314.pyc b/__pycache__/orders.cpython-314.pyc
new file mode 100644
index 0000000..dd44c30
Binary files /dev/null and b/__pycache__/orders.cpython-314.pyc differ
diff --git a/__pycache__/state.cpython-312.pyc b/__pycache__/state.cpython-312.pyc
new file mode 100644
index 0000000..39b6655
Binary files /dev/null and b/__pycache__/state.cpython-312.pyc differ
diff --git a/__pycache__/state.cpython-314.pyc b/__pycache__/state.cpython-314.pyc
new file mode 100644
index 0000000..b93a2ee
Binary files /dev/null and b/__pycache__/state.cpython-314.pyc differ
diff --git a/__pycache__/test_offline.cpython-312.pyc b/__pycache__/test_offline.cpython-312.pyc
new file mode 100644
index 0000000..a1d650f
Binary files /dev/null and b/__pycache__/test_offline.cpython-312.pyc differ
diff --git a/__pycache__/test_offline.cpython-314.pyc b/__pycache__/test_offline.cpython-314.pyc
new file mode 100644
index 0000000..bcdb015
Binary files /dev/null and b/__pycache__/test_offline.cpython-314.pyc differ
diff --git a/analytics.py b/analytics.py
new file mode 100644
index 0000000..85b293e
--- /dev/null
+++ b/analytics.py
@@ -0,0 +1,445 @@
+#!/usr/bin/env python
+"""Round-trip trade analytics over the append-only ledger (trades.jsonl).
+
+Reconstructs closed round trips by FIFO-matching sells against buys per
+(strategy, symbol), then derives the performance statistics that actually drive
+parameter decisions: expectancy, profit factor, win rate, drawdown, and
+attribution by strategy / symbol / entry signal / exit reason / hour of day.
+
+Pure and offline: reads only the ledger, never connects to IB. Used by
+dashboard.py (HTML report) and usable directly:
+
+ .venv/bin/python analytics.py # whole ledger
+ .venv/bin/python analytics.py --since 2026-08-01
+ .venv/bin/python analytics.py --json # machine-readable dump
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+from collections import defaultdict, deque
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Callable, Iterable, Optional
+
+LEDGER = Path(os.environ.get("TRADES_LEDGER", Path(__file__).resolve().parent / "trades.jsonl"))
+
+STRATEGY_SHORT = {
+ "MAStockStrategy": "MAStock",
+ "ShortTermMAVWAPStrategy": "ShortTerm",
+ "MeanReversionStrategy": "MeanRev",
+ "ForexMAStrategy": "Forex",
+}
+
+BUY_TYPES = ("seed", "buy")
+SELL_TYPES = ("sell", "sell_external")
+
+
+def short(name: str) -> str:
+ return STRATEGY_SHORT.get(name, name[:12])
+
+
+# --------------------------------------------------------------------------- #
+# round-trip reconstruction
+# --------------------------------------------------------------------------- #
+
+
+@dataclass
+class RoundTrip:
+ """One closed position slice: a sell matched against an earlier buy lot."""
+
+ strategy: str
+ symbol: str
+ qty: float
+ entry_ts: Optional[str]
+ exit_ts: str
+ entry_price: float
+ exit_price: float
+ entry_reason: str
+ exit_reason: str
+ gross_pnl: float
+ commission: float
+ estimated: bool # exit price was estimated (missed external fill)
+ seeded_entry: bool # entry lot predates the ledger (basis approximate)
+
+ @property
+ def pnl(self) -> float:
+ """Net realized P&L after commissions."""
+ return self.gross_pnl - self.commission
+
+ @property
+ def pnl_pct(self) -> float:
+ """Net return on the entry notional, in percent."""
+ cost = self.entry_price * self.qty
+ return (self.pnl / cost * 100) if cost else 0.0
+
+ @property
+ def hold_minutes(self) -> Optional[float]:
+ if not self.entry_ts:
+ return None
+ try:
+ a = datetime.fromisoformat(self.entry_ts)
+ b = datetime.fromisoformat(self.exit_ts)
+ except ValueError:
+ return None
+ return (b - a).total_seconds() / 60
+
+ @property
+ def won(self) -> bool:
+ return self.pnl > 0
+
+ def as_row(self) -> dict:
+ d = asdict(self)
+ d.update(
+ pnl=self.pnl,
+ pnl_pct=self.pnl_pct,
+ hold_minutes=self.hold_minutes,
+ won=self.won,
+ strategy_short=short(self.strategy),
+ )
+ return d
+
+
+@dataclass
+class OpenLot:
+ """A buy lot still (partly) unmatched at the end of the ledger."""
+
+ strategy: str
+ symbol: str
+ qty: float
+ price: float
+ ts: Optional[str]
+ reason: str
+ seeded: bool
+
+ def as_row(self) -> dict:
+ d = asdict(self)
+ d["strategy_short"] = short(self.strategy)
+ d["cost"] = self.qty * self.price
+ return d
+
+
+def _read_records(path: Path) -> list[dict]:
+ """Parse the JSONL ledger, skipping blank and malformed lines."""
+ records = []
+ for lineno, line in enumerate(path.read_text().splitlines(), 1):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ records.append(json.loads(line))
+ except json.JSONDecodeError:
+ print(f"warning: {path.name}:{lineno} is not valid JSON, skipped")
+ # the ledger is append-only so it is already chronological, but a manual
+ # repair could have disturbed that; sorting keeps FIFO matching honest
+ records.sort(key=lambda r: r.get("ts", ""))
+ return records
+
+
+def build_round_trips(records: Iterable[dict]) -> tuple[list[RoundTrip], list[OpenLot], float]:
+ """FIFO-match sells against buys per (strategy, symbol).
+
+ Returns (closed round trips, still-open lots, quantity sold with no known
+ cost basis). A sell that exhausts the available lots is reported in that
+ last figure rather than being silently priced at zero.
+ """
+ lots: dict[tuple[str, str], deque] = defaultdict(deque)
+ trips: list[RoundTrip] = []
+ unmatched_qty = 0.0
+
+ for r in records:
+ rtype = r.get("type")
+ key = (r.get("strategy", "?"), r.get("symbol", "?"))
+ qty = float(r.get("qty", 0) or 0)
+ price = float(r.get("price", 0) or 0)
+ if qty <= 0:
+ continue
+
+ if rtype in BUY_TYPES:
+ lots[key].append({
+ "qty": qty,
+ "price": price,
+ "ts": r.get("ts"),
+ "reason": r.get("reason", ""),
+ # commission is per-fill; carry it per share so partial
+ # matches take a proportional slice
+ "comm_per_share": (float(r.get("commission", 0) or 0) / qty),
+ "seeded": rtype == "seed",
+ })
+
+ elif rtype in SELL_TYPES:
+ remaining = qty
+ sell_comm_per_share = (float(r.get("commission", 0) or 0) / qty)
+ dq = lots[key]
+ while remaining > 1e-9 and dq:
+ lot = dq[0]
+ take = min(lot["qty"], remaining)
+ trips.append(RoundTrip(
+ strategy=key[0],
+ symbol=key[1],
+ qty=take,
+ entry_ts=lot["ts"],
+ exit_ts=r.get("ts", ""),
+ entry_price=lot["price"],
+ exit_price=price,
+ entry_reason=lot["reason"],
+ exit_reason=r.get("reason", "") or ("external" if rtype == "sell_external" else ""),
+ gross_pnl=(price - lot["price"]) * take,
+ commission=(lot["comm_per_share"] + sell_comm_per_share) * take,
+ estimated=bool(r.get("est")),
+ seeded_entry=lot["seeded"],
+ ))
+ lot["qty"] -= take
+ remaining -= take
+ if lot["qty"] <= 1e-9:
+ dq.popleft()
+ if remaining > 1e-9:
+ unmatched_qty += remaining
+
+ open_lots = [
+ OpenLot(strategy=s, symbol=sym, qty=lot["qty"], price=lot["price"],
+ ts=lot["ts"], reason=lot["reason"], seeded=lot["seeded"])
+ for (s, sym), dq in lots.items()
+ for lot in dq
+ if lot["qty"] > 1e-9
+ ]
+ return trips, open_lots, unmatched_qty
+
+
+# --------------------------------------------------------------------------- #
+# statistics
+# --------------------------------------------------------------------------- #
+
+
+def _mean(xs: list[float]) -> float:
+ return sum(xs) / len(xs) if xs else 0.0
+
+
+def _median(xs: list[float]) -> float:
+ if not xs:
+ return 0.0
+ s = sorted(xs)
+ mid = len(s) // 2
+ return s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2
+
+
+def summarize(trips: list[RoundTrip]) -> dict:
+ """Core performance statistics for a set of round trips."""
+ n = len(trips)
+ if n == 0:
+ return {"n": 0, "pnl": 0.0, "win_rate": 0.0, "expectancy": 0.0,
+ "profit_factor": None, "avg_win": 0.0, "avg_loss": 0.0,
+ "gross_profit": 0.0, "gross_loss": 0.0, "commission": 0.0,
+ "avg_pnl_pct": 0.0, "median_pnl_pct": 0.0,
+ "best": 0.0, "worst": 0.0, "avg_hold_minutes": None,
+ "breakeven_win_rate": None, "payoff_ratio": None}
+
+ pnls = [t.pnl for t in trips]
+ wins = [p for p in pnls if p > 0]
+ losses = [p for p in pnls if p <= 0]
+ gross_profit = sum(wins)
+ gross_loss = -sum(losses) # positive magnitude
+ holds = [h for h in (t.hold_minutes for t in trips) if h is not None]
+
+ avg_win = _mean(wins)
+ avg_loss = _mean([-p for p in losses]) # positive magnitude
+ payoff = (avg_win / avg_loss) if avg_loss else None
+ # win rate this strategy would need just to break even, given its own
+ # realized win/loss sizes - the number to compare the actual win rate against
+ breakeven_wr = (1 / (1 + payoff) * 100) if payoff else None
+
+ return {
+ "n": n,
+ "pnl": sum(pnls),
+ "win_rate": len(wins) / n * 100,
+ "expectancy": _mean(pnls),
+ "profit_factor": (gross_profit / gross_loss) if gross_loss else None,
+ "avg_win": avg_win,
+ "avg_loss": avg_loss,
+ "payoff_ratio": payoff,
+ "breakeven_win_rate": breakeven_wr,
+ "gross_profit": gross_profit,
+ "gross_loss": gross_loss,
+ "commission": sum(t.commission for t in trips),
+ "avg_pnl_pct": _mean([t.pnl_pct for t in trips]),
+ "median_pnl_pct": _median([t.pnl_pct for t in trips]),
+ "best": max(pnls),
+ "worst": min(pnls),
+ "avg_hold_minutes": _mean(holds) if holds else None,
+ "n_wins": len(wins),
+ "n_losses": len(losses),
+ }
+
+
+def equity_curve(trips: list[RoundTrip]) -> list[dict]:
+ """Cumulative realized P&L in exit order, with running peak and drawdown."""
+ ordered = sorted(trips, key=lambda t: t.exit_ts)
+ curve, cum, peak = [], 0.0, 0.0
+ for t in ordered:
+ cum += t.pnl
+ peak = max(peak, cum)
+ curve.append({
+ "ts": t.exit_ts,
+ "cum_pnl": cum,
+ "peak": peak,
+ "drawdown": cum - peak,
+ "pnl": t.pnl,
+ "symbol": t.symbol,
+ "strategy_short": short(t.strategy),
+ })
+ return curve
+
+
+def max_drawdown(curve: list[dict]) -> float:
+ """Largest peak-to-trough decline of the realized equity curve (<= 0)."""
+ return min((p["drawdown"] for p in curve), default=0.0)
+
+
+def group_stats(trips: list[RoundTrip], keyfn: Callable[[RoundTrip], str]) -> dict[str, dict]:
+ """Summarize round trips bucketed by an arbitrary key."""
+ buckets: dict[str, list[RoundTrip]] = defaultdict(list)
+ for t in trips:
+ buckets[keyfn(t) or "(none)"].append(t)
+ return {k: summarize(v) for k, v in buckets.items()}
+
+
+def daily_pnl(trips: list[RoundTrip]) -> dict[str, float]:
+ """Net realized P&L per calendar day, keyed by exit date."""
+ out: dict[str, float] = defaultdict(float)
+ for t in trips:
+ out[t.exit_ts[:10]] += t.pnl
+ return dict(sorted(out.items()))
+
+
+def _exit_hour(t: RoundTrip) -> str:
+ try:
+ return f"{datetime.fromisoformat(t.exit_ts).hour:02d}:00"
+ except ValueError:
+ return "(none)"
+
+
+def analyze(path: Path = LEDGER, since: Optional[str] = None,
+ until: Optional[str] = None) -> dict:
+ """Full analysis bundle. `since`/`until` are inclusive YYYY-MM-DD exit-date bounds.
+
+ Round trips are always reconstructed from the *whole* ledger so cost basis
+ stays correct; the date filter is applied to the resulting closed trades.
+ """
+ records = _read_records(path)
+ trips, open_lots, unmatched = build_round_trips(records)
+
+ if since:
+ trips = [t for t in trips if t.exit_ts[:10] >= since]
+ if until:
+ trips = [t for t in trips if t.exit_ts[:10] <= until]
+
+ curve = equity_curve(trips)
+ return {
+ "ledger": str(path),
+ "generated": datetime.now().isoformat(timespec="seconds"),
+ "since": since,
+ "until": until,
+ "n_records": len(records),
+ "unmatched_sell_qty": unmatched,
+ "overall": summarize(trips),
+ "max_drawdown": max_drawdown(curve),
+ "by_strategy": group_stats(trips, lambda t: short(t.strategy)),
+ "by_symbol": group_stats(trips, lambda t: t.symbol),
+ "by_entry_reason": group_stats(trips, lambda t: t.entry_reason),
+ "by_exit_reason": group_stats(trips, lambda t: t.exit_reason),
+ "by_exit_hour": group_stats(trips, _exit_hour),
+ "by_weekday": group_stats(
+ trips,
+ lambda t: (datetime.fromisoformat(t.exit_ts).strftime("%a")
+ if t.exit_ts else "(none)"),
+ ),
+ "daily_pnl": daily_pnl(trips),
+ "equity_curve": curve,
+ "trips": [t.as_row() for t in sorted(trips, key=lambda x: x.exit_ts, reverse=True)],
+ "open_lots": [l.as_row() for l in sorted(open_lots, key=lambda x: (x.symbol, x.strategy))],
+ }
+
+
+# --------------------------------------------------------------------------- #
+# CLI
+# --------------------------------------------------------------------------- #
+
+
+def _fmt(v, spec="+.2f", dash="-"):
+ return dash if v is None else format(v, spec)
+
+
+def _print_table(title: str, stats: dict[str, dict]):
+ if not stats:
+ return
+ print(f"\n {title}")
+ print(f" {'bucket':<14}{'n':>4}{'net P&L':>11}{'win%':>7}{'exp':>9}{'PF':>7}{'be.win%':>9}")
+ for k, s in sorted(stats.items(), key=lambda kv: -kv[1]["pnl"]):
+ print(f" {k[:14]:<14}{s['n']:>4}{s['pnl']:>+11.2f}{s['win_rate']:>7.1f}"
+ f"{s['expectancy']:>+9.2f}{_fmt(s['profit_factor'], '.2f', ' n/a'):>7}"
+ f"{_fmt(s['breakeven_win_rate'], '.1f', ' n/a'):>9}")
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Round-trip analytics over trades.jsonl")
+ ap.add_argument("--ledger", default=str(LEDGER), help="path to trades.jsonl")
+ ap.add_argument("--since", help="only count trades closed on/after YYYY-MM-DD")
+ ap.add_argument("--until", help="only count trades closed on/before YYYY-MM-DD")
+ ap.add_argument("--json", action="store_true", help="dump the full bundle as JSON")
+ args = ap.parse_args()
+
+ path = Path(args.ledger)
+ if not path.exists():
+ raise SystemExit(f"ledger not found: {path}")
+
+ data = analyze(path, args.since, args.until)
+
+ if args.json:
+ print(json.dumps(data, indent=2, default=str))
+ return
+
+ o = data["overall"]
+ span = " ".join(filter(None, [
+ f"since {args.since}" if args.since else "",
+ f"until {args.until}" if args.until else "",
+ ])) or "full history"
+ print(f"========== round-trip performance ({span}) ==========")
+ if o["n"] == 0:
+ print(" no closed round trips in range")
+ else:
+ print(f" closed trips {o['n']} ({o['n_wins']}W / {o['n_losses']}L)")
+ print(f" net realized {o['pnl']:+.2f} (commissions {o['commission']:.2f})")
+ print(f" win rate {o['win_rate']:.1f}% breakeven needs "
+ f"{_fmt(o['breakeven_win_rate'], '.1f', 'n/a')}%")
+ print(f" expectancy {o['expectancy']:+.2f} per trade")
+ print(f" profit factor {_fmt(o['profit_factor'], '.2f', 'n/a')}")
+ print(f" avg win / loss {o['avg_win']:+.2f} / -{o['avg_loss']:.2f}"
+ f" (payoff {_fmt(o['payoff_ratio'], '.2f', 'n/a')})")
+ print(f" best / worst {o['best']:+.2f} / {o['worst']:+.2f}")
+ print(f" max drawdown {data['max_drawdown']:+.2f}")
+ if o["avg_hold_minutes"] is not None:
+ print(f" avg hold {o['avg_hold_minutes']:.0f} min")
+
+ _print_table("by strategy", data["by_strategy"])
+ _print_table("by symbol", data["by_symbol"])
+ if any(k != "(none)" for k in data["by_entry_reason"]):
+ _print_table("by entry signal", data["by_entry_reason"])
+ if any(k != "(none)" for k in data["by_exit_reason"]):
+ _print_table("by exit reason", data["by_exit_reason"])
+
+ if data["unmatched_sell_qty"]:
+ print(f"\n note: {data['unmatched_sell_qty']:g} unit(s) sold with no known "
+ f"cost basis (lots predate the ledger) - excluded above")
+
+ if data["open_lots"]:
+ print(f"\n open lots ({len(data['open_lots'])}):")
+ for l in data["open_lots"]:
+ print(f" {l['strategy_short']:<10}{l['symbol']:<6} x{l['qty']:<6g} "
+ f"@ {l['price']:>9.2f} cost {l['cost']:>10.2f}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dashboard.py b/dashboard.py
new file mode 100644
index 0000000..644d696
--- /dev/null
+++ b/dashboard.py
@@ -0,0 +1,785 @@
+#!/usr/bin/env python
+"""Render the trade ledger as a self-contained interactive HTML dashboard.
+
+ .venv/bin/python dashboard.py # -> dashboard.html
+ .venv/bin/python dashboard.py -o /tmp/out.html --open
+ .venv/bin/python dashboard.py --since 2026-08-01
+
+Round trips come from analytics.py. The page embeds them as JSON and does its own
+filtering/aggregation client-side, so the strategy, symbol and date-range filters
+recompute every chart without regenerating the file. No external assets, no CDN:
+the output is one portable HTML file.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import webbrowser
+from pathlib import Path
+
+import analytics
+
+# Reference thresholds drawn on the return-distribution chart. Read from config
+# when it is importable so the annotations follow the live parameters.
+try:
+ from config import config as _cfg
+ DEFAULT_MIN_PROFIT = _cfg.short_term.min_profit_pct
+ DEFAULT_STOP_LOSS = _cfg.short_term.stop_loss_pct
+ DEFAULT_MAX_DAILY_LOSS = _cfg.max_daily_loss
+except Exception:
+ DEFAULT_MIN_PROFIT, DEFAULT_STOP_LOSS, DEFAULT_MAX_DAILY_LOSS = 1.5, 2.5, 150.0
+
+
+HTML = r"""
Bot Trade History
+
+
+
+
Bot Trade History & Performance
+
+
+
+
+
+
+
+
Realized equity curve
+
Cumulative net P&L by exit time. Shaded band is drawdown from the running peak.
+
+
+
+
+
Daily realized P&L
+
Net per calendar day. The dashed line marks the $ daily-loss circuit breaker.
+
+
+
+
+
+
By strategy
+
Net P&L; label shows trade count and win rate.
+
+
+
+
By symbol
+
Net P&L; label shows trade count and win rate.
+
+
+
+
By entry signal
+
Which signal actually pays. Needs reason in the ledger.
+
+
+
+
By exit reason
+
How trades end. Needs reason in the ledger.
+
+
+
+
+
+
Return distribution
+
+
+
+
+
+
Hold time vs return
+
One dot per closed trade. Colour is the owning strategy.
+
+
+
+
+
Open lots
+
Unmatched buy lots at the end of the ledger — cost basis only, not live marks.
+
+
+
+
+
Trade history
+
Every closed round trip in range, newest first. Click a header to sort. SELL* = exit price estimated (fill missed while the bot was offline).
+
+
+
+
+
+
+"""
+
+
+def build(data: dict, min_profit: float, stop_loss: float, max_daily_loss: float) -> str:
+ """Inject the analysis bundle into the page template."""
+ payload = {
+ "ledger": data["ledger"],
+ "generated": data["generated"],
+ "n_records": data["n_records"],
+ "unmatched_sell_qty": data["unmatched_sell_qty"],
+ "overall": data["overall"],
+ "trips": data["trips"],
+ "open_lots": data["open_lots"],
+ "min_profit_pct": min_profit,
+ "stop_loss_pct": stop_loss,
+ "max_daily_loss": max_daily_loss,
+ }
+ return HTML.replace("__DATA__", json.dumps(payload, default=str))
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Render trades.jsonl as an HTML dashboard")
+ ap.add_argument("--ledger", default=str(analytics.LEDGER))
+ ap.add_argument("-o", "--out", default="dashboard.html")
+ ap.add_argument("--since", help="only include trades closed on/after YYYY-MM-DD")
+ ap.add_argument("--until", help="only include trades closed on/before YYYY-MM-DD")
+ ap.add_argument("--min-profit", type=float, default=DEFAULT_MIN_PROFIT,
+ help="min-profit annotation on the distribution chart")
+ ap.add_argument("--stop-loss", type=float, default=DEFAULT_STOP_LOSS,
+ help="stop-loss annotation on the distribution chart")
+ ap.add_argument("--max-daily-loss", type=float, default=DEFAULT_MAX_DAILY_LOSS,
+ help="daily circuit-breaker reference line")
+ ap.add_argument("--open", action="store_true", help="open the result in a browser")
+ args = ap.parse_args()
+
+ path = Path(args.ledger)
+ if not path.exists():
+ raise SystemExit(f"ledger not found: {path}")
+
+ data = analytics.analyze(path, args.since, args.until)
+ out = Path(args.out)
+ out.write_text(build(data, args.min_profit, args.stop_loss, args.max_daily_loss))
+ print(f"wrote {out} ({data['overall']['n']} closed trips, "
+ f"net {data['overall']['pnl']:+.2f})")
+ if args.open:
+ webbrowser.open(out.resolve().as_uri())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dashboard.sh b/dashboard.sh
new file mode 100755
index 0000000..a448857
--- /dev/null
+++ b/dashboard.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+# 生成可视化交易复盘看板(单文件 HTML,无外部依赖)并在浏览器打开
+# 用法: ./dashboard.sh [--since YYYY-MM-DD] [--until YYYY-MM-DD]
+DIR="$(cd "$(dirname "$0")" && pwd)"
+exec "$DIR/.venv/bin/python" "$DIR/dashboard.py" -o "$DIR/dashboard.html" --open "$@"
diff --git a/orders.py b/orders.py
index ce86fe8..f489963 100644
--- a/orders.py
+++ b/orders.py
@@ -13,6 +13,23 @@ def create_stock_contract(symbol: str, exchange: str = "SMART", currency: str =
return Contract(symbol=symbol, secType="STK", exchange=exchange, currency=currency)
+def trade_commission(trade: Optional[Trade]) -> float:
+ """Total commission reported for a trade's fills, 0.0 if none yet.
+
+ Best effort: IB delivers commissionReport asynchronously, sometimes a moment
+ after the fill, so a freshly filled trade may still report 0. The ledger
+ treats a missing commission as unknown rather than as free, so an occasional
+ miss understates costs rather than corrupting anything.
+ """
+ if trade is None:
+ return 0.0
+ total = 0.0
+ for f in getattr(trade, "fills", []) or []:
+ report = getattr(f, "commissionReport", None)
+ total += float(getattr(report, "commission", 0) or 0)
+ return total
+
+
def has_open_order(ib: IB, order_ref: str) -> bool:
"""True if an unfinished order with this orderRef already exists."""
for trade in ib.openTrades():
diff --git a/state.py b/state.py
index e3b2600..af01a4d 100644
--- a/state.py
+++ b/state.py
@@ -129,12 +129,23 @@ class PositionTracker:
# ---------- trade ledger (append-only JSONL, used by daily_report.py) ----------
def _ledger_append(self, type_: str, strategy: str, key: str, qty: float, price: float,
- estimated: bool = False):
+ estimated: bool = False, reason: str = "", commission: float = 0.0):
+ """Append one fill to the ledger.
+
+ `reason` is the signal that caused it (entry signal on buys, exit reason
+ on sells) and `commission` the IB commission for the fill when it was
+ available at record time. Both are what analytics.py attributes P&L by,
+ so they are worth recording even when only partly populated.
+ """
rec = {
"ts": datetime.now().isoformat(timespec="seconds"),
"type": type_, "strategy": strategy, "symbol": key,
"qty": qty, "price": price,
}
+ if reason:
+ rec["reason"] = reason
+ if commission:
+ rec["commission"] = round(commission, 4)
if estimated:
rec["est"] = True
try:
@@ -198,7 +209,8 @@ class PositionTracker:
if k == key and e.get("quantity", 0) > 0
)
- def record_buy(self, strategy: str, key: str, quantity: float, price: float):
+ def record_buy(self, strategy: str, key: str, quantity: float, price: float,
+ reason: str = "", commission: float = 0.0):
entries = self._data.setdefault(strategy, {})
entry = entries.get(key)
if entry:
@@ -214,10 +226,12 @@ class PositionTracker:
"entry_ts": datetime.now(timezone.utc).isoformat(),
}
self.save()
- self._ledger_append("buy", strategy, key, quantity, price)
+ self._ledger_append("buy", strategy, key, quantity, price,
+ reason=reason, commission=commission)
logger.info("Tracker: %s owns %s x%g @ %.4f", strategy, key, entries[key]["quantity"], entries[key]["entry_price"])
- def record_sell(self, strategy: str, key: str, quantity: float, price: float = None):
+ def record_sell(self, strategy: str, key: str, quantity: float, price: float = None,
+ reason: str = "", commission: float = 0.0):
entries = self._data.get(strategy, {})
entry = entries.get(key)
if not entry:
@@ -227,8 +241,9 @@ class PositionTracker:
del entries[key]
self.save()
if price is not None:
- self._add_day_pnl((price - entry["entry_price"]) * quantity)
- self._ledger_append("sell", strategy, key, quantity, price)
+ self._add_day_pnl((price - entry["entry_price"]) * quantity - commission)
+ self._ledger_append("sell", strategy, key, quantity, price,
+ reason=reason, commission=commission)
self._record_recent_sell(key, price)
logger.info("Tracker: %s sold %s x%g, remaining=%s", strategy, key, quantity, entry.get("quantity", 0))
@@ -246,7 +261,8 @@ class PositionTracker:
"Tracker: backfilling external sell %s %s x%g @ %.2f%s",
strategy, key, qty, price, " (ESTIMATED)" if estimated else "",
)
- self._ledger_append("sell_external", strategy, key, qty, price, estimated=estimated)
+ self._ledger_append("sell_external", strategy, key, qty, price, estimated=estimated,
+ reason="HardStopOffline")
self._add_day_pnl((price - entry_price) * qty)
self._record_recent_sell(key, price)
diff --git a/strategies/.DS_Store b/strategies/.DS_Store
new file mode 100644
index 0000000..47c1619
Binary files /dev/null and b/strategies/.DS_Store differ
diff --git a/strategies/__pycache__/__init__.cpython-312.pyc b/strategies/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..6a83e5b
Binary files /dev/null and b/strategies/__pycache__/__init__.cpython-312.pyc differ
diff --git a/strategies/__pycache__/__init__.cpython-314.pyc b/strategies/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..e9b02b6
Binary files /dev/null and b/strategies/__pycache__/__init__.cpython-314.pyc differ
diff --git a/strategies/__pycache__/base.cpython-312.pyc b/strategies/__pycache__/base.cpython-312.pyc
new file mode 100644
index 0000000..47826b6
Binary files /dev/null and b/strategies/__pycache__/base.cpython-312.pyc differ
diff --git a/strategies/__pycache__/base.cpython-314.pyc b/strategies/__pycache__/base.cpython-314.pyc
new file mode 100644
index 0000000..95d6b8f
Binary files /dev/null and b/strategies/__pycache__/base.cpython-314.pyc differ
diff --git a/strategies/__pycache__/forex.cpython-312.pyc b/strategies/__pycache__/forex.cpython-312.pyc
new file mode 100644
index 0000000..62468ef
Binary files /dev/null and b/strategies/__pycache__/forex.cpython-312.pyc differ
diff --git a/strategies/__pycache__/forex.cpython-314.pyc b/strategies/__pycache__/forex.cpython-314.pyc
new file mode 100644
index 0000000..2e00148
Binary files /dev/null and b/strategies/__pycache__/forex.cpython-314.pyc differ
diff --git a/strategies/__pycache__/ma_cross.cpython-312.pyc b/strategies/__pycache__/ma_cross.cpython-312.pyc
new file mode 100644
index 0000000..34f16c9
Binary files /dev/null and b/strategies/__pycache__/ma_cross.cpython-312.pyc differ
diff --git a/strategies/__pycache__/ma_cross.cpython-314.pyc b/strategies/__pycache__/ma_cross.cpython-314.pyc
new file mode 100644
index 0000000..d783c8e
Binary files /dev/null and b/strategies/__pycache__/ma_cross.cpython-314.pyc differ
diff --git a/strategies/__pycache__/mean_reversion.cpython-312.pyc b/strategies/__pycache__/mean_reversion.cpython-312.pyc
new file mode 100644
index 0000000..dd97320
Binary files /dev/null and b/strategies/__pycache__/mean_reversion.cpython-312.pyc differ
diff --git a/strategies/__pycache__/mean_reversion.cpython-314.pyc b/strategies/__pycache__/mean_reversion.cpython-314.pyc
new file mode 100644
index 0000000..6601904
Binary files /dev/null and b/strategies/__pycache__/mean_reversion.cpython-314.pyc differ
diff --git a/strategies/__pycache__/short_term.cpython-312.pyc b/strategies/__pycache__/short_term.cpython-312.pyc
new file mode 100644
index 0000000..4612db5
Binary files /dev/null and b/strategies/__pycache__/short_term.cpython-312.pyc differ
diff --git a/strategies/__pycache__/short_term.cpython-314.pyc b/strategies/__pycache__/short_term.cpython-314.pyc
new file mode 100644
index 0000000..082aa49
Binary files /dev/null and b/strategies/__pycache__/short_term.cpython-314.pyc differ
diff --git a/strategies/base.py b/strategies/base.py
index 9368545..c405193 100644
--- a/strategies/base.py
+++ b/strategies/base.py
@@ -9,7 +9,7 @@ from ib_insync import IB, Contract, StopOrder, Trade
from bars import BarManager
from config import config
-from orders import has_open_order, wait_trade_done
+from orders import has_open_order, trade_commission, wait_trade_done
from state import PositionTracker
logger = logging.getLogger(__name__)
@@ -208,7 +208,10 @@ class BaseStrategy(ABC):
"%s HARD STOP filled: %s x%g @ %.2f",
self.name, key, filled, trade.orderStatus.avgFillPrice,
)
- self.tracker.record_sell(self.name, key, filled, trade.orderStatus.avgFillPrice)
+ self.tracker.record_sell(self.name, key, filled,
+ trade.orderStatus.avgFillPrice,
+ reason="HardStop",
+ commission=trade_commission(trade))
self._mark_hard_stop_cooldown(key)
elif owned:
logger.warning(
@@ -285,7 +288,8 @@ class BaseStrategy(ABC):
stop_filled, stop_price, confirmed = await self._cancel_stop(key)
if stop_filled > 0:
logger.info("%s: stop filled %g during trailing raise", key, stop_filled)
- self.tracker.record_sell(self.name, key, stop_filled, stop_price)
+ self.tracker.record_sell(self.name, key, stop_filled, stop_price,
+ reason="HardStopDuringTrail")
remaining = owned["quantity"] - stop_filled
if remaining <= 0:
return
diff --git a/strategies/forex.py b/strategies/forex.py
index cf0b584..4b41d39 100644
--- a/strategies/forex.py
+++ b/strategies/forex.py
@@ -5,7 +5,7 @@ from ib_insync import Contract, IB
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
from config import config
-from orders import execute_market_order
+from orders import execute_market_order, trade_commission
from state import PositionTracker
from strategies.base import BaseStrategy
@@ -87,13 +87,13 @@ class ForexMAStrategy(BaseStrategy):
"FOREX STOP-LOSS SELL: %s close=%.5f entry=%.5f",
pair, last["close"], entry,
)
- await self._sell(pair, contract, owned["quantity"])
+ await self._sell(pair, contract, owned["quantity"], "SoftStop")
elif cross_down:
logger.info(
"FOREX SELL %s (fast MA %.5f < slow MA %.5f)",
pair, last["fast_ma"], last["slow_ma"],
)
- await self._sell(pair, contract, owned["quantity"])
+ await self._sell(pair, contract, owned["quantity"], "CrossDown")
else:
if cross_up:
logger.info(
@@ -102,19 +102,20 @@ class ForexMAStrategy(BaseStrategy):
)
await self._buy(pair, contract)
- async def _buy(self, pair: str, contract: Contract):
+ async def _buy(self, pair: str, contract: Contract, reason: str = "MACross"):
ref = f"{self.name}:{pair}"
trade = await execute_market_order(self.ib, contract, "BUY", self.units, ref)
if trade and trade.orderStatus.filled > 0:
self.tracker.record_buy(
- self.name, pair, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
+ self.name, pair, trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
+ reason=reason, commission=trade_commission(trade),
)
- async def _sell(self, pair: str, contract: Contract, quantity: float):
+ async def _sell(self, pair: str, contract: Contract, quantity: float, reason: str = ""):
ref = f"{self.name}:{pair}"
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
if trade and trade.orderStatus.filled > 0:
- self.tracker.record_sell(self.name, pair, trade.orderStatus.filled)
+ self.tracker.record_sell(self.name, pair, trade.orderStatus.filled, reason=reason)
async def on_tick(self):
pass
diff --git a/strategies/ma_cross.py b/strategies/ma_cross.py
index 6dfcd37..d4ad3ac 100644
--- a/strategies/ma_cross.py
+++ b/strategies/ma_cross.py
@@ -5,7 +5,7 @@ from ib_insync import Contract, IB
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
from config import config
-from orders import execute_market_order
+from orders import execute_market_order, trade_commission
from state import PositionTracker
from strategies.base import BaseStrategy
@@ -125,7 +125,7 @@ class MAStockStrategy(BaseStrategy):
"STOCK STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
symbol, last["close"], entry, profit_pct,
)
- await self._sell(symbol, contract, owned["quantity"])
+ await self._sell(symbol, contract, owned["quantity"], "SoftStop")
self._mark_stop_cooldown(symbol)
elif not fast_above:
# re-checked every cycle: exits as soon as profit requirement is met
@@ -134,7 +134,7 @@ class MAStockStrategy(BaseStrategy):
"STOCK SELL: %s (fast MA %.2f < slow MA %.2f, profit=%.2f%%, ADX=%.1f)",
symbol, last["fast_ma"], last["slow_ma"], profit_pct, last["adx"],
)
- await self._sell(symbol, contract, owned["quantity"])
+ await self._sell(symbol, contract, owned["quantity"], "SignalExit")
else:
logger.debug(
"STOCK SELL WAITING: %s profit %.2f%% < min %.1f%%",
@@ -158,12 +158,14 @@ class MAStockStrategy(BaseStrategy):
)
await self._buy(symbol, contract, qty)
- async def _buy(self, symbol: str, contract: Contract, quantity: int):
+ async def _buy(self, symbol: str, contract: Contract, quantity: int,
+ reason: str = "GoldenCross+ADX"):
ref = f"{self.name}:{symbol}"
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
if trade and trade.orderStatus.filled > 0:
self.tracker.record_buy(
- self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
+ self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
+ reason=reason, commission=trade_commission(trade),
)
if self._use_hard_stop():
stop_price = self._target_stop_price(
@@ -177,13 +179,15 @@ class MAStockStrategy(BaseStrategy):
# cool down to avoid retrying every cycle
self._mark_cooldown(symbol, "order failed")
- async def _sell(self, symbol: str, contract: Contract, quantity: float):
+ async def _sell(self, symbol: str, contract: Contract, quantity: float,
+ reason: str = ""):
# cancel the hard stop first; it may have filled in the cancel race
if self._use_hard_stop():
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
if stop_filled > 0:
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
- self.tracker.record_sell(self.name, symbol, stop_filled, stop_price)
+ self.tracker.record_sell(self.name, symbol, stop_filled, stop_price,
+ reason="HardStopDuringCancel")
quantity -= stop_filled
if quantity <= 0:
return
@@ -198,7 +202,9 @@ class MAStockStrategy(BaseStrategy):
ref = f"{self.name}:{symbol}"
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
if trade and trade.orderStatus.filled > 0:
- self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice)
+ self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled,
+ trade.orderStatus.avgFillPrice,
+ reason=reason, commission=trade_commission(trade))
async def on_tick(self):
pass
diff --git a/strategies/mean_reversion.py b/strategies/mean_reversion.py
index f22d4cf..ecbcf85 100644
--- a/strategies/mean_reversion.py
+++ b/strategies/mean_reversion.py
@@ -5,7 +5,7 @@ from ib_insync import Contract, IB
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
from config import config
-from orders import execute_market_order
+from orders import execute_market_order, trade_commission
from state import PositionTracker
from strategies.base import BaseStrategy
@@ -145,7 +145,7 @@ class MeanReversionStrategy(BaseStrategy):
"MeanRev BUY: %s x%d [%s] RSI=%.1f, close=%.2f, BB_lower=%.2f",
symbol, qty, buy_signal, last["rsi"], last["close"], last["bb_lower"],
)
- await self._buy(symbol, contract, qty)
+ await self._buy(symbol, contract, qty, buy_signal)
else:
entry = owned["entry_price"]
profit_pct = (last["close"] - entry) / entry * 100
@@ -157,7 +157,7 @@ class MeanReversionStrategy(BaseStrategy):
"MeanRev STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
symbol, last["close"], entry, profit_pct,
)
- await self._sell(symbol, contract, owned["quantity"])
+ await self._sell(symbol, contract, owned["quantity"], "SoftStop")
self._mark_stop_cooldown(symbol)
return
@@ -181,14 +181,16 @@ class MeanReversionStrategy(BaseStrategy):
"MeanRev SELL: %s [%s] RSI=%.1f, close=%.2f, entry=%.2f, profit=%.2f%%",
symbol, sell_signal, last["rsi"], last["close"], entry, profit_pct,
)
- await self._sell(symbol, contract, owned["quantity"])
+ await self._sell(symbol, contract, owned["quantity"], sell_signal)
- async def _buy(self, symbol: str, contract: Contract, quantity: int):
+ async def _buy(self, symbol: str, contract: Contract, quantity: int,
+ reason: str = ""):
ref = f"{self.name}:{symbol}"
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
if trade and trade.orderStatus.filled > 0:
self.tracker.record_buy(
- self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
+ self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
+ reason=reason, commission=trade_commission(trade),
)
if self._use_hard_stop():
stop_price = self._target_stop_price(
@@ -202,13 +204,15 @@ class MeanReversionStrategy(BaseStrategy):
# cool down to avoid retrying every cycle
self._mark_cooldown(symbol, "order failed")
- async def _sell(self, symbol: str, contract: Contract, quantity: float):
+ async def _sell(self, symbol: str, contract: Contract, quantity: float,
+ reason: str = ""):
# cancel the hard stop first; it may have filled in the cancel race
if self._use_hard_stop():
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
if stop_filled > 0:
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
- self.tracker.record_sell(self.name, symbol, stop_filled, stop_price)
+ self.tracker.record_sell(self.name, symbol, stop_filled, stop_price,
+ reason="HardStopDuringCancel")
quantity -= stop_filled
if quantity <= 0:
return
@@ -223,7 +227,9 @@ class MeanReversionStrategy(BaseStrategy):
ref = f"{self.name}:{symbol}"
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
if trade and trade.orderStatus.filled > 0:
- self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice)
+ self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled,
+ trade.orderStatus.avgFillPrice,
+ reason=reason, commission=trade_commission(trade))
async def on_tick(self):
pass
diff --git a/strategies/short_term.py b/strategies/short_term.py
index 7c5cc65..d3aee0a 100644
--- a/strategies/short_term.py
+++ b/strategies/short_term.py
@@ -6,7 +6,7 @@ from ib_insync import Contract, IB
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
from config import config
-from orders import execute_market_order
+from orders import execute_market_order, trade_commission
from state import PositionTracker
from strategies.base import BaseStrategy
@@ -119,7 +119,7 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
"ShortTerm STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
symbol, last["close"], entry, profit_pct,
)
- await self._sell(symbol, contract, owned["quantity"])
+ await self._sell(symbol, contract, owned["quantity"], "SoftStop")
self._mark_stop_cooldown(symbol)
return
@@ -128,7 +128,7 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
hold_days = (date.today() - entry_date).days
if hold_days >= self.cfg.max_hold_days:
logger.info("ShortTerm SELL %s: max hold reached (%d days)", symbol, hold_days)
- await self._sell(symbol, contract, owned["quantity"])
+ await self._sell(symbol, contract, owned["quantity"], "MaxHold")
return
if not fast_above:
@@ -138,7 +138,7 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
"ShortTerm SELL: %s (fast EMA %.2f < slow EMA %.2f, profit=%.2f%%)",
symbol, last["fast_ema"], last["slow_ema"], profit_pct,
)
- await self._sell(symbol, contract, owned["quantity"])
+ await self._sell(symbol, contract, owned["quantity"], "SignalExit")
else:
logger.debug(
"ShortTerm SELL WAITING: %s profit %.2f%% < min %.1f%%",
@@ -162,12 +162,14 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
)
await self._buy(symbol, contract, qty)
- async def _buy(self, symbol: str, contract: Contract, quantity: int):
+ async def _buy(self, symbol: str, contract: Contract, quantity: int,
+ reason: str = "EMACross+VWAP"):
ref = f"{self.name}:{symbol}"
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
if trade and trade.orderStatus.filled > 0:
self.tracker.record_buy(
- self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
+ self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice,
+ reason=reason, commission=trade_commission(trade),
)
if self._use_hard_stop():
stop_price = self._target_stop_price(
@@ -181,13 +183,15 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
# cool down to avoid retrying every cycle
self._mark_cooldown(symbol, "order failed")
- async def _sell(self, symbol: str, contract: Contract, quantity: float):
+ async def _sell(self, symbol: str, contract: Contract, quantity: float,
+ reason: str = ""):
# cancel the hard stop first; it may have filled in the cancel race
if self._use_hard_stop():
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
if stop_filled > 0:
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
- self.tracker.record_sell(self.name, symbol, stop_filled, stop_price)
+ self.tracker.record_sell(self.name, symbol, stop_filled, stop_price,
+ reason="HardStopDuringCancel")
quantity -= stop_filled
if quantity <= 0:
return
@@ -202,7 +206,9 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
ref = f"{self.name}:{symbol}"
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
if trade and trade.orderStatus.filled > 0:
- self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice)
+ self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled,
+ trade.orderStatus.avgFillPrice,
+ reason=reason, commission=trade_commission(trade))
async def on_tick(self):
pass
diff --git a/test_offline.py b/test_offline.py
new file mode 100644
index 0000000..5aea141
--- /dev/null
+++ b/test_offline.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python
+"""Offline tests — no IB connection, no orders, safe to run against the live repo.
+
+ .venv/bin/python -m unittest test_offline -v
+
+Covers the pure layers: ledger writing (PositionTracker), round-trip
+reconstruction and statistics (analytics), and the indicator maths. Indicator
+tests are skipped when pandas is unavailable.
+"""
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+import analytics
+from state import PositionTracker
+
+try:
+ import pandas as pd
+ HAVE_PANDAS = True
+except ImportError:
+ HAVE_PANDAS = False
+
+
+class LedgerTest(unittest.TestCase):
+ """PositionTracker must record the attribution fields the dashboard needs."""
+
+ def setUp(self):
+ self.dir = tempfile.TemporaryDirectory()
+ self.tracker = PositionTracker(str(Path(self.dir.name) / "bot_state.json"))
+
+ def tearDown(self):
+ self.dir.cleanup()
+
+ def _ledger(self):
+ return [json.loads(l) for l in
+ self.tracker.ledger_file.read_text().splitlines() if l.strip()]
+
+ def test_buy_records_reason_and_commission(self):
+ self.tracker.record_buy("S", "AAPL", 10, 100.0, reason="RSI", commission=0.35)
+ rec = self._ledger()[-1]
+ self.assertEqual(rec["type"], "buy")
+ self.assertEqual(rec["reason"], "RSI")
+ self.assertEqual(rec["commission"], 0.35)
+
+ def test_sell_records_reason_and_commission(self):
+ self.tracker.record_buy("S", "AAPL", 10, 100.0, reason="RSI")
+ self.tracker.record_sell("S", "AAPL", 10, 103.0,
+ reason="SignalExit", commission=0.4)
+ rec = self._ledger()[-1]
+ self.assertEqual(rec["reason"], "SignalExit")
+ self.assertEqual(rec["commission"], 0.4)
+
+ def test_absent_fields_are_omitted(self):
+ """A record with no reason/commission stays byte-compatible with the old
+ schema, so historical ledgers keep parsing."""
+ self.tracker.record_buy("S", "AAPL", 10, 100.0)
+ rec = self._ledger()[-1]
+ self.assertNotIn("reason", rec)
+ self.assertNotIn("commission", rec)
+
+ def test_day_pnl_is_net_of_commission(self):
+ self.tracker.record_buy("S", "AAPL", 10, 100.0)
+ self.tracker.record_sell("S", "AAPL", 10, 101.0, commission=0.5)
+ self.assertAlmostEqual(self.tracker.day_realized_pnl(), 10.0 - 0.5, places=6)
+
+ def test_external_sell_is_tagged(self):
+ self.tracker.record_buy("S", "AAPL", 10, 100.0)
+ self.tracker.reconcile({}) # account holds none -> backfill an external sell
+ rec = self._ledger()[-1]
+ self.assertEqual(rec["type"], "sell_external")
+ self.assertEqual(rec["reason"], "HardStopOffline")
+ self.assertTrue(rec["est"])
+
+
+class RoundTripTest(unittest.TestCase):
+ """FIFO reconstruction has to survive partial fills and pre-ledger lots."""
+
+ @staticmethod
+ def _trips(records):
+ return analytics.build_round_trips(records)
+
+ def test_simple_round_trip(self):
+ trips, open_lots, unmatched = self._trips([
+ {"ts": "2026-08-01T10:00:00", "type": "buy", "strategy": "S", "symbol": "X",
+ "qty": 10, "price": 100.0, "reason": "Entry", "commission": 0.35},
+ {"ts": "2026-08-01T11:00:00", "type": "sell", "strategy": "S", "symbol": "X",
+ "qty": 10, "price": 102.0, "reason": "SignalExit", "commission": 0.35},
+ ])
+ self.assertEqual(len(trips), 1)
+ self.assertEqual(open_lots, [])
+ self.assertEqual(unmatched, 0)
+ t = trips[0]
+ self.assertAlmostEqual(t.gross_pnl, 20.0)
+ self.assertAlmostEqual(t.commission, 0.70)
+ self.assertAlmostEqual(t.pnl, 19.30)
+ self.assertAlmostEqual(t.pnl_pct, 19.30 / 1000 * 100)
+ self.assertEqual(t.hold_minutes, 60)
+ self.assertEqual(t.entry_reason, "Entry")
+ self.assertEqual(t.exit_reason, "SignalExit")
+ self.assertTrue(t.won)
+
+ def test_partial_sell_splits_the_lot(self):
+ trips, open_lots, _ = self._trips([
+ {"ts": "2026-08-01T10:00:00", "type": "buy", "strategy": "S", "symbol": "X",
+ "qty": 10, "price": 100.0, "commission": 1.0},
+ {"ts": "2026-08-01T11:00:00", "type": "sell", "strategy": "S", "symbol": "X",
+ "qty": 4, "price": 105.0},
+ ])
+ self.assertEqual(len(trips), 1)
+ self.assertEqual(trips[0].qty, 4)
+ self.assertAlmostEqual(trips[0].gross_pnl, 20.0)
+ # buy commission is allocated pro rata: 4/10 of $1.00
+ self.assertAlmostEqual(trips[0].commission, 0.4)
+ self.assertEqual(len(open_lots), 1)
+ self.assertEqual(open_lots[0].qty, 6)
+
+ def test_sell_spanning_two_lots_is_fifo(self):
+ trips, _, _ = self._trips([
+ {"ts": "2026-08-01T10:00:00", "type": "buy", "strategy": "S", "symbol": "X",
+ "qty": 5, "price": 100.0},
+ {"ts": "2026-08-01T10:30:00", "type": "buy", "strategy": "S", "symbol": "X",
+ "qty": 5, "price": 110.0},
+ {"ts": "2026-08-01T11:00:00", "type": "sell", "strategy": "S", "symbol": "X",
+ "qty": 8, "price": 120.0},
+ ])
+ self.assertEqual(len(trips), 2)
+ # oldest lot consumed first, in full
+ self.assertEqual((trips[0].qty, trips[0].entry_price), (5, 100.0))
+ self.assertEqual((trips[1].qty, trips[1].entry_price), (3, 110.0))
+ self.assertAlmostEqual(sum(t.gross_pnl for t in trips), 5 * 20 + 3 * 10)
+
+ def test_sell_without_basis_is_reported_not_guessed(self):
+ trips, _, unmatched = self._trips([
+ {"ts": "2026-08-01T11:00:00", "type": "sell", "strategy": "S", "symbol": "X",
+ "qty": 7, "price": 120.0},
+ ])
+ self.assertEqual(trips, [])
+ self.assertEqual(unmatched, 7)
+
+ def test_positions_are_owned_per_strategy(self):
+ """Two strategies holding the same symbol must not cross-match."""
+ trips, open_lots, unmatched = self._trips([
+ {"ts": "2026-08-01T10:00:00", "type": "buy", "strategy": "A", "symbol": "X",
+ "qty": 5, "price": 100.0},
+ {"ts": "2026-08-01T10:01:00", "type": "buy", "strategy": "B", "symbol": "X",
+ "qty": 5, "price": 200.0},
+ {"ts": "2026-08-01T11:00:00", "type": "sell", "strategy": "B", "symbol": "X",
+ "qty": 5, "price": 210.0},
+ ])
+ self.assertEqual(len(trips), 1)
+ self.assertEqual(trips[0].strategy, "B")
+ self.assertAlmostEqual(trips[0].gross_pnl, 50.0)
+ self.assertEqual(unmatched, 0)
+ self.assertEqual([(l.strategy, l.qty) for l in open_lots], [("A", 5)])
+
+ def test_seed_lot_is_flagged(self):
+ trips, _, _ = self._trips([
+ {"ts": "2026-08-01T10:00:00", "type": "seed", "strategy": "S", "symbol": "X",
+ "qty": 5, "price": 100.0},
+ {"ts": "2026-08-01T11:00:00", "type": "sell", "strategy": "S", "symbol": "X",
+ "qty": 5, "price": 101.0},
+ ])
+ self.assertTrue(trips[0].seeded_entry)
+
+
+class StatsTest(unittest.TestCase):
+ """The statistics that parameter decisions get made on."""
+
+ @staticmethod
+ def _trip(pnl, entry=100.0, qty=1.0, ts="2026-08-01T10:00:00"):
+ return analytics.RoundTrip(
+ strategy="S", symbol="X", qty=qty, entry_ts=ts, exit_ts=ts,
+ entry_price=entry, exit_price=entry + pnl, entry_reason="", exit_reason="",
+ gross_pnl=pnl, commission=0.0, estimated=False, seeded_entry=False)
+
+ def test_summary_of_empty_set(self):
+ s = analytics.summarize([])
+ self.assertEqual(s["n"], 0)
+ self.assertEqual(s["pnl"], 0.0)
+ self.assertIsNone(s["profit_factor"])
+
+ def test_core_statistics(self):
+ # 3 wins of +10, 2 losses of -20
+ trips = [self._trip(10)] * 3 + [self._trip(-20)] * 2
+ s = analytics.summarize(trips)
+ self.assertEqual(s["n"], 5)
+ self.assertAlmostEqual(s["pnl"], 30 - 40)
+ self.assertAlmostEqual(s["win_rate"], 60.0)
+ self.assertAlmostEqual(s["expectancy"], -2.0)
+ self.assertAlmostEqual(s["profit_factor"], 30 / 40)
+ self.assertAlmostEqual(s["avg_win"], 10.0)
+ self.assertAlmostEqual(s["avg_loss"], 20.0)
+ self.assertAlmostEqual(s["payoff_ratio"], 0.5)
+ # payoff 0.5 => must win 2 of every 3 just to break even
+ self.assertAlmostEqual(s["breakeven_win_rate"], 100 / 1.5)
+
+ def test_breakeven_win_rate_matches_actual_at_zero_expectancy(self):
+ """Sanity check on the headline diagnostic: when a set nets exactly zero,
+ its win rate must equal its own breakeven win rate."""
+ trips = [self._trip(10)] * 2 + [self._trip(-20)]
+ s = analytics.summarize(trips)
+ self.assertAlmostEqual(s["pnl"], 0.0)
+ self.assertAlmostEqual(s["win_rate"], s["breakeven_win_rate"], places=6)
+
+ def test_profit_factor_is_none_without_losses(self):
+ self.assertIsNone(analytics.summarize([self._trip(5)])["profit_factor"])
+
+ def test_max_drawdown(self):
+ trips = [
+ self._trip(100, ts="2026-08-01T10:00:00"),
+ self._trip(-40, ts="2026-08-01T11:00:00"),
+ self._trip(-30, ts="2026-08-01T12:00:00"),
+ self._trip(50, ts="2026-08-01T13:00:00"),
+ ]
+ curve = analytics.equity_curve(trips)
+ self.assertEqual([p["cum_pnl"] for p in curve], [100, 60, 30, 80])
+ self.assertAlmostEqual(analytics.max_drawdown(curve), -70.0)
+
+ def test_grouping(self):
+ a = self._trip(10); a.symbol = "AAA"
+ b = self._trip(-5); b.symbol = "BBB"
+ g = analytics.group_stats([a, b], lambda t: t.symbol)
+ self.assertAlmostEqual(g["AAA"]["pnl"], 10)
+ self.assertAlmostEqual(g["BBB"]["pnl"], -5)
+
+ def test_analyze_end_to_end_with_date_filter(self):
+ recs = [
+ {"ts": "2026-08-01T10:00:00", "type": "buy", "strategy": "S", "symbol": "X",
+ "qty": 1, "price": 100.0},
+ {"ts": "2026-08-01T11:00:00", "type": "sell", "strategy": "S", "symbol": "X",
+ "qty": 1, "price": 110.0},
+ {"ts": "2026-08-05T10:00:00", "type": "buy", "strategy": "S", "symbol": "X",
+ "qty": 1, "price": 100.0},
+ {"ts": "2026-08-05T11:00:00", "type": "sell", "strategy": "S", "symbol": "X",
+ "qty": 1, "price": 90.0},
+ ]
+ with tempfile.TemporaryDirectory() as d:
+ p = Path(d) / "trades.jsonl"
+ p.write_text("\n".join(json.dumps(r) for r in recs) + "\n")
+ allt = analytics.analyze(p)
+ self.assertEqual(allt["overall"]["n"], 2)
+ self.assertAlmostEqual(allt["overall"]["pnl"], 0.0)
+ # filtering the window must not corrupt the cost basis of what remains
+ late = analytics.analyze(p, since="2026-08-05")
+ self.assertEqual(late["overall"]["n"], 1)
+ self.assertAlmostEqual(late["overall"]["pnl"], -10.0)
+
+
+@unittest.skipUnless(HAVE_PANDAS, "pandas not installed")
+class IndicatorTest(unittest.TestCase):
+ """Guards on the two indicator bugs AGENTS.md warns against reintroducing."""
+
+ def test_rsi_is_100_on_a_pure_uptrend(self):
+ from strategies.mean_reversion import MeanReversionStrategy
+ rsi = MeanReversionStrategy._calc_rsi(pd.Series(range(1, 40), dtype=float), 14)
+ # no losses -> rs is inf -> RSI must saturate at 100, never NaN
+ self.assertFalse(pd.isna(rsi.iloc[-1]))
+ self.assertAlmostEqual(rsi.iloc[-1], 100.0, places=6)
+
+ def test_adx_direction_on_a_clean_uptrend(self):
+ from strategies.ma_cross import MAStockStrategy
+ n = 60
+ close = pd.Series([100 + i for i in range(n)], dtype=float)
+ df = pd.DataFrame({"high": close + 0.5, "low": close - 0.5, "close": close})
+ adx = MAStockStrategy._calc_adx(df, 14)
+ # a monotonic uptrend must read as strongly trending
+ self.assertGreater(adx.iloc[-1], 50)
+
+ def test_vwap_resets_each_day(self):
+ from strategies.short_term import ShortTermMAVWAPStrategy
+ ts = (list(pd.date_range("2026-08-03 13:30", periods=3, freq="1min", tz="UTC"))
+ + list(pd.date_range("2026-08-04 13:30", periods=3, freq="1min", tz="UTC")))
+ df = pd.DataFrame({
+ "date": ts,
+ "high": [10, 10, 10, 20, 20, 20], "low": [10, 10, 10, 20, 20, 20],
+ "close": [10, 10, 10, 20, 20, 20], "volume": [1, 1, 1, 1, 1, 1],
+ })
+ vwap = ShortTermMAVWAPStrategy._calc_vwap(df)
+ # day two must not be dragged toward day one's prices
+ self.assertAlmostEqual(vwap.iloc[2], 10.0)
+ self.assertAlmostEqual(vwap.iloc[3], 20.0)
+
+ def test_forming_bar_is_dropped(self):
+ from datetime import datetime, timedelta, timezone
+ from ib_insync import BarData
+ from bars import to_completed_df
+
+ def bar(date, close):
+ return BarData(date=date, open=close, high=close, low=close,
+ close=close, volume=1)
+
+ now = datetime.now(timezone.utc)
+ bars = [bar(now - timedelta(seconds=120), 1.0), bar(now - timedelta(seconds=60), 2.0),
+ bar(now, 3.0)] # the last bar is still forming
+ df = to_completed_df(bars, 60)
+ self.assertEqual(len(df), 2)
+ self.assertEqual(df["close"].iloc[-1], 2.0)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)