"""PAPER TRADER — TP01 Trend Portfolio (PORT LF4h), forward-only, simulato. Esegue la strategia VINCENTE (src/strategies/trend_portfolio.py, config CANONICAL) in paper trading FORWARD-ONLY su capitale virtuale (default 2000 USDT), portafoglio 50/50 BTC+ETH a 4h. Stato persistente -> resume al riavvio. DESIGN (onesto, niente esecuzione reale: l'esecuzione e' DISABILITATA nel progetto): - Legge i parquet certificati locali (data/raw, BTC/ETH 1h) e resampla a 4h. - Alla prima esecuzione parte dall'ultima barra 4h CHIUSA disponibile (forward-only: NON include lo storico nel PnL di paper, traccia solo da ora in avanti). - Ad ogni run processa le NUOVE barre 4h chiuse dall'ultima volta: applica il rendimento della posizione tenuta, addebita le fee sul turnover, registra i trade sui cambi di posizione, poi ricalcola la posizione-bersaglio (decisa con dati <= ultima barra chiusa). - Per avere barre fresche, aggiornare prima i dati: uv run python scripts/analysis/rebuild_history.py --asset BTC ETH Stato: data/paper_trend/state.json + trades.jsonl (append-only). uv run python scripts/live/paper_trend.py # avanza il paper col dato disponibile uv run python scripts/live/paper_trend.py --status # solo stato, non avanza uv run python scripts/live/paper_trend.py --reset # azzera lo stato (riparte da ora) """ from __future__ import annotations import json import sys from pathlib import Path import numpy as np import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) from src.backtest.harness import load from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_4h, simple_returns STATE_DIR = PROJECT_ROOT / "data" / "paper_trend" STATE_FILE = STATE_DIR / "state.json" TRADES_FILE = STATE_DIR / "trades.jsonl" ASSETS = ["BTC", "ETH"] WEIGHT = 0.5 INITIAL_CAPITAL = 2000.0 def build_4h() -> dict[str, pd.DataFrame]: return {a: resample_4h(load(a, "1h")) for a in ASSETS} def load_state() -> dict | None: if STATE_FILE.exists(): return json.loads(STATE_FILE.read_text()) return None def save_state(st: dict): STATE_DIR.mkdir(parents=True, exist_ok=True) STATE_FILE.write_text(json.dumps(st, indent=2)) def append_trade(rec: dict): STATE_DIR.mkdir(parents=True, exist_ok=True) with open(TRADES_FILE, "a") as f: f.write(json.dumps(rec) + "\n") def init_state(dfs) -> dict: last_ts = min(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) tp = TrendPortfolio(**CANONICAL) positions = {} for a in ASSETS: df = dfs[a] df = df[df["timestamp"] <= last_ts] positions[a] = tp.current_target(df) return dict( capital=INITIAL_CAPITAL, initial_capital=INITIAL_CAPITAL, start_ts=last_ts, last_ts=last_ts, positions=positions, n_bars=0, peak=INITIAL_CAPITAL, max_dd=0.0, ) def advance(st: dict, dfs: dict) -> dict: """Processa tutte le barre 4h chiuse DOPO st['last_ts'].""" tp = TrendPortfolio(**CANONICAL) # precompute per-asset: timestamps, returns, target series (causale) data = {} for a in ASSETS: df = dfs[a] c = df["close"].values.astype(float) data[a] = dict( ts=df["timestamp"].values.astype("int64"), dt=pd.to_datetime(df["datetime"]).values, r=simple_returns(c), tgt=tp.target_series(df), ) # common new timestamps after last_ts (present in both assets) common = sorted(set(data["BTC"]["ts"]).intersection(data["ETH"]["ts"])) new_ts = [t for t in common if t > st["last_ts"]] if not new_ts: return st pos = dict(st["positions"]) cap = st["capital"] peak = st.get("peak", cap) max_dd = st.get("max_dd", 0.0) idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS} for t in new_ts: # 1) apply held position return over this bar, charge turnover fees vs new target combo = 0.0 new_pos = {} for a in ASSETS: i = idx[a][int(t)] r = float(data[a]["r"][i]) held = pos[a] new_t = float(data[a]["tgt"][i]) turn = abs(new_t - held) net = held * r - CANONICAL["fee_side"] * turn combo += WEIGHT * net new_pos[a] = new_t # record a trade when the SIGN of position changes (entry/exit/flip) if np.sign(new_t) != np.sign(held): append_trade(dict( ts=int(t), dt=str(pd.Timestamp(data[a]["dt"][i])), asset=a, action="ENTRY" if new_t != 0 else "EXIT", from_pos=round(held, 4), to_pos=round(new_t, 4), capital=round(cap, 2), )) cap *= (1.0 + max(combo, -0.99)) peak = max(peak, cap) max_dd = max(max_dd, (peak - cap) / peak if peak > 0 else 0.0) pos = new_pos st.update(capital=cap, last_ts=int(new_ts[-1]), positions=pos, n_bars=st.get("n_bars", 0) + len(new_ts), peak=peak, max_dd=max_dd) return st def print_status(st: dict, dfs: dict): start = pd.Timestamp(st["start_ts"], unit="ms", tz="UTC") last = pd.Timestamp(st["last_ts"], unit="ms", tz="UTC") days = (last - start).total_seconds() / 86400 cap = st["capital"] ret = cap / st["initial_capital"] - 1 daily = (cap - st["initial_capital"]) / days if days > 0 else 0.0 print("=" * 72) print(" PAPER TRADER — TP01 Trend Portfolio (PORT LF4h, 50/50 BTC+ETH, 4h)") print("=" * 72) print(f" start {start:%Y-%m-%d %H:%M} UTC") print(f" last bar {last:%Y-%m-%d %H:%M} UTC ({days:.1f} giorni, {st['n_bars']} barre 4h)") print(f" capitale {cap:,.2f} USDT (start {st['initial_capital']:,.0f})") print(f" ritorno {ret*100:+.2f}% | €/giorno {daily:+.2f} | maxDD {st['max_dd']*100:.1f}%") print(f" posizioni now { 'flat' if all(p==0 for p in st['positions'].values()) else '' }") for a in ASSETS: p = st["positions"][a] state = "FLAT" if p == 0 else ("LONG" if p > 0 else "SHORT") print(f" {a}: {state:<5s} target {p:+.3f}x (frazione di equity dello sleeve)") # what the strategy decides at the latest available closed bar print(" ── prossima decisione (ultima barra chiusa disponibile) ──") tp = TrendPortfolio(**CANONICAL) for a in ASSETS: w = tp.current_target(dfs[a]) print(f" {a}: target {w:+.3f}x") if TRADES_FILE.exists(): n = sum(1 for _ in open(TRADES_FILE)) print(f" trade registrati: {n} ({TRADES_FILE})") def main(): argv = sys.argv[1:] dfs = build_4h() if "--reset" in argv: if STATE_FILE.exists(): STATE_FILE.unlink() if TRADES_FILE.exists(): TRADES_FILE.unlink() print("stato azzerato.") st = load_state() if st is None: st = init_state(dfs) save_state(st) print("paper trader inizializzato (forward-only da ora).\n") elif "--status" not in argv: st = advance(st, dfs) save_state(st) print_status(st, dfs) if __name__ == "__main__": main()