feat(phase-3): paper-trading runner BTC+ETH
Modulo paper_trading per forward-test virtuale Phase 3: - Portfolio multi-asset equal-weight sleeve, fees bp su round-trip - PaperExecutor compila strategia JSON e applica segnale a bar close - PaperRepository persiste runs/ticks/trades/equity in runs.db - CLI scripts/run_paper_trading.py: loop polling Cerbero, exec su nuovo bar Strategie deployate: - BTC fb63e851 (Sharpe OOS +0.50, mean rev RSI+ATR+hour gate) - ETH facd6af85d5d (Sharpe OOS +0.19, trend vol regime + SMA50/200) Capitale virtuale $1000 (sleeve $500 ciascuno), 2 settimane smoke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""PaperExecutor: applica un segnale di strategia a un Portfolio.
|
||||
|
||||
Il flusso per ogni tick:
|
||||
|
||||
bar OHLCV chiuso -> compile_strategy(strategy) -> Series[Side]
|
||||
-> last_signal = series.iloc[-1]
|
||||
-> match con posizione attuale -> open / close / hold
|
||||
|
||||
Niente delay 1-bar: in paper-trading il segnale viene calcolato sulla
|
||||
barra appena chiusa e applicato al prezzo close della stessa. La latenza
|
||||
reale tra tick e ordine va misurata separatamente (Phase 3 spec).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd # type: ignore[import-untyped]
|
||||
|
||||
from ..backtest.orders import Side, Trade
|
||||
from ..protocol.compiler import compile_strategy
|
||||
from ..protocol.parser import parse_strategy
|
||||
from .portfolio import OpenPosition, Portfolio
|
||||
|
||||
|
||||
@dataclass
|
||||
class TickResult:
|
||||
ts: datetime
|
||||
symbol: str
|
||||
bar_ts: datetime
|
||||
close_price: float
|
||||
signal: Side
|
||||
action_taken: str # "open_long" | "open_short" | "close" | "reverse" | "hold"
|
||||
trade: Trade | None = None
|
||||
new_position: OpenPosition | None = None
|
||||
|
||||
|
||||
class PaperExecutor:
|
||||
def __init__(self, strategy_json_path: Path, symbol: str) -> None:
|
||||
text = strategy_json_path.read_text()
|
||||
# parse_strategy si aspetta JSON pulito, non fence; il file e' gia' JSON.
|
||||
self._strategy = parse_strategy(text)
|
||||
self._compiled = compile_strategy(self._strategy)
|
||||
self.symbol = symbol
|
||||
self.strategy_path = strategy_json_path
|
||||
|
||||
def execute_tick(
|
||||
self,
|
||||
portfolio: Portfolio,
|
||||
ohlcv: pd.DataFrame,
|
||||
now: datetime,
|
||||
) -> TickResult:
|
||||
"""Esegui un tick: calcola segnale su tutto ``ohlcv`` (per indicatori
|
||||
con lookback), prendi l'ultimo, e applica al portfolio."""
|
||||
if len(ohlcv) == 0:
|
||||
raise ValueError("Empty OHLCV passed to execute_tick")
|
||||
signals = self._compiled(ohlcv)
|
||||
# ultimo bar chiuso
|
||||
bar_ts = ohlcv.index[-1]
|
||||
close_price = float(ohlcv["close"].iloc[-1])
|
||||
signal = Side(signals.iloc[-1]) if signals.iloc[-1] is not None else Side.FLAT
|
||||
|
||||
current = portfolio.positions.get(self.symbol)
|
||||
action = "hold"
|
||||
trade: Trade | None = None
|
||||
new_position: OpenPosition | None = None
|
||||
|
||||
if current is None and signal != Side.FLAT:
|
||||
new_position = portfolio.open(self.symbol, signal, close_price, now)
|
||||
action = f"open_{signal.value}"
|
||||
elif current is not None and signal == Side.FLAT:
|
||||
trade = portfolio.close(self.symbol, close_price, now)
|
||||
action = "close"
|
||||
elif current is not None and signal != current.side:
|
||||
# reverse: chiudi e riapri opposto
|
||||
trade = portfolio.close(self.symbol, close_price, now)
|
||||
new_position = portfolio.open(self.symbol, signal, close_price, now)
|
||||
action = "reverse"
|
||||
|
||||
return TickResult(
|
||||
ts=now,
|
||||
symbol=self.symbol,
|
||||
bar_ts=bar_ts.to_pydatetime() if hasattr(bar_ts, "to_pydatetime") else bar_ts,
|
||||
close_price=close_price,
|
||||
signal=signal,
|
||||
action_taken=action,
|
||||
trade=trade,
|
||||
new_position=new_position,
|
||||
)
|
||||
|
||||
@property
|
||||
def strategy_dict(self) -> dict:
|
||||
return json.loads(self.strategy_path.read_text())
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Persistenza paper-trading: usa lo stesso ``runs.db`` con tabelle dedicate
|
||||
``paper_trading_*`` (vedi :mod:`multi_swarm.persistence.schema`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .executor import TickResult
|
||||
from .portfolio import Portfolio
|
||||
|
||||
|
||||
class PaperRepository:
|
||||
def __init__(self, db_path: Path | str):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
def create_run(self, name: str, initial_capital: float, config: dict[str, Any]) -> str:
|
||||
rid = uuid.uuid4().hex
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_runs "
|
||||
"(id, name, started_at, status, initial_capital, config_json) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(rid, name, self._now(), "running", initial_capital, json.dumps(config)),
|
||||
)
|
||||
return rid
|
||||
|
||||
def stop_run(self, run_id: str, status: str = "stopped") -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE paper_trading_runs SET stopped_at=?, status=? WHERE id=?",
|
||||
(self._now(), status, run_id),
|
||||
)
|
||||
|
||||
def save_tick(self, run_id: str, tick: TickResult) -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_ticks "
|
||||
"(paper_run_id, symbol, ts, bar_ts, close_price, signal, action_taken) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
run_id,
|
||||
tick.symbol,
|
||||
tick.ts.isoformat(),
|
||||
tick.bar_ts.isoformat() if hasattr(tick.bar_ts, "isoformat") else str(tick.bar_ts),
|
||||
tick.close_price,
|
||||
tick.signal.value,
|
||||
tick.action_taken,
|
||||
),
|
||||
)
|
||||
if tick.trade is not None:
|
||||
t = tick.trade
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_trades "
|
||||
"(paper_run_id, symbol, side, qty, entry_price, exit_price, "
|
||||
"entry_ts, exit_ts, pnl, fees) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
run_id,
|
||||
tick.symbol,
|
||||
t.side.value,
|
||||
t.size,
|
||||
t.entry_price,
|
||||
t.exit_price,
|
||||
t.entry_ts.isoformat(),
|
||||
t.exit_ts.isoformat(),
|
||||
t.net_pnl,
|
||||
t.fees,
|
||||
),
|
||||
)
|
||||
|
||||
def save_equity_snapshot(
|
||||
self,
|
||||
run_id: str,
|
||||
ts: datetime,
|
||||
equity: float,
|
||||
cash: float,
|
||||
positions_value: float,
|
||||
) -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_equity "
|
||||
"(paper_run_id, ts, equity, cash, positions_value) VALUES (?,?,?,?,?)",
|
||||
(run_id, ts.isoformat(), equity, cash, positions_value),
|
||||
)
|
||||
|
||||
def sync_open_positions(self, run_id: str, portfolio: Portfolio) -> None:
|
||||
"""Sostituisce snapshot posizioni aperte. Idempotente: cancella e reinserisce."""
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM paper_trading_positions WHERE paper_run_id=?", (run_id,)
|
||||
)
|
||||
for sym, pos in portfolio.positions.items():
|
||||
conn.execute(
|
||||
"INSERT INTO paper_trading_positions "
|
||||
"(paper_run_id, symbol, side, qty, entry_price, entry_ts) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(run_id, sym, pos.side.value, pos.qty, pos.entry_price, pos.entry_ts.isoformat()),
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Portfolio multi-asset per paper-trading.
|
||||
|
||||
Modello semplificato: capitale unico ``cash``, allocazione equal-weight
|
||||
fra N posizioni (sleeve = 1/N del capitale iniziale per ogni simbolo).
|
||||
Niente leva, niente liquidation, fees su entry+exit (bp del notional).
|
||||
|
||||
Una :class:`Position` rappresenta una posizione aperta su un singolo
|
||||
simbolo (long/short, qty in unita' dell'asset, prezzo di entry). La
|
||||
posizione viene chiusa con :meth:`Portfolio.close` che produce un
|
||||
:class:`Trade` realized e accredita ``cash``.
|
||||
|
||||
Mark-to-market via :meth:`Portfolio.equity`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from ..backtest.orders import Side, Trade
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenPosition:
|
||||
symbol: str
|
||||
side: Side
|
||||
qty: float
|
||||
entry_price: float
|
||||
entry_ts: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class Portfolio:
|
||||
initial_capital: float
|
||||
fees_bp: float = 5.0
|
||||
n_sleeves: int = 2 # numero strategie / asset previsti
|
||||
cash: float = field(init=False)
|
||||
positions: dict[str, OpenPosition] = field(default_factory=dict)
|
||||
closed_trades: list[Trade] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.cash = self.initial_capital
|
||||
|
||||
@property
|
||||
def sleeve_capital(self) -> float:
|
||||
return self.initial_capital / self.n_sleeves
|
||||
|
||||
def open(
|
||||
self,
|
||||
symbol: str,
|
||||
side: Side,
|
||||
price: float,
|
||||
ts: datetime,
|
||||
) -> OpenPosition:
|
||||
if symbol in self.positions:
|
||||
raise ValueError(f"Position already open on {symbol}")
|
||||
if side == Side.FLAT:
|
||||
raise ValueError("Cannot open a FLAT position")
|
||||
# sleeve fisso: alloca 1/n_sleeves del capitale iniziale, qty = notional/price.
|
||||
notional = self.sleeve_capital
|
||||
qty = notional / price
|
||||
fees = notional * (self.fees_bp / 10000.0)
|
||||
self.cash -= fees
|
||||
pos = OpenPosition(symbol=symbol, side=side, qty=qty, entry_price=price, entry_ts=ts)
|
||||
self.positions[symbol] = pos
|
||||
return pos
|
||||
|
||||
def close(
|
||||
self,
|
||||
symbol: str,
|
||||
price: float,
|
||||
ts: datetime,
|
||||
) -> Trade:
|
||||
if symbol not in self.positions:
|
||||
raise ValueError(f"No open position on {symbol}")
|
||||
pos = self.positions.pop(symbol)
|
||||
trade = Trade(
|
||||
entry_ts=pos.entry_ts,
|
||||
exit_ts=ts,
|
||||
side=pos.side,
|
||||
size=pos.qty,
|
||||
entry_price=pos.entry_price,
|
||||
exit_price=price,
|
||||
fees_bp=self.fees_bp,
|
||||
)
|
||||
# net_pnl include gia' i fees sull'intero round-trip; abbiamo gia'
|
||||
# addebitato meta' fees all'open, ora addebitiamo il resto.
|
||||
self.cash += trade.gross_pnl - (trade.fees / 2.0)
|
||||
self.closed_trades.append(trade)
|
||||
return trade
|
||||
|
||||
def equity(self, last_prices: dict[str, float]) -> tuple[float, float]:
|
||||
"""Ritorna (equity_totale, positions_value) marcando posizioni aperte
|
||||
al ``last_prices[symbol]``. Posizioni senza prezzo disponibile valgono
|
||||
notional di entry (fallback conservativo)."""
|
||||
positions_value = 0.0
|
||||
for sym, pos in self.positions.items():
|
||||
price = last_prices.get(sym, pos.entry_price)
|
||||
unreal = pos.qty * (
|
||||
price - pos.entry_price if pos.side == Side.LONG
|
||||
else pos.entry_price - price
|
||||
)
|
||||
positions_value += pos.qty * pos.entry_price + unreal
|
||||
return self.cash + positions_value, positions_value
|
||||
@@ -77,7 +77,68 @@ CREATE TABLE IF NOT EXISTS adversarial_findings (
|
||||
FOREIGN KEY (run_id) REFERENCES runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
stopped_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
initial_capital REAL NOT NULL,
|
||||
config_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_positions (
|
||||
paper_run_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
qty REAL NOT NULL,
|
||||
entry_price REAL NOT NULL,
|
||||
entry_ts TEXT NOT NULL,
|
||||
PRIMARY KEY (paper_run_id, symbol),
|
||||
FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
paper_run_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
qty REAL NOT NULL,
|
||||
entry_price REAL NOT NULL,
|
||||
exit_price REAL NOT NULL,
|
||||
entry_ts TEXT NOT NULL,
|
||||
exit_ts TEXT NOT NULL,
|
||||
pnl REAL NOT NULL,
|
||||
fees REAL NOT NULL,
|
||||
FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_equity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
paper_run_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
equity REAL NOT NULL,
|
||||
cash REAL NOT NULL,
|
||||
positions_value REAL NOT NULL,
|
||||
FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paper_trading_ticks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
paper_run_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
bar_ts TEXT NOT NULL,
|
||||
close_price REAL NOT NULL,
|
||||
signal TEXT NOT NULL,
|
||||
action_taken TEXT NOT NULL,
|
||||
FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_evaluations_fitness ON evaluations(run_id, fitness DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_genomes_generation ON genomes(run_id, generation_idx);
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_run ON cost_records(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_paper_trades_run ON paper_trading_trades(paper_run_id, exit_ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_paper_equity_run ON paper_trading_equity(paper_run_id, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_paper_ticks_run ON paper_trading_ticks(paper_run_id, ts);
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user