You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

337 lines
14 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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