8c4ddebe85
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
8.5 KiB
Python
254 lines
8.5 KiB
Python
"""Paper trader: loop principale che monitora, segnala e opera su Deribit testnet."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
|
|
from src.live.cerbero_client import CerberoClient
|
|
from src.live.signal_engine import SignalEngine
|
|
|
|
LOG_DIR = Path(__file__).resolve().parents[2] / "data" / "paper_trades"
|
|
INSTRUMENT = "ETH_USDC-PERPETUAL"
|
|
TRAIN_INSTRUMENT = "ETH-PERPETUAL" # storico più lungo per training
|
|
CURRENCY = "USDC"
|
|
RESOLUTION = "15"
|
|
LEVERAGE = 3
|
|
POSITION_PCT = 0.15
|
|
HOLD_BARS = 3
|
|
POLL_SECONDS = 60
|
|
LOOKBACK_DAYS = 60
|
|
TRAIN_LOOKBACK_DAYS = 365
|
|
|
|
|
|
class PaperTrader:
|
|
def __init__(self):
|
|
self.client = CerberoClient()
|
|
self.engine = SignalEngine(bb_w=14, sq_thr=0.8, ml_thr=0.70)
|
|
|
|
self.in_position = False
|
|
self.position_entry_time: datetime | None = None
|
|
self.position_direction: str | None = None
|
|
self.position_entry_price: float = 0
|
|
self.bars_held = 0
|
|
self.last_bar_ts: int = 0
|
|
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
self.log_path = LOG_DIR / f"trades_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
|
|
self.status_path = LOG_DIR / "status.json"
|
|
|
|
def log(self, event: str, data: dict | None = None):
|
|
entry = {
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"event": event,
|
|
**(data or {}),
|
|
}
|
|
with open(self.log_path, "a") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
print(f" [{entry['timestamp'][:19]}] {event}: {json.dumps(data or {})}")
|
|
|
|
def save_status(self):
|
|
status = {
|
|
"in_position": self.in_position,
|
|
"direction": self.position_direction,
|
|
"entry_price": self.position_entry_price,
|
|
"entry_time": self.position_entry_time.isoformat() if self.position_entry_time else None,
|
|
"bars_held": self.bars_held,
|
|
"last_update": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
with open(self.status_path, "w") as f:
|
|
json.dump(status, f, indent=2)
|
|
|
|
def fetch_candles(self, days: int = LOOKBACK_DAYS, instrument: str | None = None) -> pd.DataFrame:
|
|
end = datetime.now(timezone.utc)
|
|
start = end - timedelta(days=days)
|
|
candles = self.client.get_historical(
|
|
instrument or TRAIN_INSTRUMENT,
|
|
start.strftime("%Y-%m-%d"),
|
|
end.strftime("%Y-%m-%d"),
|
|
RESOLUTION,
|
|
)
|
|
if not candles:
|
|
return pd.DataFrame()
|
|
df = pd.DataFrame(candles)
|
|
df["timestamp"] = df["timestamp"].astype("int64")
|
|
df = df.sort_values("timestamp").reset_index(drop=True)
|
|
return df
|
|
|
|
def train_model(self):
|
|
self.log("TRAINING", {"lookback_days": TRAIN_LOOKBACK_DAYS, "instrument": TRAIN_INSTRUMENT})
|
|
df = self.fetch_candles(TRAIN_LOOKBACK_DAYS, TRAIN_INSTRUMENT)
|
|
if df.empty:
|
|
self.log("TRAINING_FAILED", {"reason": "no data"})
|
|
return False
|
|
result = self.engine.train(df, lookahead=HOLD_BARS)
|
|
self.log("TRAINING_DONE", result)
|
|
return "error" not in result
|
|
|
|
def open_position(self, direction: str, signal: dict):
|
|
ticker = self.client.get_ticker(INSTRUMENT)
|
|
price = ticker["last_price"]
|
|
account = self.client.get_account_summary()
|
|
equity = account["equity"]
|
|
|
|
notional = equity * POSITION_PCT
|
|
amount = round(notional / price, 3)
|
|
amount = max(amount, 0.001)
|
|
|
|
side = "buy" if direction == "buy" else "sell"
|
|
|
|
self.log("OPENING", {
|
|
"side": side,
|
|
"amount": amount,
|
|
"price": price,
|
|
"equity": equity,
|
|
"signal": signal,
|
|
})
|
|
|
|
try:
|
|
result = self.client.place_order(
|
|
instrument=INSTRUMENT,
|
|
side=side,
|
|
amount=amount,
|
|
order_type="market",
|
|
leverage=LEVERAGE,
|
|
label="pythagoras-squeeze",
|
|
)
|
|
self.in_position = True
|
|
self.position_direction = side
|
|
self.position_entry_price = price
|
|
self.position_entry_time = datetime.now(timezone.utc)
|
|
self.bars_held = 0
|
|
self.log("OPENED", {"order_result": result})
|
|
except Exception as e:
|
|
self.log("OPEN_FAILED", {"error": str(e)})
|
|
|
|
def close_current_position(self, reason: str):
|
|
if not self.in_position:
|
|
return
|
|
|
|
ticker = self.client.get_ticker(INSTRUMENT)
|
|
exit_price = ticker["last_price"]
|
|
|
|
if self.position_direction == "buy":
|
|
pnl_pct = (exit_price - self.position_entry_price) / self.position_entry_price * 100
|
|
else:
|
|
pnl_pct = (self.position_entry_price - exit_price) / self.position_entry_price * 100
|
|
|
|
self.log("CLOSING", {
|
|
"reason": reason,
|
|
"entry_price": self.position_entry_price,
|
|
"exit_price": exit_price,
|
|
"pnl_pct": round(pnl_pct, 3),
|
|
"bars_held": self.bars_held,
|
|
})
|
|
|
|
try:
|
|
result = self.client.close_position(INSTRUMENT)
|
|
self.log("CLOSED", {"result": result, "pnl_pct": round(pnl_pct, 3)})
|
|
except Exception as e:
|
|
self.log("CLOSE_FAILED", {"error": str(e)})
|
|
|
|
self.in_position = False
|
|
self.position_direction = None
|
|
self.position_entry_price = 0
|
|
self.position_entry_time = None
|
|
self.bars_held = 0
|
|
|
|
def check_position_exit(self, df: pd.DataFrame):
|
|
if not self.in_position:
|
|
return
|
|
|
|
current_ts = df["timestamp"].iloc[-1]
|
|
if current_ts > self.last_bar_ts:
|
|
self.bars_held += 1
|
|
self.last_bar_ts = current_ts
|
|
|
|
if self.bars_held >= HOLD_BARS:
|
|
self.close_current_position("hold_limit")
|
|
return
|
|
|
|
price = df["close"].iloc[-1]
|
|
if self.position_direction == "buy":
|
|
pnl_pct = (price - self.position_entry_price) / self.position_entry_price
|
|
else:
|
|
pnl_pct = (self.position_entry_price - price) / self.position_entry_price
|
|
|
|
if pnl_pct <= -0.02:
|
|
self.close_current_position("stop_loss_2pct")
|
|
|
|
def run_once(self) -> str:
|
|
"""Esegui un singolo ciclo. Ritorna lo stato."""
|
|
df = self.fetch_candles(LOOKBACK_DAYS, TRAIN_INSTRUMENT)
|
|
if df.empty:
|
|
return "no_data"
|
|
|
|
if self.in_position:
|
|
self.check_position_exit(df)
|
|
self.save_status()
|
|
if self.in_position:
|
|
return f"in_position_{self.position_direction}_bar{self.bars_held}"
|
|
return "position_closed"
|
|
|
|
signal = self.engine.check_signal(df)
|
|
if signal:
|
|
self.log("SIGNAL", signal)
|
|
self.open_position(signal["direction"], signal)
|
|
self.save_status()
|
|
return f"signal_{signal['direction']}"
|
|
|
|
self.save_status()
|
|
return "watching"
|
|
|
|
def run(self, retrain_hours: int = 24):
|
|
"""Loop principale."""
|
|
print("=" * 60)
|
|
print(f" PAPER TRADER — {INSTRUMENT} (margine {CURRENCY})")
|
|
print(f" Segnali da: {TRAIN_INSTRUMENT} {RESOLUTION}m")
|
|
print(f" Leva: {LEVERAGE}x, Position: {POSITION_PCT*100:.0f}%, Hold: {HOLD_BARS} barre")
|
|
print(f" Poll: ogni {POLL_SECONDS}s")
|
|
print(f" Log: {self.log_path}")
|
|
print("=" * 60)
|
|
|
|
account = self.client.get_account_summary()
|
|
self.log("STARTUP", {
|
|
"equity": account["equity"],
|
|
"testnet": account.get("testnet", True),
|
|
})
|
|
|
|
if not self.train_model():
|
|
print("Training fallito. Uscita.")
|
|
return
|
|
|
|
last_train = datetime.now(timezone.utc)
|
|
|
|
while True:
|
|
try:
|
|
now = datetime.now(timezone.utc)
|
|
if (now - last_train).total_seconds() > retrain_hours * 3600:
|
|
self.train_model()
|
|
last_train = now
|
|
|
|
status = self.run_once()
|
|
if status != "watching":
|
|
print(f" → {status}")
|
|
|
|
except KeyboardInterrupt:
|
|
self.log("SHUTDOWN", {"reason": "keyboard"})
|
|
if self.in_position:
|
|
self.close_current_position("shutdown")
|
|
break
|
|
except Exception as e:
|
|
self.log("ERROR", {"error": str(e)})
|
|
print(f" ERRORE: {e}")
|
|
|
|
time.sleep(POLL_SECONDS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
trader = PaperTrader()
|
|
trader.run()
|