Files
PythagorasGoal/scripts/live/paper_trend.py
Adriano ae7f3d17f2 deploy: TP01 trend portfolio (PORT LF4h) module + paper trader
- src/strategies/trend_portfolio.py: canonical winner, causal/no-leakage,
  reproduces CAGR +16.5% Sharpe 1.36 maxDD 13.8%
- scripts/live/paper_trend.py: forward-only paper trader, persistent state, resume
- tests/test_trend_portfolio.py: 5 tests (causality, profitability, long-only, paper parity)
2026-06-19 20:35:28 +02:00

191 lines
7.1 KiB
Python

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