#!/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()