git ignored nothing?

This commit is contained in:
joeeliang 2026-08-12 01:46:19 -07:00
parent 427ea00b71
commit 7252577a73
45 changed files with 1665 additions and 57 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

13
.gitignore vendored
View File

@ -1,13 +0,0 @@
.venv/
__pycache__/
*.pyc
.env
bot_state.json
day_pnl.json
recent_sells.json
trades.jsonl
trading_bot.log
*.log

View File

@ -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` | 全历史往返交易分析引擎(纯离线,不连 IBFIFO 配对成 `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` | 主循环60sdisconnectedEvent 只注册一次且有并发/关机防护;重连后 `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 收市前停机)。

View File

@ -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` 警示。
## ⚠️ 从旧版本迁移(重要)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

445
analytics.py Normal file
View File

@ -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()

785
dashboard.py Normal file
View File

@ -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"""<title>Bot Trade History</title>
<style>
:root {
color-scheme: light;
--page:#f9f9f7; --surface:#fcfcfb;
--text:#0b0b0b; --text-2:#52514e; --muted:#898781;
--grid:#e1e0d9; --axis:#c3c2b7; --border:rgba(11,11,11,0.10);
--pos:#2a78d6; --neg:#e34948;
--s1:#2a78d6; --s2:#eb6834; --s3:#1baf7a; --s4:#eda100;
--wash:rgba(11,11,11,0.04);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--page:#0d0d0d; --surface:#1a1a19;
--text:#ffffff; --text-2:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,0.10);
--pos:#3987e5; --neg:#e66767;
--s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500;
--wash:rgba(255,255,255,0.06);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--page:#0d0d0d; --surface:#1a1a19;
--text:#ffffff; --text-2:#c3c2b7; --muted:#898781;
--grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,0.10);
--pos:#3987e5; --neg:#e66767;
--s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500;
--wash:rgba(255,255,255,0.06);
}
body {
background:var(--page); color:var(--text);
font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
margin:0; padding:24px 20px 64px;
}
.wrap { max-width:1120px; margin:0 auto; }
h1 { font-size:20px; font-weight:650; margin:0 0 4px; letter-spacing:-0.01em; }
.sub { color:var(--text-2); font-size:13px; margin-bottom:20px; }
.sub code { color:var(--muted); font-size:12px; }
.card {
background:var(--surface); border:1px solid var(--border); border-radius:10px;
padding:16px 18px; margin-bottom:16px;
}
.card h2 {
font-size:13px; font-weight:600; margin:0 0 2px; letter-spacing:0.01em;
}
.card .note { color:var(--muted); font-size:12px; margin:0 0 14px; }
/* filters */
.filters { display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end; }
.fgroup { display:flex; flex-direction:column; gap:6px; }
.flabel { font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); }
.chips { display:flex; flex-wrap:wrap; gap:6px; }
.chip {
border:1px solid var(--border); background:transparent; color:var(--text-2);
border-radius:999px; padding:4px 11px; font-size:12.5px; cursor:pointer;
font-family:inherit; display:inline-flex; align-items:center; gap:6px;
min-height:28px;
}
.chip:hover { background:var(--wash); }
.chip[aria-pressed="true"] { color:var(--text); border-color:var(--axis); background:var(--wash); }
.chip .dot { width:8px; height:8px; border-radius:2px; background:currentColor; opacity:.35; }
.chip[aria-pressed="true"] .dot { opacity:1; }
select, input[type=date] {
font:inherit; font-size:12.5px; color:var(--text); background:var(--surface);
border:1px solid var(--border); border-radius:6px; padding:5px 8px; min-height:30px;
}
/* stat tiles */
.tiles { display:grid; grid-template-columns:repeat(4,1fr); gap:1px;
background:var(--border); border:1px solid var(--border); border-radius:10px;
overflow:hidden; margin-bottom:16px; }
@media (max-width:820px) { .tiles { grid-template-columns:repeat(2,1fr); } }
.tile { background:var(--surface); padding:14px 16px; }
.tile .k { font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); }
.tile .v { font-size:23px; font-weight:600; margin-top:4px; letter-spacing:-0.02em; }
.tile .h { font-size:12px; color:var(--text-2); margin-top:2px; }
.up { color:var(--pos); } .down { color:var(--neg); }
.grid2 { display:grid; grid-template-columns:repeat(auto-fit,minmax(420px,1fr)); gap:16px; }
svg { display:block; width:100%; overflow:visible; }
svg text { fill:var(--muted); font-size:11px; }
svg text.lbl { fill:var(--text-2); font-size:11.5px; }
svg text.val { fill:var(--text-2); font-size:11px; font-variant-numeric:tabular-nums; }
.gridline { stroke:var(--grid); stroke-width:1; }
.baseline { stroke:var(--axis); stroke-width:1; }
.annot { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.8; }
.legend { display:flex; flex-wrap:wrap; gap:14px; margin:0 0 10px; font-size:12px; color:var(--text-2); }
.legend span { display:inline-flex; align-items:center; gap:6px; }
.legend i { width:10px; height:10px; border-radius:2px; display:inline-block; }
/* tables */
.scroll { overflow-x:auto; }
table { border-collapse:collapse; width:100%; font-size:12.5px; }
th, td { text-align:right; padding:6px 9px; white-space:nowrap; }
th:first-child, td:first-child, th.l, td.l { text-align:left; }
thead th {
color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase;
letter-spacing:0.04em; border-bottom:1px solid var(--axis); cursor:pointer;
position:sticky; top:0; background:var(--surface);
}
thead th:hover { color:var(--text-2); }
tbody tr { border-bottom:1px solid var(--grid); }
tbody tr:hover { background:var(--wash); }
td.num { font-variant-numeric:tabular-nums; }
.tag { font-size:11px; color:var(--text-2); border:1px solid var(--border);
border-radius:4px; padding:1px 6px; }
.est { color:var(--muted); font-size:11px; }
.tallwrap { max-height:520px; overflow-y:auto; }
#tip {
position:fixed; pointer-events:none; z-index:50; opacity:0; transition:opacity .08s;
background:var(--surface); color:var(--text); border:1px solid var(--axis);
border-radius:7px; padding:8px 10px; font-size:12px; line-height:1.45;
box-shadow:0 4px 14px rgba(0,0,0,0.13); max-width:260px;
}
#tip b { font-weight:600; }
#tip .r { color:var(--text-2); }
.empty { color:var(--muted); padding:22px 0; text-align:center; font-size:13px; }
</style>
<div class="wrap">
<h1>Bot Trade History &amp; Performance</h1>
<div class="sub" id="meta"></div>
<div class="card">
<div class="filters" id="filters"></div>
</div>
<div class="tiles" id="tiles"></div>
<div class="card">
<h2>Realized equity curve</h2>
<p class="note">Cumulative net P&amp;L by exit time. Shaded band is drawdown from the running peak.</p>
<div id="equity"></div>
</div>
<div class="card">
<h2>Daily realized P&amp;L</h2>
<p class="note">Net per calendar day. The dashed line marks the $<span id="dlLbl"></span> daily-loss circuit breaker.</p>
<div id="daily"></div>
</div>
<div class="grid2">
<div class="card">
<h2>By strategy</h2>
<p class="note">Net P&amp;L; label shows trade count and win rate.</p>
<div id="byStrategy"></div>
</div>
<div class="card">
<h2>By symbol</h2>
<p class="note">Net P&amp;L; label shows trade count and win rate.</p>
<div id="bySymbol"></div>
</div>
<div class="card">
<h2>By entry signal</h2>
<p class="note">Which signal actually pays. Needs <code>reason</code> in the ledger.</p>
<div id="byEntry"></div>
</div>
<div class="card">
<h2>By exit reason</h2>
<p class="note">How trades end. Needs <code>reason</code> in the ledger.</p>
<div id="byExit"></div>
</div>
</div>
<div class="card">
<h2>Return distribution</h2>
<p class="note" id="distNote"></p>
<div id="dist"></div>
</div>
<div class="card">
<h2>Hold time vs return</h2>
<p class="note">One dot per closed trade. Colour is the owning strategy.</p>
<div id="scatter"></div>
</div>
<div class="card">
<h2>Open lots</h2>
<p class="note">Unmatched buy lots at the end of the ledger cost basis only, not live marks.</p>
<div class="scroll" id="openTbl"></div>
</div>
<div class="card">
<h2>Trade history</h2>
<p class="note">Every closed round trip in range, newest first. Click a header to sort. <span class="est">SELL* = exit price estimated (fill missed while the bot was offline).</span></p>
<div class="scroll tallwrap" id="tripTbl"></div>
</div>
</div>
<div id="tip" role="tooltip"></div>
<script>
const DATA = __DATA__;
const SERIES = ['--s1','--s2','--s3','--s4'];
const cssv = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
/* ---------- helpers ---------- */
const money = v => (v<0?'-':'+') + '$' + Math.abs(v).toFixed(2);
const money0 = v => (v<0?'-':'') + '$' + Math.abs(v).toFixed(0);
const pct = v => v.toFixed(1) + '%';
const el = (t,a={},kids=[]) => {
const n = document.createElementNS('http://www.w3.org/2000/svg', t);
for (const k in a) n.setAttribute(k, a[k]);
kids.forEach(c => n.appendChild(c));
return n;
};
const svg = (w,h) => el('svg', {viewBox:`0 0 ${w} ${h}`, height:h,
preserveAspectRatio:'xMinYMid meet', role:'img'});
const txt = (x,y,s,cls='',anchor='start') => {
const n = el('text',{x,y,'text-anchor':anchor}); if(cls) n.setAttribute('class',cls);
n.textContent = s; return n;
};
function niceTicks(lo, hi, want=5) {
if (lo === hi) { lo -= 1; hi += 1; }
const raw = (hi-lo)/want, mag = Math.pow(10, Math.floor(Math.log10(raw)));
const step = [1,2,2.5,5,10].map(m=>m*mag).find(s=>s>=raw) || 10*mag;
const out = []; for (let v=Math.ceil(lo/step)*step; v<=hi+1e-9; v+=step) out.push(v);
return out;
}
/* Ticks are rounded inward, so the axis domain must be widened back out to the
data - otherwise an extreme value (e.g. a gap-through-stop loss) plots
outside the plot area. */
function domain(lo, hi, ticks) {
return [Math.min(lo, ticks[0]), Math.max(hi, ticks[ticks.length-1])];
}
/* mirrors analytics.summarize() so client-side filters recompute identically */
function summarize(ts) {
const n = ts.length;
if (!n) return {n:0,pnl:0,win_rate:0,expectancy:0,profit_factor:null,avg_win:0,
avg_loss:0,payoff:null,be_wr:null,best:0,worst:0,commission:0,
n_wins:0,n_losses:0,avg_hold:null};
const p = ts.map(t=>t.pnl);
const w = p.filter(x=>x>0), l = p.filter(x=>x<=0);
const gp = w.reduce((a,b)=>a+b,0), gl = -l.reduce((a,b)=>a+b,0);
const aw = w.length ? gp/w.length : 0, al = l.length ? gl/l.length : 0;
const payoff = al ? aw/al : null;
const holds = ts.map(t=>t.hold_minutes).filter(h=>h!=null);
return {
n, pnl:p.reduce((a,b)=>a+b,0), win_rate:w.length/n*100,
expectancy:p.reduce((a,b)=>a+b,0)/n, profit_factor: gl ? gp/gl : null,
avg_win:aw, avg_loss:al, payoff, be_wr: payoff ? 100/(1+payoff) : null,
best:Math.max(...p), worst:Math.min(...p),
commission: ts.reduce((a,t)=>a+t.commission,0),
n_wins:w.length, n_losses:l.length,
avg_hold: holds.length ? holds.reduce((a,b)=>a+b,0)/holds.length : null,
};
}
function groupBy(ts, keyfn) {
const m = new Map();
ts.forEach(t => { const k = keyfn(t) || '(none)';
if(!m.has(k)) m.set(k,[]); m.get(k).push(t); });
return m;
}
/* ---------- tooltip ---------- */
const tip = document.getElementById('tip');
function bindTip(node, html) {
node.addEventListener('pointerenter', e => {
tip.innerHTML = html; tip.style.opacity = 1; move(e);
});
node.addEventListener('pointermove', move);
node.addEventListener('pointerleave', () => tip.style.opacity = 0);
function move(e) {
const r = tip.getBoundingClientRect();
let x = e.clientX + 14, y = e.clientY - r.height - 10;
if (x + r.width > innerWidth - 8) x = e.clientX - r.width - 14;
if (y < 8) y = e.clientY + 16;
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
}
/* ---------- state & filtering ---------- */
const strategies = [...new Set(DATA.trips.map(t=>t.strategy_short))].sort();
const symbols = [...new Set(DATA.trips.map(t=>t.symbol))].sort();
const stratColor = {};
strategies.forEach((s,i) => stratColor[s] = SERIES[i % SERIES.length]);
const state = { strat:new Set(strategies), sym:new Set(symbols), days:0 };
function filtered() {
let ts = DATA.trips.filter(t => state.strat.has(t.strategy_short) && state.sym.has(t.symbol));
if (state.days > 0) {
const all = DATA.trips.map(t=>t.exit_ts).sort();
if (all.length) {
const last = new Date(all[all.length-1]);
const cut = new Date(last.getTime() - state.days*86400000).toISOString().slice(0,10);
ts = ts.filter(t => t.exit_ts.slice(0,10) >= cut);
}
}
return ts;
}
/* ---------- filter UI ---------- */
function buildFilters() {
const f = document.getElementById('filters');
f.innerHTML = '';
f.appendChild(chipGroup('Strategy', strategies, state.strat, s=>cssv(stratColor[s])));
f.appendChild(chipGroup('Symbol', symbols, state.sym, ()=>null));
const g = document.createElement('div'); g.className = 'fgroup';
g.innerHTML = '<span class="flabel">Range</span>';
const sel = document.createElement('select');
[[0,'All time'],[7,'Last 7 days'],[30,'Last 30 days'],[90,'Last 90 days']]
.forEach(([v,l]) => { const o=document.createElement('option'); o.value=v; o.textContent=l; sel.appendChild(o); });
sel.value = state.days;
sel.onchange = () => { state.days = +sel.value; render(); };
g.appendChild(sel); f.appendChild(g);
function chipGroup(label, items, set, colorFn) {
const g = document.createElement('div'); g.className='fgroup';
g.innerHTML = `<span class="flabel">${label}</span>`;
const box = document.createElement('div'); box.className='chips';
items.forEach(it => {
const b = document.createElement('button');
b.className='chip'; b.type='button';
b.setAttribute('aria-pressed', set.has(it));
const c = colorFn(it);
b.innerHTML = (c ? `<i class="dot" style="background:${c}"></i>` : '') + it;
b.onclick = () => {
if (set.has(it)) { if (set.size>1) set.delete(it); } else set.add(it);
b.setAttribute('aria-pressed', set.has(it)); render();
};
box.appendChild(b);
});
g.appendChild(box); return g;
}
}
/* ---------- stat tiles ---------- */
function renderTiles(ts) {
const s = summarize(ts);
const dd = drawdownOf(ts);
const beat = s.be_wr != null ? s.win_rate - s.be_wr : null;
const tiles = [
['Net realized', money(s.pnl), s.pnl>=0?'up':'down',
`${s.n} trades · ${money0(s.commission)} commission`],
['Expectancy', money(s.expectancy), s.expectancy>=0?'up':'down', 'per trade'],
['Win rate', pct(s.win_rate), '',
s.be_wr!=null ? `needs ${pct(s.be_wr)} to break even` : `${s.n_wins}W / ${s.n_losses}L`],
['Edge vs breakeven', beat!=null ? (beat>=0?'+':'')+beat.toFixed(1)+'pp' : '',
beat!=null ? (beat>=0?'up':'down') : '',
'win rate minus breakeven'],
['Profit factor', s.profit_factor!=null ? s.profit_factor.toFixed(2) : '',
s.profit_factor!=null ? (s.profit_factor>=1?'up':'down') : '',
`gross ${money0(s.avg_win*s.n_wins)} / ${money0(-s.avg_loss*s.n_losses)}`],
['Payoff ratio', s.payoff!=null ? s.payoff.toFixed(2) : '',
s.payoff!=null ? (s.payoff>=1?'up':'down') : '',
`avg ${money0(s.avg_win)} win / ${money0(s.avg_loss)} loss`],
['Max drawdown', money(dd), dd<0?'down':'', 'realized, peak to trough'],
['Avg hold', s.avg_hold!=null ? fmtHold(s.avg_hold) : '', '', 'entry to exit'],
];
document.getElementById('tiles').innerHTML = tiles.map(([k,v,cls,h]) =>
`<div class="tile"><div class="k">${k}</div><div class="v ${cls}">${v}</div><div class="h">${h}</div></div>`
).join('');
}
const fmtHold = m => m < 90 ? Math.round(m)+' min'
: m < 1440 ? (m/60).toFixed(1)+' h' : (m/1440).toFixed(1)+' d';
function drawdownOf(ts) {
let cum=0, peak=0, dd=0;
[...ts].sort((a,b)=>a.exit_ts<b.exit_ts?-1:1).forEach(t=>{
cum+=t.pnl; peak=Math.max(peak,cum); dd=Math.min(dd,cum-peak);
});
return dd;
}
/* ---------- equity curve ---------- */
function renderEquity(ts) {
const host = document.getElementById('equity');
host.innerHTML = '';
if (!ts.length) return host.innerHTML = '<div class="empty">No closed trades in range.</div>';
const pts = [...ts].sort((a,b)=>a.exit_ts<b.exit_ts?-1:1);
let cum=0, peak=0;
const series = pts.map(t => { cum+=t.pnl; peak=Math.max(peak,cum);
return {x:new Date(t.exit_ts).getTime(), cum, peak, t}; });
const W = host.clientWidth || 860, H = 260;
const m = {t:12, r:16, b:26, l:56};
const iw = W-m.l-m.r, ih = H-m.t-m.b;
const x0 = series[0].x, x1 = series[series.length-1].x;
const sx = v => m.l + (x1===x0 ? iw/2 : (v-x0)/(x1-x0)*iw);
const lo = Math.min(0, ...series.map(p=>p.cum)), hi = Math.max(0, ...series.map(p=>p.peak));
const ticks = niceTicks(lo, hi);
const yLo = Math.min(lo, ticks[0]), yHi = Math.max(hi, ticks[ticks.length-1]);
const sy = v => m.t + ih - (v-yLo)/(yHi-yLo)*ih;
const s = svg(W,H);
ticks.forEach(v => {
s.appendChild(el('line',{class:'gridline',x1:m.l,x2:W-m.r,y1:sy(v),y2:sy(v)}));
s.appendChild(txt(m.l-9, sy(v)+4, money0(v), 'val', 'end'));
});
s.appendChild(el('line',{class:'baseline',x1:m.l,x2:W-m.r,y1:sy(0),y2:sy(0)}));
// drawdown band: between running peak and equity
const band = series.map(p=>`${sx(p.x)},${sy(p.peak)}`).join(' ') + ' ' +
[...series].reverse().map(p=>`${sx(p.x)},${sy(p.cum)}`).join(' ');
s.appendChild(el('polygon',{points:band, fill:cssv('--neg'), opacity:0.13}));
s.appendChild(el('polyline',{
points: series.map(p=>`${sx(p.x)},${sy(p.cum)}`).join(' '),
fill:'none', stroke:cssv('--pos'), 'stroke-width':2,
'stroke-linejoin':'round','stroke-linecap':'round'}));
// x labels: first / middle / last date
[0, Math.floor(series.length/2), series.length-1].filter((v,i,a)=>a.indexOf(v)===i)
.forEach((i,j,arr) => {
const p = series[i];
s.appendChild(txt(sx(p.x), H-8, new Date(p.x).toISOString().slice(5,10),
'', j===0?'start':(j===arr.length-1?'end':'middle')));
});
// hover markers (invisible wide hit targets)
series.forEach(p => {
const g = el('g');
g.appendChild(el('circle',{cx:sx(p.x),cy:sy(p.cum),r:8,fill:'transparent'}));
g.appendChild(el('circle',{cx:sx(p.x),cy:sy(p.cum),r:2.5,
fill:cssv('--pos'),opacity:0.55}));
bindTip(g, `<b>${p.t.symbol}</b> <span class="r">${p.t.strategy_short}</span><br>
<span class="r">${p.t.exit_ts.replace('T',' ')}</span><br>
trade ${money(p.t.pnl)} · cumulative <b>${money(p.cum)}</b>
${p.cum<p.peak ? `<br><span class="r">drawdown ${money(p.cum-p.peak)}</span>`:''}`);
s.appendChild(g);
});
host.appendChild(s);
}
/* ---------- daily bars ---------- */
function renderDaily(ts) {
const host = document.getElementById('daily');
host.innerHTML = '';
if (!ts.length) return host.innerHTML = '<div class="empty">No closed trades in range.</div>';
const m2 = new Map();
ts.forEach(t => { const d=t.exit_ts.slice(0,10); m2.set(d,(m2.get(d)||0)+t.pnl); });
const days = [...m2.entries()].sort();
const W = host.clientWidth || 860, H = 200;
const m = {t:10,r:16,b:30,l:56}, iw=W-m.l-m.r, ih=H-m.t-m.b;
const vals = days.map(d=>d[1]);
const dLo = Math.min(0,...vals,-DATA.max_daily_loss), dHi = Math.max(0,...vals);
const ticks = niceTicks(dLo, dHi);
const [yLo, yHi] = domain(dLo, dHi, ticks);
const sy = v => m.t + ih - (v-yLo)/(yHi-yLo)*ih;
const bw = Math.max(2, Math.min(26, iw/days.length - 2));
const s = svg(W,H);
ticks.forEach(v=>{
s.appendChild(el('line',{class:'gridline',x1:m.l,x2:W-m.r,y1:sy(v),y2:sy(v)}));
s.appendChild(txt(m.l-9,sy(v)+4,money0(v),'val','end'));
});
// circuit-breaker reference
if (-DATA.max_daily_loss >= yLo) {
s.appendChild(el('line',{class:'annot',x1:m.l,x2:W-m.r,
y1:sy(-DATA.max_daily_loss),y2:sy(-DATA.max_daily_loss)}));
}
days.forEach(([d,v],i) => {
const cx = m.l + (i+0.5)*(iw/days.length);
const y = v>=0 ? sy(v) : sy(0), h = Math.max(1, Math.abs(sy(v)-sy(0)));
const g = el('g');
g.appendChild(el('rect',{x:cx-bw/2, y:y, width:bw, height:h, rx:Math.min(4,bw/2),
fill:cssv(v>=0?'--pos':'--neg')}));
g.appendChild(el('rect',{x:cx-bw/2-3, y:m.t, width:bw+6, height:ih, fill:'transparent'}));
bindTip(g, `<b>${d}</b><br>net <b>${money(v)}</b>`);
s.appendChild(g);
});
s.appendChild(el('line',{class:'baseline',x1:m.l,x2:W-m.r,y1:sy(0),y2:sy(0)}));
[0, days.length-1].filter((v,i,a)=>a.indexOf(v)===i).forEach((i,j) => {
s.appendChild(txt(m.l+(i+0.5)*(iw/days.length), H-8, days[i][0].slice(5),
'', j===0?'start':'end'));
});
host.appendChild(s);
}
/* ---------- horizontal attribution bars ---------- */
function renderBars(hostId, ts, keyfn, colorByStrategy=false) {
const host = document.getElementById(hostId);
host.innerHTML = '';
const groups = [...groupBy(ts, keyfn).entries()]
.map(([k,v]) => [k, summarize(v)])
.sort((a,b) => b[1].pnl - a[1].pnl);
if (!groups.length || (groups.length===1 && groups[0][0]==='(none)' && !ts.length))
return host.innerHTML = '<div class="empty">No data in range.</div>';
if (groups.every(g => g[0]==='(none)'))
return host.innerHTML = '<div class="empty">Not recorded in this ledger yet — '
+ 'new trades will populate it.</div>';
const rowH = 30, W = host.clientWidth || 420, H = groups.length*rowH + 24;
const labelW = Math.min(120, Math.max(...groups.map(g=>g[0].length))*7 + 10);
const m = {t:6, r:64, l:labelW+8}, iw = W-m.l-m.r;
const mx = Math.max(1, ...groups.map(g=>Math.abs(g[1].pnl)));
const zero = m.l + iw/2, half = iw/2;
const s = svg(W,H);
s.appendChild(el('line',{class:'baseline',x1:zero,x2:zero,y1:m.t,y2:m.t+groups.length*rowH}));
groups.forEach(([k,st],i) => {
const cy = m.t + i*rowH + rowH/2;
const w = Math.abs(st.pnl)/mx*half;
const g = el('g');
const pos = st.pnl >= 0;
g.appendChild(el('rect',{
x: pos ? zero+1 : zero-w-1, y: cy-7, width: Math.max(1.5,w), height:14,
rx:4, fill: colorByStrategy ? cssv(stratColor[k]||'--s1') : cssv(pos?'--pos':'--neg')}));
s.appendChild(txt(m.l-8, cy+4, k, 'lbl', 'end'));
s.appendChild(txt(W-m.r+8, cy+4, money0(st.pnl), 'val'));
g.appendChild(el('rect',{x:m.l,y:cy-rowH/2,width:iw,height:rowH,fill:'transparent'}));
bindTip(g, `<b>${k}</b><br>net <b>${money(st.pnl)}</b> over ${st.n} trades<br>
<span class="r">win ${pct(st.win_rate)}`
+ (st.be_wr!=null ? ` · breakeven ${pct(st.be_wr)}` : '')
+ `<br>expectancy ${money(st.expectancy)} · payoff `
+ (st.payoff!=null?st.payoff.toFixed(2):'') + `</span>`);
s.appendChild(g);
});
host.appendChild(s);
}
/* ---------- return distribution ---------- */
function renderDist(ts) {
const host = document.getElementById('dist');
host.innerHTML = '';
document.getElementById('distNote').innerHTML =
`Net return per trade in 0.5% bins. Dashed lines mark the +${DATA.min_profit_pct}% `
+ `minimum-profit exit gate and the ${DATA.stop_loss_pct}% hard stop. `
+ `A healthy distribution has its right tail reaching further than its left.`;
if (!ts.length) return host.innerHTML = '<div class="empty">No closed trades in range.</div>';
const BIN = 0.5;
const vals = ts.map(t=>t.pnl_pct);
const lo = Math.floor(Math.min(...vals, -DATA.stop_loss_pct)/BIN)*BIN;
const hi = Math.ceil(Math.max(...vals, DATA.min_profit_pct)/BIN)*BIN;
const nb = Math.max(1, Math.round((hi-lo)/BIN));
const bins = Array.from({length:nb}, (_,i)=>({lo:lo+i*BIN, hi:lo+(i+1)*BIN, items:[]}));
vals.forEach((v,i) => {
let k = Math.floor((v-lo)/BIN); k = Math.max(0, Math.min(nb-1,k));
bins[k].items.push(ts[i]);
});
const W = host.clientWidth || 860, H = 236;
// extra top margin so the threshold labels sit above the plot, never on a bar
const m = {t:26,r:16,b:34,l:40}, iw=W-m.l-m.r, ih=H-m.t-m.b;
const mxc = Math.max(1, ...bins.map(b=>b.items.length));
const cTicks = niceTicks(0, mxc, 4);
const yHi = Math.max(mxc, cTicks[cTicks.length-1]);
const sy = c => m.t + ih - c/yHi*ih;
const sx = v => m.l + (v-lo)/(hi-lo)*iw;
const bw = Math.max(2, iw/nb - 2);
const s = svg(W,H);
cTicks.forEach(c=>{
s.appendChild(el('line',{class:'gridline',x1:m.l,x2:W-m.r,y1:sy(c),y2:sy(c)}));
s.appendChild(txt(m.l-8,sy(c)+4,c,'val','end'));
});
bins.forEach(b => {
if (!b.items.length) return;
const c = b.items.length, cx = sx((b.lo+b.hi)/2);
const g = el('g');
g.appendChild(el('rect',{x:cx-bw/2, y:sy(c), width:bw, height:ih-(sy(c)-m.t),
rx:Math.min(4,bw/2), fill:cssv(b.lo>=0?'--pos':'--neg')}));
g.appendChild(el('rect',{x:cx-bw/2-2,y:m.t,width:bw+4,height:ih,fill:'transparent'}));
const sum = b.items.reduce((a,t)=>a+t.pnl,0);
bindTip(g, `<b>${b.lo.toFixed(1)}% to ${b.hi.toFixed(1)}%</b><br>
${c} trade${c>1?'s':''} · net ${money(sum)}<br>
<span class="r">${[...new Set(b.items.map(t=>t.symbol))].join(', ')}</span>`);
s.appendChild(g);
});
[[DATA.min_profit_pct, 'min profit'], [-DATA.stop_loss_pct, 'stop']].forEach(([v,l])=>{
s.appendChild(el('line',{class:'annot',x1:sx(v),x2:sx(v),y1:m.t-4,y2:m.t+ih}));
s.appendChild(txt(sx(v), m.t-10, l, '', 'middle'));
});
s.appendChild(el('line',{class:'baseline',x1:m.l,x2:W-m.r,y1:m.t+ih,y2:m.t+ih}));
niceTicks(lo,hi,6).forEach(v=>{
if (v<lo-1e-9||v>hi+1e-9) return;
s.appendChild(txt(sx(v), H-10, v.toFixed(1)+'%', '', 'middle'));
});
host.appendChild(s);
}
/* ---------- hold vs return scatter ---------- */
function renderScatter(ts) {
const host = document.getElementById('scatter');
host.innerHTML = '';
const pts = ts.filter(t => t.hold_minutes != null);
if (!pts.length) return host.innerHTML =
'<div class="empty">No hold times available (entry timestamps missing for these lots).</div>';
const used = [...new Set(pts.map(p=>p.strategy_short))].sort();
host.innerHTML = '<div class="legend">' + used.map(s =>
`<span><i style="background:${cssv(stratColor[s])}"></i>${s}</span>`).join('') + '</div>';
const W = host.clientWidth || 860, H = 260;
const m = {t:12,r:16,b:34,l:48}, iw=W-m.l-m.r, ih=H-m.t-m.b;
const hMax = Math.max(...pts.map(p=>p.hold_minutes));
const rLo = Math.min(0,...pts.map(p=>p.pnl_pct)), rHi = Math.max(0,...pts.map(p=>p.pnl_pct));
const rTicks = niceTicks(rLo, rHi, 5);
const [yLo, yHi] = domain(rLo, rHi, rTicks);
const sy = v => m.t + ih - (v-yLo)/(yHi-yLo)*ih;
// sqrt x-scale: hold times span minutes to days
const sx = v => m.l + Math.sqrt(v/Math.max(1,hMax))*iw;
const s = svg(W,H);
rTicks.forEach(v=>{
s.appendChild(el('line',{class:'gridline',x1:m.l,x2:W-m.r,y1:sy(v),y2:sy(v)}));
s.appendChild(txt(m.l-8,sy(v)+4,v.toFixed(1)+'%','val','end'));
});
s.appendChild(el('line',{class:'baseline',x1:m.l,x2:W-m.r,y1:sy(0),y2:sy(0)}));
const xt = [15,60,240,1440,4320,10080].filter(v => v <= hMax*0.88);
xt.forEach(v => s.appendChild(txt(sx(v), H-10, fmtHold(v), '', 'middle')));
// always anchor the right end, otherwise the axis trails off unlabelled
s.appendChild(txt(sx(hMax), H-10, fmtHold(hMax), '', 'end'));
pts.forEach(p => {
const g = el('g');
g.appendChild(el('circle',{cx:sx(p.hold_minutes),cy:sy(p.pnl_pct),r:5,
fill:cssv(stratColor[p.strategy_short]||'--s1'), 'fill-opacity':0.8,
stroke:cssv('--surface'), 'stroke-width':2}));
g.appendChild(el('circle',{cx:sx(p.hold_minutes),cy:sy(p.pnl_pct),r:11,fill:'transparent'}));
bindTip(g, `<b>${p.symbol}</b> <span class="r">${p.strategy_short}</span><br>
${p.pnl_pct>=0?'+':''}${p.pnl_pct.toFixed(2)}% · ${money(p.pnl)}<br>
<span class="r">held ${fmtHold(p.hold_minutes)}`
+ (p.exit_reason?` · exit ${p.exit_reason}`:'') + `</span>`);
s.appendChild(g);
});
host.appendChild(s);
}
/* ---------- tables ---------- */
let sortKey='exit_ts', sortDir=-1;
function renderTrips(ts) {
const host = document.getElementById('tripTbl');
if (!ts.length) return host.innerHTML = '<div class="empty">No closed trades in range.</div>';
const cols = [
['exit_ts','Exit', t=>t.exit_ts.replace('T',' '), 'l'],
['strategy_short','Strategy', t=>t.strategy_short, 'l'],
['symbol','Symbol', t=>t.symbol, 'l'],
['qty','Qty', t=>(+t.qty).toLocaleString(), 'num'],
['entry_price','Entry', t=>t.entry_price.toFixed(2), 'num'],
['exit_price','Exit px', t=>t.exit_price.toFixed(2) + (t.estimated?' <span class="est">*</span>':''), 'num'],
['pnl','Net P&L', t=>`<span class="${t.pnl>=0?'up':'down'}">${money(t.pnl)}</span>`, 'num'],
['pnl_pct','Return', t=>`<span class="${t.pnl>=0?'up':'down'}">${(t.pnl_pct>=0?'+':'')+t.pnl_pct.toFixed(2)}%</span>`, 'num'],
['hold_minutes','Held', t=>t.hold_minutes!=null?fmtHold(t.hold_minutes):'', 'num'],
['entry_reason','Entry signal', t=>t.entry_reason?`<span class="tag">${t.entry_reason}</span>`:'', 'l'],
['exit_reason','Exit reason', t=>t.exit_reason?`<span class="tag">${t.exit_reason}</span>`:'', 'l'],
];
const rows = [...ts].sort((a,b) => {
const x=a[sortKey], y=b[sortKey];
if (x==null) return 1; if (y==null) return -1;
return (x<y?-1:x>y?1:0) * sortDir;
});
host.innerHTML = `<table><thead><tr>` +
cols.map(([k,l,,cls]) => `<th class="${cls==='l'?'l':''}" data-k="${k}">${l}`
+ (sortKey===k ? (sortDir<0?'':'') : '') + `</th>`).join('') +
`</tr></thead><tbody>` +
rows.map(t => '<tr>' + cols.map(([,,fn,cls]) =>
`<td class="${cls}">${fn(t)}</td>`).join('') + '</tr>').join('') +
`</tbody></table>`;
host.querySelectorAll('th').forEach(th => th.onclick = () => {
const k = th.dataset.k;
if (sortKey===k) sortDir*=-1; else { sortKey=k; sortDir=-1; }
renderTrips(filtered());
});
}
function renderOpen() {
const host = document.getElementById('openTbl');
const lots = DATA.open_lots;
if (!lots.length) return host.innerHTML = '<div class="empty">No open lots.</div>';
host.innerHTML = `<table><thead><tr>
<th class="l">Strategy</th><th class="l">Symbol</th><th>Qty</th>
<th>Entry</th><th>Cost basis</th><th class="l">Signal</th><th class="l">Opened</th>
</tr></thead><tbody>` +
lots.map(l => `<tr>
<td class="l">${l.strategy_short}</td><td class="l">${l.symbol}</td>
<td class="num">${(+l.qty).toLocaleString()}</td>
<td class="num">${l.price.toFixed(2)}</td>
<td class="num">$${l.cost.toFixed(2)}</td>
<td class="l">${l.reason?`<span class="tag">${l.reason}</span>`:''}</td>
<td class="l">${l.ts ? l.ts.replace('T',' ') : (l.seeded?'<span class="est">pre-ledger</span>':'')}</td>
</tr>`).join('') + `</tbody></table>`;
}
/* ---------- orchestration ---------- */
function render() {
const ts = filtered();
renderTiles(ts);
renderEquity(ts);
renderDaily(ts);
renderBars('byStrategy', ts, t=>t.strategy_short, true);
renderBars('bySymbol', ts, t=>t.symbol);
renderBars('byEntry', ts, t=>t.entry_reason);
renderBars('byExit', ts, t=>t.exit_reason);
renderDist(ts);
renderScatter(ts);
renderTrips(ts);
}
document.getElementById('meta').innerHTML =
`${DATA.overall.n} closed round trips from ${DATA.n_records} ledger records`
+ (DATA.unmatched_sell_qty ? ` · <span class="est">${DATA.unmatched_sell_qty} unit(s) sold with unknown cost basis, excluded</span>` : '')
+ `<br><code>${DATA.ledger}</code> · generated ${DATA.generated.replace('T',' ')}`;
document.getElementById('dlLbl').textContent = DATA.max_daily_loss.toFixed(0);
buildFilters();
renderOpen();
render();
let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(render, 140); });
</script>
"""
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()

5
dashboard.sh Executable file
View File

@ -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 "$@"

View File

@ -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():

View File

@ -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)

BIN
strategies/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

302
test_offline.py Normal file
View File

@ -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)