git ignored nothing?
This commit is contained in:
parent
427ea00b71
commit
7252577a73
13
.gitignore
vendored
13
.gitignore
vendored
@ -1,13 +0,0 @@
|
|||||||
.venv/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
.env
|
|
||||||
|
|
||||||
bot_state.json
|
|
||||||
day_pnl.json
|
|
||||||
recent_sells.json
|
|
||||||
trades.jsonl
|
|
||||||
trading_bot.log
|
|
||||||
|
|
||||||
*.log
|
|
||||||
10
AGENTS.md
10
AGENTS.md
@ -21,6 +21,9 @@ Interactive Brokers 股票自动交易机器人(Python + ib_insync),多策
|
|||||||
| `bars.py` | `BarManager`:每个合约一条 `keepUpToDate=True` 实时K线订阅,多策略共享;`to_completed_df` 丢弃未收盘bar;`is_market_active` 通过最新bar时效判断开闭市 |
|
| `bars.py` | `BarManager`:每个合约一条 `keepUpToDate=True` 实时K线订阅,多策略共享;`to_completed_df` 丢弃未收盘bar;`is_market_active` 通过最新bar时效判断开闭市 |
|
||||||
| `state.py` | `PositionTracker`:持仓归属(哪个策略拥有哪笔仓位),持久化到 `bot_state.json`;启动/重连时 `reconcile()` 对账;每笔买卖追加写入 `trades.jsonl` 流水账本(首次运行以当前持仓为 seed) |
|
| `state.py` | `PositionTracker`:持仓归属(哪个策略拥有哪笔仓位),持久化到 `bot_state.json`;启动/重连时 `reconcile()` 对账;每笔买卖追加写入 `trades.jsonl` 流水账本(首次运行以当前持仓为 seed) |
|
||||||
| `daily_report.py` / `report.sh` | 当日成交明细+盈亏报告(FIFO,数据源 `trades.jsonl`);用法 `./report.sh [YYYY-MM-DD]` |
|
| `daily_report.py` / `report.sh` | 当日成交明细+盈亏报告(FIFO,数据源 `trades.jsonl`);用法 `./report.sh [YYYY-MM-DD]` |
|
||||||
|
| `analytics.py` | 全历史往返交易分析引擎(纯离线,不连 IB):FIFO 配对成 `RoundTrip` → 胜率/期望值/盈亏比/profit factor/最大回撤/**保本胜率**,并按策略、标的、入场信号、出场原因、时段归因。`--json` 可供程序消费;`dashboard.py` 依赖它 |
|
||||||
|
| `dashboard.py` / `dashboard.sh` | 把 `analytics` 结果渲染成自包含 HTML 看板(无 CDN、无外部资源)。图表为手写内联 SVG + 原生 JS;数据以 JSON 内嵌,筛选在浏览器端重算(`summarize()` 是 `analytics.summarize` 的 JS 镜像,**改动统计口径时两处都要改**) |
|
||||||
|
| `test_offline.py` | 离线单元测试(不连 IB、不下单):账本字段、FIFO 配对、统计口径、指标数学。改策略后必跑 `.venv/bin/python -m unittest test_offline` |
|
||||||
| `orders.py` | `execute_market_order`:下单+等待成交(30s超时撤单)+防重复单(`has_open_order`,按 orderRef 匹配) |
|
| `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`(默认关闭) |
|
| `strategies/` | `ma_cross`(SMA20/50+ADX)、`short_term`(EMA5/10+VWAP+max_hold)、`mean_reversion`(RSI/布林/急跌抄底)、`forex`(默认关闭) |
|
||||||
| `main.py` | 主循环60s;disconnectedEvent 只注册一次且有并发/关机防护;重连后 `bar_manager.reset()` + 重新 `on_start` |
|
| `main.py` | 主循环60s;disconnectedEvent 只注册一次且有并发/关机防护;重连后 `bar_manager.reset()` + 重新 `on_start` |
|
||||||
@ -38,7 +41,8 @@ Interactive Brokers 股票自动交易机器人(Python + ib_insync),多策
|
|||||||
8. **MeanRev 趋势过滤**:仅在 `close > SMA(trend_ma_period=50)` 时允许抄底,避免下跌趋势中接飞刀。
|
8. **MeanRev 趋势过滤**:仅在 `close > SMA(trend_ma_period=50)` 时允许抄底,避免下跌趋势中接飞刀。
|
||||||
9. **信号只用已收盘K线**:最后一根 forming bar 必须丢弃(时间戳比较法)。
|
9. **信号只用已收盘K线**:最后一根 forming bar 必须丢弃(时间戳比较法)。
|
||||||
10. **休市禁交易**:最新bar年龄 > `bar_seconds*5` 视为休市,跳过全部信号。收盘后约5分钟内 bar 仍"新鲜",此窗口的市价单会隔夜排队——收市停机需提前(参考 15:59 EOD 停止的做法)。
|
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(勿重新引入)
|
## ⚠️ 已修复的 bug(勿重新引入)
|
||||||
|
|
||||||
@ -78,6 +82,10 @@ Interactive Brokers 股票自动交易机器人(Python + ib_insync),多策
|
|||||||
./restart_bot.sh # 重启 bot(杀旧进程 + nohup 启动)
|
./restart_bot.sh # 重启 bot(杀旧进程 + nohup 启动)
|
||||||
tail -f trading_bot.log # 运行日志
|
tail -f trading_bot.log # 运行日志
|
||||||
cat bot_state.json # 当前策略持仓归属与成本
|
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 收市前停机)。
|
一次性定时任务示例(cron):`close_legacy_positions.sh`(平仓+自动启动bot)、`stop_bot_eod.sh`(15:59 收市前停机)。
|
||||||
|
|||||||
20
README.md
20
README.md
@ -103,8 +103,28 @@ tail -f trading_bot.log
|
|||||||
|
|
||||||
# 3. 查看当天成交明细与盈亏(可指定日期)
|
# 3. 查看当天成交明细与盈亏(可指定日期)
|
||||||
./report.sh [YYYY-MM-DD]
|
./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` 警示。
|
启动时如为实盘模式,日志会有醒目的 `LIVE TRADING MODE` 警示。
|
||||||
|
|
||||||
## ⚠️ 从旧版本迁移(重要)
|
## ⚠️ 从旧版本迁移(重要)
|
||||||
|
|||||||
BIN
__pycache__/analytics.cpython-312.pyc
Normal file
BIN
__pycache__/analytics.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/analytics.cpython-314.pyc
Normal file
BIN
__pycache__/analytics.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/bars.cpython-312.pyc
Normal file
BIN
__pycache__/bars.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/bars.cpython-314.pyc
Normal file
BIN
__pycache__/bars.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/close_legacy_positions.cpython-314.pyc
Normal file
BIN
__pycache__/close_legacy_positions.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config.cpython-314.pyc
Normal file
BIN
__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/connection.cpython-314.pyc
Normal file
BIN
__pycache__/connection.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/daily_report.cpython-314.pyc
Normal file
BIN
__pycache__/daily_report.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/dashboard.cpython-314.pyc
Normal file
BIN
__pycache__/dashboard.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/main.cpython-314.pyc
Normal file
BIN
__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/orders.cpython-312.pyc
Normal file
BIN
__pycache__/orders.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/orders.cpython-314.pyc
Normal file
BIN
__pycache__/orders.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/state.cpython-312.pyc
Normal file
BIN
__pycache__/state.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/state.cpython-314.pyc
Normal file
BIN
__pycache__/state.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/test_offline.cpython-312.pyc
Normal file
BIN
__pycache__/test_offline.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/test_offline.cpython-314.pyc
Normal file
BIN
__pycache__/test_offline.cpython-314.pyc
Normal file
Binary file not shown.
445
analytics.py
Normal file
445
analytics.py
Normal 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
785
dashboard.py
Normal 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 & 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&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&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&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&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
5
dashboard.sh
Executable 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 "$@"
|
||||||
17
orders.py
17
orders.py
@ -13,6 +13,23 @@ def create_stock_contract(symbol: str, exchange: str = "SMART", currency: str =
|
|||||||
return Contract(symbol=symbol, secType="STK", exchange=exchange, currency=currency)
|
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:
|
def has_open_order(ib: IB, order_ref: str) -> bool:
|
||||||
"""True if an unfinished order with this orderRef already exists."""
|
"""True if an unfinished order with this orderRef already exists."""
|
||||||
for trade in ib.openTrades():
|
for trade in ib.openTrades():
|
||||||
|
|||||||
30
state.py
30
state.py
@ -129,12 +129,23 @@ class PositionTracker:
|
|||||||
# ---------- trade ledger (append-only JSONL, used by daily_report.py) ----------
|
# ---------- trade ledger (append-only JSONL, used by daily_report.py) ----------
|
||||||
|
|
||||||
def _ledger_append(self, type_: str, strategy: str, key: str, qty: float, price: float,
|
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 = {
|
rec = {
|
||||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||||
"type": type_, "strategy": strategy, "symbol": key,
|
"type": type_, "strategy": strategy, "symbol": key,
|
||||||
"qty": qty, "price": price,
|
"qty": qty, "price": price,
|
||||||
}
|
}
|
||||||
|
if reason:
|
||||||
|
rec["reason"] = reason
|
||||||
|
if commission:
|
||||||
|
rec["commission"] = round(commission, 4)
|
||||||
if estimated:
|
if estimated:
|
||||||
rec["est"] = True
|
rec["est"] = True
|
||||||
try:
|
try:
|
||||||
@ -198,7 +209,8 @@ class PositionTracker:
|
|||||||
if k == key and e.get("quantity", 0) > 0
|
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, {})
|
entries = self._data.setdefault(strategy, {})
|
||||||
entry = entries.get(key)
|
entry = entries.get(key)
|
||||||
if entry:
|
if entry:
|
||||||
@ -214,10 +226,12 @@ class PositionTracker:
|
|||||||
"entry_ts": datetime.now(timezone.utc).isoformat(),
|
"entry_ts": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
self.save()
|
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"])
|
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, {})
|
entries = self._data.get(strategy, {})
|
||||||
entry = entries.get(key)
|
entry = entries.get(key)
|
||||||
if not entry:
|
if not entry:
|
||||||
@ -227,8 +241,9 @@ class PositionTracker:
|
|||||||
del entries[key]
|
del entries[key]
|
||||||
self.save()
|
self.save()
|
||||||
if price is not None:
|
if price is not None:
|
||||||
self._add_day_pnl((price - entry["entry_price"]) * quantity)
|
self._add_day_pnl((price - entry["entry_price"]) * quantity - commission)
|
||||||
self._ledger_append("sell", strategy, key, quantity, price)
|
self._ledger_append("sell", strategy, key, quantity, price,
|
||||||
|
reason=reason, commission=commission)
|
||||||
self._record_recent_sell(key, price)
|
self._record_recent_sell(key, price)
|
||||||
logger.info("Tracker: %s sold %s x%g, remaining=%s", strategy, key, quantity, entry.get("quantity", 0))
|
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",
|
"Tracker: backfilling external sell %s %s x%g @ %.2f%s",
|
||||||
strategy, key, qty, price, " (ESTIMATED)" if estimated else "",
|
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._add_day_pnl((price - entry_price) * qty)
|
||||||
self._record_recent_sell(key, price)
|
self._record_recent_sell(key, price)
|
||||||
|
|
||||||
|
|||||||
BIN
strategies/.DS_Store
vendored
Normal file
BIN
strategies/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
strategies/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
strategies/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
strategies/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/base.cpython-312.pyc
Normal file
BIN
strategies/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/base.cpython-314.pyc
Normal file
BIN
strategies/__pycache__/base.cpython-314.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/forex.cpython-312.pyc
Normal file
BIN
strategies/__pycache__/forex.cpython-312.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/forex.cpython-314.pyc
Normal file
BIN
strategies/__pycache__/forex.cpython-314.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/ma_cross.cpython-312.pyc
Normal file
BIN
strategies/__pycache__/ma_cross.cpython-312.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/ma_cross.cpython-314.pyc
Normal file
BIN
strategies/__pycache__/ma_cross.cpython-314.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/mean_reversion.cpython-312.pyc
Normal file
BIN
strategies/__pycache__/mean_reversion.cpython-312.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/mean_reversion.cpython-314.pyc
Normal file
BIN
strategies/__pycache__/mean_reversion.cpython-314.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/short_term.cpython-312.pyc
Normal file
BIN
strategies/__pycache__/short_term.cpython-312.pyc
Normal file
Binary file not shown.
BIN
strategies/__pycache__/short_term.cpython-314.pyc
Normal file
BIN
strategies/__pycache__/short_term.cpython-314.pyc
Normal file
Binary file not shown.
@ -9,7 +9,7 @@ from ib_insync import IB, Contract, StopOrder, Trade
|
|||||||
|
|
||||||
from bars import BarManager
|
from bars import BarManager
|
||||||
from config import config
|
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
|
from state import PositionTracker
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -208,7 +208,10 @@ class BaseStrategy(ABC):
|
|||||||
"%s HARD STOP filled: %s x%g @ %.2f",
|
"%s HARD STOP filled: %s x%g @ %.2f",
|
||||||
self.name, key, filled, trade.orderStatus.avgFillPrice,
|
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)
|
self._mark_hard_stop_cooldown(key)
|
||||||
elif owned:
|
elif owned:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -285,7 +288,8 @@ class BaseStrategy(ABC):
|
|||||||
stop_filled, stop_price, confirmed = await self._cancel_stop(key)
|
stop_filled, stop_price, confirmed = await self._cancel_stop(key)
|
||||||
if stop_filled > 0:
|
if stop_filled > 0:
|
||||||
logger.info("%s: stop filled %g during trailing raise", key, stop_filled)
|
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
|
remaining = owned["quantity"] - stop_filled
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -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 bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
|
||||||
from config import config
|
from config import config
|
||||||
from orders import execute_market_order
|
from orders import execute_market_order, trade_commission
|
||||||
from state import PositionTracker
|
from state import PositionTracker
|
||||||
from strategies.base import BaseStrategy
|
from strategies.base import BaseStrategy
|
||||||
|
|
||||||
@ -87,13 +87,13 @@ class ForexMAStrategy(BaseStrategy):
|
|||||||
"FOREX STOP-LOSS SELL: %s close=%.5f entry=%.5f",
|
"FOREX STOP-LOSS SELL: %s close=%.5f entry=%.5f",
|
||||||
pair, last["close"], entry,
|
pair, last["close"], entry,
|
||||||
)
|
)
|
||||||
await self._sell(pair, contract, owned["quantity"])
|
await self._sell(pair, contract, owned["quantity"], "SoftStop")
|
||||||
elif cross_down:
|
elif cross_down:
|
||||||
logger.info(
|
logger.info(
|
||||||
"FOREX SELL %s (fast MA %.5f < slow MA %.5f)",
|
"FOREX SELL %s (fast MA %.5f < slow MA %.5f)",
|
||||||
pair, last["fast_ma"], last["slow_ma"],
|
pair, last["fast_ma"], last["slow_ma"],
|
||||||
)
|
)
|
||||||
await self._sell(pair, contract, owned["quantity"])
|
await self._sell(pair, contract, owned["quantity"], "CrossDown")
|
||||||
else:
|
else:
|
||||||
if cross_up:
|
if cross_up:
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -102,19 +102,20 @@ class ForexMAStrategy(BaseStrategy):
|
|||||||
)
|
)
|
||||||
await self._buy(pair, contract)
|
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}"
|
ref = f"{self.name}:{pair}"
|
||||||
trade = await execute_market_order(self.ib, contract, "BUY", self.units, ref)
|
trade = await execute_market_order(self.ib, contract, "BUY", self.units, ref)
|
||||||
if trade and trade.orderStatus.filled > 0:
|
if trade and trade.orderStatus.filled > 0:
|
||||||
self.tracker.record_buy(
|
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}"
|
ref = f"{self.name}:{pair}"
|
||||||
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
||||||
if trade and trade.orderStatus.filled > 0:
|
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):
|
async def on_tick(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -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 bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
|
||||||
from config import config
|
from config import config
|
||||||
from orders import execute_market_order
|
from orders import execute_market_order, trade_commission
|
||||||
from state import PositionTracker
|
from state import PositionTracker
|
||||||
from strategies.base import BaseStrategy
|
from strategies.base import BaseStrategy
|
||||||
|
|
||||||
@ -125,7 +125,7 @@ class MAStockStrategy(BaseStrategy):
|
|||||||
"STOCK STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
"STOCK STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
||||||
symbol, last["close"], entry, profit_pct,
|
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)
|
self._mark_stop_cooldown(symbol)
|
||||||
elif not fast_above:
|
elif not fast_above:
|
||||||
# re-checked every cycle: exits as soon as profit requirement is met
|
# 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)",
|
"STOCK SELL: %s (fast MA %.2f < slow MA %.2f, profit=%.2f%%, ADX=%.1f)",
|
||||||
symbol, last["fast_ma"], last["slow_ma"], profit_pct, last["adx"],
|
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:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"STOCK SELL WAITING: %s profit %.2f%% < min %.1f%%",
|
"STOCK SELL WAITING: %s profit %.2f%% < min %.1f%%",
|
||||||
@ -158,12 +158,14 @@ class MAStockStrategy(BaseStrategy):
|
|||||||
)
|
)
|
||||||
await self._buy(symbol, contract, qty)
|
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}"
|
ref = f"{self.name}:{symbol}"
|
||||||
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
||||||
if trade and trade.orderStatus.filled > 0:
|
if trade and trade.orderStatus.filled > 0:
|
||||||
self.tracker.record_buy(
|
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():
|
if self._use_hard_stop():
|
||||||
stop_price = self._target_stop_price(
|
stop_price = self._target_stop_price(
|
||||||
@ -177,13 +179,15 @@ class MAStockStrategy(BaseStrategy):
|
|||||||
# cool down to avoid retrying every cycle
|
# cool down to avoid retrying every cycle
|
||||||
self._mark_cooldown(symbol, "order failed")
|
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
|
# cancel the hard stop first; it may have filled in the cancel race
|
||||||
if self._use_hard_stop():
|
if self._use_hard_stop():
|
||||||
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
||||||
if stop_filled > 0:
|
if stop_filled > 0:
|
||||||
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
|
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
|
quantity -= stop_filled
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
return
|
return
|
||||||
@ -198,7 +202,9 @@ class MAStockStrategy(BaseStrategy):
|
|||||||
ref = f"{self.name}:{symbol}"
|
ref = f"{self.name}:{symbol}"
|
||||||
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
||||||
if trade and trade.orderStatus.filled > 0:
|
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):
|
async def on_tick(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -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 bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
|
||||||
from config import config
|
from config import config
|
||||||
from orders import execute_market_order
|
from orders import execute_market_order, trade_commission
|
||||||
from state import PositionTracker
|
from state import PositionTracker
|
||||||
from strategies.base import BaseStrategy
|
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",
|
"MeanRev BUY: %s x%d [%s] RSI=%.1f, close=%.2f, BB_lower=%.2f",
|
||||||
symbol, qty, buy_signal, last["rsi"], last["close"], last["bb_lower"],
|
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:
|
else:
|
||||||
entry = owned["entry_price"]
|
entry = owned["entry_price"]
|
||||||
profit_pct = (last["close"] - entry) / entry * 100
|
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%%)",
|
"MeanRev STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
||||||
symbol, last["close"], entry, profit_pct,
|
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)
|
self._mark_stop_cooldown(symbol)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -181,14 +181,16 @@ class MeanReversionStrategy(BaseStrategy):
|
|||||||
"MeanRev SELL: %s [%s] RSI=%.1f, close=%.2f, entry=%.2f, profit=%.2f%%",
|
"MeanRev SELL: %s [%s] RSI=%.1f, close=%.2f, entry=%.2f, profit=%.2f%%",
|
||||||
symbol, sell_signal, last["rsi"], last["close"], entry, profit_pct,
|
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}"
|
ref = f"{self.name}:{symbol}"
|
||||||
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
||||||
if trade and trade.orderStatus.filled > 0:
|
if trade and trade.orderStatus.filled > 0:
|
||||||
self.tracker.record_buy(
|
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():
|
if self._use_hard_stop():
|
||||||
stop_price = self._target_stop_price(
|
stop_price = self._target_stop_price(
|
||||||
@ -202,13 +204,15 @@ class MeanReversionStrategy(BaseStrategy):
|
|||||||
# cool down to avoid retrying every cycle
|
# cool down to avoid retrying every cycle
|
||||||
self._mark_cooldown(symbol, "order failed")
|
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
|
# cancel the hard stop first; it may have filled in the cancel race
|
||||||
if self._use_hard_stop():
|
if self._use_hard_stop():
|
||||||
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
||||||
if stop_filled > 0:
|
if stop_filled > 0:
|
||||||
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
|
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
|
quantity -= stop_filled
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
return
|
return
|
||||||
@ -223,7 +227,9 @@ class MeanReversionStrategy(BaseStrategy):
|
|||||||
ref = f"{self.name}:{symbol}"
|
ref = f"{self.name}:{symbol}"
|
||||||
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
||||||
if trade and trade.orderStatus.filled > 0:
|
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):
|
async def on_tick(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -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 bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
|
||||||
from config import config
|
from config import config
|
||||||
from orders import execute_market_order
|
from orders import execute_market_order, trade_commission
|
||||||
from state import PositionTracker
|
from state import PositionTracker
|
||||||
from strategies.base import BaseStrategy
|
from strategies.base import BaseStrategy
|
||||||
|
|
||||||
@ -119,7 +119,7 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
|
|||||||
"ShortTerm STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
"ShortTerm STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
||||||
symbol, last["close"], entry, profit_pct,
|
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)
|
self._mark_stop_cooldown(symbol)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
|
|||||||
hold_days = (date.today() - entry_date).days
|
hold_days = (date.today() - entry_date).days
|
||||||
if hold_days >= self.cfg.max_hold_days:
|
if hold_days >= self.cfg.max_hold_days:
|
||||||
logger.info("ShortTerm SELL %s: max hold reached (%d days)", symbol, 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
|
return
|
||||||
|
|
||||||
if not fast_above:
|
if not fast_above:
|
||||||
@ -138,7 +138,7 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
|
|||||||
"ShortTerm SELL: %s (fast EMA %.2f < slow EMA %.2f, profit=%.2f%%)",
|
"ShortTerm SELL: %s (fast EMA %.2f < slow EMA %.2f, profit=%.2f%%)",
|
||||||
symbol, last["fast_ema"], last["slow_ema"], profit_pct,
|
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:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"ShortTerm SELL WAITING: %s profit %.2f%% < min %.1f%%",
|
"ShortTerm SELL WAITING: %s profit %.2f%% < min %.1f%%",
|
||||||
@ -162,12 +162,14 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
|
|||||||
)
|
)
|
||||||
await self._buy(symbol, contract, qty)
|
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}"
|
ref = f"{self.name}:{symbol}"
|
||||||
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
|
||||||
if trade and trade.orderStatus.filled > 0:
|
if trade and trade.orderStatus.filled > 0:
|
||||||
self.tracker.record_buy(
|
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():
|
if self._use_hard_stop():
|
||||||
stop_price = self._target_stop_price(
|
stop_price = self._target_stop_price(
|
||||||
@ -181,13 +183,15 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
|
|||||||
# cool down to avoid retrying every cycle
|
# cool down to avoid retrying every cycle
|
||||||
self._mark_cooldown(symbol, "order failed")
|
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
|
# cancel the hard stop first; it may have filled in the cancel race
|
||||||
if self._use_hard_stop():
|
if self._use_hard_stop():
|
||||||
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
|
||||||
if stop_filled > 0:
|
if stop_filled > 0:
|
||||||
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
|
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
|
quantity -= stop_filled
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
return
|
return
|
||||||
@ -202,7 +206,9 @@ class ShortTermMAVWAPStrategy(BaseStrategy):
|
|||||||
ref = f"{self.name}:{symbol}"
|
ref = f"{self.name}:{symbol}"
|
||||||
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
|
||||||
if trade and trade.orderStatus.filled > 0:
|
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):
|
async def on_tick(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
302
test_offline.py
Normal file
302
test_offline.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user