d152941360
Integra il lavoro della linea di ricerca parallela (AdrianoDev), verificato indipendentemente
col mio gauntlet onesto (regge il hold-out 2025-26 su entrambi gli asset, plateau 1h/4h/1d):
- src/strategies/trend_portfolio.py TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)
- src/backtest/harness.py harness onesto (load + backtest_signals no-leakage + OOS)
- scripts/research/track{A,B,C,D,E}_*.py + trackD_timing.py (le 5 track della ricerca)
- scripts/live/paper_trend.py paper trader forward-only di TP01 (no esecuzione reale)
- tests/test_trend_portfolio.py (5 test, passano) + 6 diari trackA-E + synthesis
- CLAUDE.md aggiornato con l'esito ricerca (TP01 vincente, mean-rev morto, onesta su €50/g)
Squash (non merge) per NON portare in git i ~68MB di data/_feed_backup/*.bak che il branch
aveva committato per errore: esclusi + data/_feed_backup/ e data/paper_trend/ ora gitignorati.
Storia granulare del branch conservata sul ref origin/strategy-research-2026-06.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
7.1 KiB
Python
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()
|