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)