Files
ramseshk aad4ee8cf6 Add ML dashboard panel, paper trader, time decay model
- /api/ml endpoint: per-model raw vs calibrated, typhoon, spatial features
- ML Predictions card in web dashboard (color-coded, sorted by confidence)
- Typhoon Probabilities card (T1/T3/T8 now/72h/120h)
- PaperTrader: simulated trading with portfolio Kelly, P&L tracking,
  trade history persistence, auto-resolution after 24h
- TimeDecayModel: theta decay for binary options
  sigma(t) = sigma_0 * (T-t)^beta (beta=0.4 for weather)
  Fair price convergence from 50%→model_prob as expiry approaches
- Paper trader CLI: --track (monitor), --report, --simulate-days

Run dashboard: python web_dashboard.py  # see ML panel
Run paper:  python ml/paper_trader.py --simulate-days 30
2026-08-11 11:15:43 +08:00

354 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Paper Trading Simulator for HK Weather Prediction Markets.
Simulates trading against hypothetical market prices using ML model
predictions, tracking P&L, Sharpe ratio, and drawdown over time.
Usage:
python ml/paper_trader.py # Single run
python ml/paper_trader.py --track # Monitor mode (every 6h)
python ml/paper_trader.py --report # Print historical report
"""
import sys
import json
import time
import argparse
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).parent.parent))
from ml.predictor import MLPredictor
from ml.model import TARGET_DEFINITIONS
from strategy.portfolio_kelly import PortfolioKelly
TRADE_LOG = Path(__file__).parent.parent / "data" / "paper_trades.jsonl"
HISTORY_LOG = Path(__file__).parent.parent / "data" / "paper_pnl.csv"
class TimeDecayModel:
"""Models theta decay for binary options approaching resolution.
Near-expiry markets exhibit predictable uncertainty collapse.
The market often overprices uncertainty at intermediate horizons
and suddenly converges to certainty near expiry.
Model: sigma(t) = sigma_0 * (T - t)^beta
Where beta ~ 0.3-0.5 for weather outcomes (slower decay than financial).
"""
def __init__(self):
self.beta = 0.4 # Weather-specific decay exponent
self.min_sigma = 0.02 # Minimum uncertainty at t=0
def decay_factor(self, hours_to_expiry: float, max_horizon: float = 168.0) -> float:
"""
Compute time decay factor for binary option.
At t=max_horizon: factor = 1.0 (maximum uncertainty)
At t=0: factor = min_sigma/sigma_0 (minimum uncertainty)
Returns factor in [0, 1] representing remaining uncertainty fraction.
"""
if hours_to_expiry <= 0:
return self.min_sigma
if hours_to_expiry >= max_horizon:
return 1.0
tau = hours_to_expiry / max_horizon
return self.min_sigma + (1.0 - self.min_sigma) * tau ** self.beta
def fair_price_convergence(
self,
model_probability: float,
hours_to_expiry: float,
market_probability: Optional[float] = None,
) -> dict:
"""
Compute fair price adjusted for time decay.
When hours_to_expiry is large, the model probability should be closer
to 50% (maximum uncertainty). As expiry approaches, it should converge
to either 0% or 100%.
Returns dict with fair_price, uncertainty_band, and edge vs market.
"""
decay = self.decay_factor(hours_to_expiry)
prob_0_1 = model_probability / 100.0
# Fair price: blend between 50% (at t=far) and model_prob (at t=0)
fair_price = 50.0 + (model_probability - 50.0) * (1.0 - decay)
# Uncertainty band: width proportional to remaining time
# At t=0: band = 0 (certain). At t=max: band = 30pp.
if market_probability is not None:
edge = fair_price - market_probability
else:
edge = 0.0
return {
"fair_price": fair_price,
"time_decay_factor": 1.0 - decay,
"edge_vs_market": edge,
"hours_to_expiry": hours_to_expiry,
}
class PaperTrader:
"""Simulate trading using ML predictions and track P&L."""
def __init__(self, bankroll: float = 1000.0, min_edge_bps: float = 50):
self.bankroll = bankroll
self.initial_bankroll = bankroll
self.min_edge_bps = min_edge_bps
self.predictor = MLPredictor(bankroll_usdc=bankroll, min_edge_bps=min_edge_bps)
self.time_decay = TimeDecayModel()
self.portfolio_kelly = PortfolioKelly()
# State
self.positions: Dict[str, dict] = {} # target -> {side, size, entry_prob, entry_time}
self.trade_history: List[dict] = []
self.pnl_history: List[float] = [bankroll]
self.dates: List[str] = [datetime.now().strftime("%Y-%m-%d %H:%M")]
self._load_history()
def run(self, market_prices: Optional[Dict[str, float]] = None, resolution_hours: float = 24.0):
"""Execute a single trading cycle.
Parameters
----------
market_prices : dict
{target_name: market_implied_probability_0_100}
If None, uses simulated prices (model - noise).
resolution_hours : float
Hours until positions auto-resolve (24h default, 0 for immediate).
"""
self.predictor.fetch_and_predict()
if market_prices is None:
market_prices = self._simulate_market_prices()
self._close_resolved(resolution_hours)
self._open_new(market_prices)
self._save()
def _simulate_market_prices(self) -> Dict[str, float]:
"""Simulate market prices with realistic bid-ask spreads."""
prices = {}
predictions = self.predictor._last_predictions or {}
for target in TARGET_DEFINITIONS:
model_p = predictions.get(target, 50.0)
# Market price: model + noise + spread
noise = np.random.normal(0, 8) # 8% stdev market noise
# Systematic bias: market underweights extremes
bias = -0.15 * (model_p - 50)
market_p = model_p + noise + bias
# Random spread: 0.5-3%
spread = np.random.uniform(0.5, 3.0)
# Round to nearest spread tick
market_p = round(market_p / spread) * spread
prices[target] = float(np.clip(market_p, 1, 99))
return prices
def _open_new(self, market_prices: Dict[str, float]):
"""Open new positions based on ML signals."""
predictions = self.predictor._last_predictions or {}
# Collect edges
edges = {}
for target, model_p in predictions.items():
mkt_p = market_prices.get(target, 50.0)
edge_decimal = (model_p - mkt_p) / 100.0
if abs(edge_decimal * 100) >= self.min_edge_bps:
edges[target] = edge_decimal
if not edges:
return
# Portfolio Kelly sizing
sizes = self.portfolio_kelly.simultaneous_kelly(edges, bankroll=self.bankroll)
for target, size in sizes.items():
if size < 1.0 or target in self.positions:
continue
model_p = predictions[target]
mkt_p = market_prices.get(target, 50.0)
side = "buy_yes" if model_p > mkt_p else "buy_no"
position = {
"target": target,
"side": side,
"size_usdc": size,
"entry_prob": model_p,
"entry_market": mkt_p,
"entry_time": datetime.now().isoformat(),
"entry_roll": self.bankroll,
}
self.positions[target] = position
self.trade_history.append({
"action": "open",
"time": datetime.now().isoformat(),
**position,
})
def _close_resolved(self, resolution_hours: float = 24.0):
"""Close positions where outcomes are known."""
closed = []
for target, pos in list(self.positions.items()):
# Simulate outcome resolution after 24h
entry_time = datetime.fromisoformat(pos["entry_time"])
hours_open = (datetime.now() - entry_time).total_seconds() / 3600
if hours_open >= resolution_hours:
# Random resolution biased by our probability
our_p = pos["entry_prob"] / 100.0
won = np.random.random() < our_p
if pos["side"] == "buy_yes":
profit = pos["size_usdc"] * ((1 - pos["entry_market"] / 100) / (pos["entry_market"] / 100)) if won else -pos["size_usdc"]
else:
mkt_no = 100 - pos["entry_market"]
profit = pos["size_usdc"] * ((1 - mkt_no / 100) / (mkt_no / 100)) if won else -pos["size_usdc"]
self.bankroll += profit
self.pnl_history.append(self.bankroll)
self.dates.append(datetime.now().strftime("%Y-%m-%d %H:%M"))
self.trade_history.append({
"action": "close",
"time": datetime.now().isoformat(),
"target": target,
"won": won,
"profit_usdc": profit,
"bankroll_after": self.bankroll,
"hours_open": hours_open,
})
closed.append(target)
for target in closed:
del self.positions[target]
def report(self) -> str:
"""Generate performance report."""
if len(self.pnl_history) < 2:
return "No trading history yet."
pnl = np.array(self.pnl_history)
returns = np.diff(pnl) / (pnl[:-1] + 1e-9)
total_trades = len([t for t in self.trade_history if t["action"] == "close"])
wins = len([t for t in self.trade_history if t["action"] == "close" and t.get("won")])
losses = total_trades - wins
sharpe = np.mean(returns) / max(np.std(returns), 1e-9) * np.sqrt(365) if len(returns) > 1 else 0
max_dd = max(1 - np.minimum.accumulate(pnl) / np.maximum.accumulate(pnl)) * 100
roi = (self.bankroll - self.initial_bankroll) / self.initial_bankroll * 100
lines = [
f"=== Paper Trading Report ({datetime.now():%Y-%m-%d %H:%M}) ===",
f" Bankroll: ${self.initial_bankroll:.0f} → ${self.bankroll:.0f} ({roi:+.1f}%)",
f" Trades: {total_trades} ({wins}W/{losses}L, {wins/max(total_trades,1)*100:.0f}% win)",
f" Sharpe: {sharpe:.2f}",
f" Max DD: {max_dd:.1f}%",
f" Open positions: {len(self.positions)}",
]
if self.positions:
lines.append(f" Open:")
for target, pos in self.positions.items():
lines.append(f" {target}: {pos['side']} ${pos['size_usdc']:.0f} @ {pos['entry_prob']:.0f}%")
return "\n".join(lines)
def _save(self):
"""Persist trade state."""
TRADE_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(TRADE_LOG, "w") as f:
for t in self.trade_history:
f.write(json.dumps(t) + "\n")
pd.DataFrame({
"date": self.dates,
"bankroll": self.pnl_history,
}).to_csv(HISTORY_LOG, index=False)
def _load_history(self):
"""Load previous trading history."""
if HISTORY_LOG.exists():
try:
df = pd.read_csv(HISTORY_LOG)
self.pnl_history = df["bankroll"].tolist()
self.dates = df["date"].tolist()
self.bankroll = self.pnl_history[-1] if self.pnl_history else self.initial_bankroll
except Exception:
pass
if TRADE_LOG.exists():
try:
with open(TRADE_LOG) as f:
for line in f:
if line.strip():
self.trade_history.append(json.loads(line))
except Exception:
pass
def main():
parser = argparse.ArgumentParser(description="Paper trading simulator")
parser.add_argument("--bankroll", type=float, default=1000.0)
parser.add_argument("--edge", type=float, default=50, help="Min edge in bps")
parser.add_argument("--track", action="store_true", help="Run continuously")
parser.add_argument("--report", action="store_true", help="Print report and exit")
parser.add_argument("--simulate-days", type=int, default=0, help="Simulate N days of trading")
args = parser.parse_args()
trader = PaperTrader(bankroll=args.bankroll, min_edge_bps=args.edge)
if args.report:
print(trader.report())
return
if args.simulate_days > 0:
print(f"Simulating {args.simulate_days} days of trading...")
for i in range(args.simulate_days):
trader.run()
if (i + 1) % 10 == 0:
print(f" Day {i+1}/{args.simulate_days} | Bankroll: ${trader.bankroll:.0f}")
print(trader.report())
return
if args.track:
print(f"Paper trading monitor starting. Bankroll: ${args.bankroll:.0f}")
print("Running every 6 hours. Ctrl+C to stop.\n")
trader.run()
print(trader.report())
while True:
try:
time.sleep(6 * 3600)
trader.run()
print(trader.report())
except KeyboardInterrupt:
print("\nStopping monitor.")
print(trader.report())
break
else:
trader.run()
print(trader.report())
if __name__ == "__main__":
main()