|
|
"""2-week paper trading simulation using real pipeline DB + MockBroker.
|
|
|
|
|
|
Simulates what would have happened during March 8-21, 2026 for sessions v236 and v4.41.
|
|
|
Uses real EventDetector (connects to PostgreSQL pipeline DB) but replaces Alpaca with
|
|
|
a MockBroker that fills orders using Oracle historical price data.
|
|
|
|
|
|
Usage:
|
|
|
python -m apps.tools.simulate_2week
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import asyncio
|
|
|
import datetime as dt
|
|
|
import os
|
|
|
import sys
|
|
|
import tempfile
|
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
|
|
|
|
# ── Load .env ─────────────────────────────────────────────────────────────────
|
|
|
_ENV_FILE = Path(__file__).parent.parent.parent / ".env"
|
|
|
if _ENV_FILE.exists():
|
|
|
for _line in _ENV_FILE.read_text().splitlines():
|
|
|
_line = _line.strip()
|
|
|
if _line and not _line.startswith("#") and "=" in _line:
|
|
|
_k, _, _v = _line.partition("=")
|
|
|
os.environ.setdefault(_k.strip(), _v.strip())
|
|
|
|
|
|
# ── Verify Oracle is reachable before heavy imports ───────────────────────────
|
|
|
_ORACLE_URL = os.environ.get("STOCK_ORACLE_URL", "http://localhost:18001")
|
|
|
_POSTGRES_DSN = os.environ.get("POSTGRES_DSN", "")
|
|
|
|
|
|
from rich.console import Console
|
|
|
from rich.table import Table
|
|
|
from rich import box as rbox
|
|
|
|
|
|
console = Console(width=120)
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
# MockBroker + helpers (imported from mock_broker module)
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
from apps.paper_trader.mock_broker import MockBroker, get_event_symbols_from_db, prefetch_bars as _prefetch_bars
|
|
|
|
|
|
|
|
|
async def prefetch_bars(
|
|
|
symbols: list[str],
|
|
|
start: dt.date,
|
|
|
end: dt.date,
|
|
|
oracle_url: str,
|
|
|
) -> dict[str, dict[dt.date, dict]]:
|
|
|
return await _prefetch_bars(symbols, start, end, oracle_url, console=console)
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
# Simulation runner
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def run_simulation(
|
|
|
session_name: str,
|
|
|
config_path: str,
|
|
|
initial_equity: float,
|
|
|
trading_days: list[dt.date],
|
|
|
bars_cache: dict[str, dict[dt.date, dict]],
|
|
|
db_dsn: str,
|
|
|
oracle_url: str,
|
|
|
) -> dict[str, Any]:
|
|
|
"""Run phased simulation for one session. Returns summary."""
|
|
|
import tempfile
|
|
|
from apps.paper_trader.state import StateManager, SessionRow
|
|
|
from apps.paper_trader.event_detector import EventDetector
|
|
|
from apps.paper_trader.engine import PaperTradingEngine
|
|
|
|
|
|
# Temp SQLite DB (won't touch production DB)
|
|
|
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
|
tmp.close()
|
|
|
tmp_db = tmp.name
|
|
|
|
|
|
state = StateManager(tmp_db)
|
|
|
session_id = state.create_session(
|
|
|
session_name=session_name,
|
|
|
config_path=config_path,
|
|
|
initial_equity=initial_equity,
|
|
|
)
|
|
|
session = state.get_session(session_id)
|
|
|
|
|
|
broker = MockBroker(initial_equity=initial_equity, bars_cache=bars_cache)
|
|
|
detector = EventDetector(db_dsn=db_dsn, oracle_url=oracle_url)
|
|
|
engine = PaperTradingEngine(
|
|
|
session=session,
|
|
|
broker=broker,
|
|
|
state=state,
|
|
|
event_detector=detector,
|
|
|
)
|
|
|
|
|
|
all_entries: list[dict] = []
|
|
|
all_exits: list[dict] = []
|
|
|
daily_summaries: list[dict] = []
|
|
|
|
|
|
console.print(f"\n[bold cyan]Session: {session_name}[/] config: {config_path}")
|
|
|
console.print(f" Equity: ${initial_equity:,.0f} | Days: {trading_days[0]} → {trading_days[-1]}\n")
|
|
|
|
|
|
for day in trading_days:
|
|
|
# ── run-open: exits + after-close entries ──────────────────────────
|
|
|
broker.set_sim_context(day, "open")
|
|
|
open_result = await engine.run_next_open(target_date=day, force=True)
|
|
|
|
|
|
# ── run-close: same-day entries ────────────────────────────────────
|
|
|
broker.set_sim_context(day, "close")
|
|
|
close_result = await engine.run_reaction_close(target_date=day, force=True)
|
|
|
|
|
|
acct = broker.get_account()
|
|
|
exits_today = open_result.get("exits", [])
|
|
|
entries_open = open_result.get("entries", [])
|
|
|
entries_close = close_result.get("entries", [])
|
|
|
candidates_open = open_result.get("candidates_detected", 0)
|
|
|
candidates_close = close_result.get("candidates_detected", 0)
|
|
|
rejected_open = open_result.get("rejected", [])
|
|
|
rejected_close = close_result.get("rejected", [])
|
|
|
|
|
|
for e in exits_today:
|
|
|
e["exit_date"] = day.isoformat()
|
|
|
for e in entries_open:
|
|
|
e["entry_date"] = day.isoformat()
|
|
|
e["convention"] = "next_open"
|
|
|
for e in entries_close:
|
|
|
e["entry_date"] = day.isoformat()
|
|
|
e["convention"] = "reaction_close"
|
|
|
|
|
|
all_exits.extend(exits_today)
|
|
|
all_entries.extend(entries_open + entries_close)
|
|
|
|
|
|
summary = {
|
|
|
"date": day,
|
|
|
"equity": acct.equity,
|
|
|
"cash": acct.cash,
|
|
|
"market_value": acct.long_market_value,
|
|
|
"positions": len(broker.list_positions()),
|
|
|
"exits": len(exits_today),
|
|
|
"entries": len(entries_open) + len(entries_close),
|
|
|
"candidates": candidates_open + candidates_close,
|
|
|
"rejected": len(rejected_open) + len(rejected_close),
|
|
|
}
|
|
|
daily_summaries.append(summary)
|
|
|
|
|
|
pnl_today = sum(e.get("pnl", 0) for e in exits_today)
|
|
|
day_str = f"[bold]{day.strftime('%a %m/%d')}[/]"
|
|
|
eq_str = f"${acct.equity:,.2f}"
|
|
|
entries_str = f"{len(entries_open)+len(entries_close)} entries"
|
|
|
exits_str = f"{len(exits_today)} exits"
|
|
|
cand_str = f"{candidates_open + candidates_close} candidates"
|
|
|
pos_str = f"{len(broker.list_positions())} pos"
|
|
|
|
|
|
details = []
|
|
|
for e in entries_open + entries_close:
|
|
|
details.append(f" [green] ↳ ENTER {e['symbol']} ({e.get('event_type','?')}, score={e.get('score', 0):.2f}) "
|
|
|
f"× {e['shares']} shares [{e['convention']}][/]")
|
|
|
for e in exits_today:
|
|
|
pnl_col = "green" if e.get("pnl", 0) >= 0 else "red"
|
|
|
details.append(f" [{pnl_col}] ↳ EXIT {e['symbol']} ({e['reason']}) "
|
|
|
f"P&L=${e.get('pnl', 0):+,.2f}[/{pnl_col}]")
|
|
|
for r in (rejected_open + rejected_close)[:3]: # show first 3 rejections
|
|
|
details.append(f" [dim] ↳ skip {r['symbol']} → {r['reason']}[/]")
|
|
|
|
|
|
console.print(f" {day_str} {eq_str} {entries_str} {exits_str} {cand_str} {pos_str}")
|
|
|
for d in details:
|
|
|
console.print(d)
|
|
|
|
|
|
# ── Final summary ──────────────────────────────────────────────────────
|
|
|
acct_final = broker.get_account()
|
|
|
total_pnl = acct_final.equity - initial_equity
|
|
|
pct = total_pnl / initial_equity * 100
|
|
|
|
|
|
console.print(f"\n [bold]Final:[/] equity=${acct_final.equity:,.2f} "
|
|
|
f"P&L=${total_pnl:+,.2f} ({pct:+.2f}%) "
|
|
|
f"trades={len(all_entries)} entries / {len(all_exits)} exits")
|
|
|
|
|
|
# Print trade table
|
|
|
if all_entries or all_exits:
|
|
|
tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 1))
|
|
|
tbl.add_column("Date", style="dim")
|
|
|
tbl.add_column("Action", style="bold")
|
|
|
tbl.add_column("Symbol")
|
|
|
tbl.add_column("Event Type")
|
|
|
tbl.add_column("Shares", justify="right")
|
|
|
tbl.add_column("Score", justify="right")
|
|
|
tbl.add_column("Convention / Reason")
|
|
|
|
|
|
for e in sorted(all_entries, key=lambda x: x["entry_date"]):
|
|
|
tbl.add_row(
|
|
|
e["entry_date"], "[green]ENTER[/]", e["symbol"],
|
|
|
e.get("event_type", "?"),
|
|
|
str(e.get("shares", "?")),
|
|
|
f"{e.get('score', 0):.2f}",
|
|
|
e.get("convention", "?"),
|
|
|
)
|
|
|
for e in sorted(all_exits, key=lambda x: x.get("exit_date", "")):
|
|
|
pnl = e.get("pnl", 0)
|
|
|
pnl_col = "green" if pnl >= 0 else "red"
|
|
|
tbl.add_row(
|
|
|
e.get("exit_date", "?"), f"[{pnl_col}]EXIT[/{pnl_col}]", e["symbol"],
|
|
|
"",
|
|
|
str(abs(int(e.get("shares", 0)))),
|
|
|
f"[{pnl_col}]{pnl:+,.2f}[/{pnl_col}]",
|
|
|
e.get("reason", "?"),
|
|
|
)
|
|
|
console.print(tbl)
|
|
|
|
|
|
# Clean up temp DB
|
|
|
try:
|
|
|
Path(tmp_db).unlink()
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
return {
|
|
|
"session": session_name,
|
|
|
"total_pnl": total_pnl,
|
|
|
"total_pnl_pct": pct,
|
|
|
"entries": len(all_entries),
|
|
|
"exits": len(all_exits),
|
|
|
"final_equity": acct_final.equity,
|
|
|
"daily": daily_summaries,
|
|
|
}
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
# Main
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
SESSIONS = [
|
|
|
{
|
|
|
"name": "v236",
|
|
|
"config": "configs/experiments/return_max_long_v1.236.json",
|
|
|
"equity": 10000.0,
|
|
|
},
|
|
|
{
|
|
|
"name": "v4.41",
|
|
|
"config": "configs/experiments/return_max_long_v4.41.json",
|
|
|
"equity": 10000.0,
|
|
|
},
|
|
|
]
|
|
|
|
|
|
SIM_START = dt.date(2026, 2, 22)
|
|
|
SIM_END = dt.date(2026, 3, 21)
|
|
|
|
|
|
|
|
|
async def main() -> None:
|
|
|
from libs.common.time_utils import is_trading_day
|
|
|
|
|
|
console.print("[bold cyan]═══ 1-Month Paper Trading Simulation (Feb 22 – Mar 21, 2026) ═══[/]")
|
|
|
console.print(f"Oracle URL : {_ORACLE_URL}")
|
|
|
console.print(f"Postgres : {_POSTGRES_DSN[:50]}..." if len(_POSTGRES_DSN) > 50 else f"Postgres : {_POSTGRES_DSN}")
|
|
|
|
|
|
# ── Trading days ───────────────────────────────────────────────────────
|
|
|
all_days = [
|
|
|
SIM_START + dt.timedelta(days=i)
|
|
|
for i in range((SIM_END - SIM_START).days + 1)
|
|
|
]
|
|
|
trading_days = [d for d in all_days if is_trading_day(d)]
|
|
|
console.print(f"Trading days: {[d.isoformat() for d in trading_days]}\n")
|
|
|
|
|
|
# ── Get symbols from pipeline DB ───────────────────────────────────────
|
|
|
console.print("[bold]Fetching event symbols from pipeline DB...[/]")
|
|
|
if not _POSTGRES_DSN:
|
|
|
console.print("[red]ERROR: POSTGRES_DSN not set! Cannot query pipeline DB.[/]")
|
|
|
sys.exit(1)
|
|
|
|
|
|
event_symbols = await get_event_symbols_from_db(
|
|
|
_POSTGRES_DSN,
|
|
|
start_date=SIM_START - dt.timedelta(days=7), # buffer for label lag
|
|
|
end_date=SIM_END,
|
|
|
console=console,
|
|
|
)
|
|
|
console.print(f" Found {len(event_symbols)} unique symbols in events")
|
|
|
|
|
|
# ── Pre-fetch bars ─────────────────────────────────────────────────────
|
|
|
all_symbols = sorted(set(event_symbols + ["SPY", "QQQ"]))
|
|
|
bar_start = SIM_START - dt.timedelta(days=90) # enough history for exits + macro SMA
|
|
|
|
|
|
console.print(f"\n[bold]Pre-fetching bars for {len(all_symbols)} symbols ({bar_start} → {SIM_END})...[/]")
|
|
|
bars_cache = await prefetch_bars(all_symbols, bar_start, SIM_END, _ORACLE_URL)
|
|
|
fetched = sum(1 for v in bars_cache.values() if v)
|
|
|
console.print(f" Fetched bars for {fetched}/{len(all_symbols)} symbols")
|
|
|
|
|
|
if fetched == 0:
|
|
|
console.print("[red]ERROR: No bars fetched. Is Oracle running at {_ORACLE_URL}?[/]")
|
|
|
sys.exit(1)
|
|
|
|
|
|
# ── Suppress verbose logs during simulation ────────────────────────────
|
|
|
import logging
|
|
|
logging.getLogger("apps.paper_trader").setLevel(logging.WARNING)
|
|
|
logging.getLogger("libs.backtest").setLevel(logging.WARNING)
|
|
|
|
|
|
# ── Run simulations ────────────────────────────────────────────────────
|
|
|
console.print("\n[bold]Running simulations...[/]\n" + "═" * 80)
|
|
|
|
|
|
results = []
|
|
|
for sess in SESSIONS:
|
|
|
result = await run_simulation(
|
|
|
session_name=sess["name"],
|
|
|
config_path=sess["config"],
|
|
|
initial_equity=sess["equity"],
|
|
|
trading_days=trading_days,
|
|
|
bars_cache=bars_cache,
|
|
|
db_dsn=_POSTGRES_DSN,
|
|
|
oracle_url=_ORACLE_URL,
|
|
|
)
|
|
|
results.append(result)
|
|
|
console.print("─" * 80)
|
|
|
|
|
|
# ── Final comparison table ─────────────────────────────────────────────
|
|
|
console.print("\n[bold cyan]═══ Final Results ═══[/]")
|
|
|
tbl = Table(box=rbox.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 2))
|
|
|
tbl.add_column("Session")
|
|
|
tbl.add_column("Final Equity", justify="right")
|
|
|
tbl.add_column("P&L", justify="right")
|
|
|
tbl.add_column("P&L %", justify="right")
|
|
|
tbl.add_column("Entries", justify="right")
|
|
|
tbl.add_column("Exits", justify="right")
|
|
|
|
|
|
for r in results:
|
|
|
pnl_col = "green" if r["total_pnl"] >= 0 else "red"
|
|
|
tbl.add_row(
|
|
|
r["session"],
|
|
|
f"${r['final_equity']:,.2f}",
|
|
|
f"[{pnl_col}]${r['total_pnl']:+,.2f}[/{pnl_col}]",
|
|
|
f"[{pnl_col}]{r['total_pnl_pct']:+.2f}%[/{pnl_col}]",
|
|
|
str(r["entries"]),
|
|
|
str(r["exits"]),
|
|
|
)
|
|
|
console.print(tbl)
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
asyncio.run(main())
|