From b79c87e4af75fabf4a377fa4de17d29770c6e6c3 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Wed, 27 May 2026 23:12:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20multi-strategy=20paper=20trader=20?= =?UTF-8?q?=E2=80=94=20N=20strategie=20in=20parallelo=20su=20testnet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/live/multi_runner.py: orchestratore con fetch raggruppato per asset/tf - src/live/strategy_worker.py: worker indipendente con stato persistente JSONL - src/live/strategy_loader.py: import dinamico classi Strategy - strategies.yml: config dichiarativa con defaults e override per strategia - Docker: container unico, strategies.yml montato come volume read-only - Supporta hot-add: aggiungi riga YAML + restart, storico intatto - Ogni strategia: €1000 USDC virtuale, equity tracking, Telegram notify Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 4 +- docker-compose.yml | 5 +- .../2026-05-27-multi-strategy-paper-trader.md | 174 +++++++++++ pyproject.toml | 1 + src/live/multi_runner.py | 271 ++++++++++++++++++ src/live/strategy_loader.py | 51 ++++ src/live/strategy_worker.py | 226 +++++++++++++++ strategies.yml | 33 +++ uv.lock | 57 ++++ 9 files changed, 819 insertions(+), 3 deletions(-) create mode 100644 docs/specs/2026-05-27-multi-strategy-paper-trader.md create mode 100644 src/live/multi_runner.py create mode 100644 src/live/strategy_loader.py create mode 100644 src/live/strategy_worker.py create mode 100644 strategies.yml diff --git a/Dockerfile b/Dockerfile index d4cf71d..7317fa6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,9 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev COPY src/ src/ +COPY scripts/strategies/ scripts/strategies/ +COPY strategies.yml strategies.yml VOLUME /app/data -CMD ["uv", "run", "python", "-m", "src.live.paper_trader"] +CMD ["uv", "run", "python", "-m", "src.live.multi_runner"] diff --git a/docker-compose.yml b/docker-compose.yml index b863f3f..6d28b7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,17 @@ services: paper-trader: build: . - container_name: pythagoras-paper + container_name: pythagoras-multi restart: unless-stopped volumes: - ./data:/app/data + - ./strategies.yml:/app/strategies.yml:ro env_file: - .env environment: - PYTHONUNBUFFERED=1 healthcheck: - test: ["CMD", "python", "-c", "import json; s=json.load(open('/app/data/paper_trades/status.json')); assert s['last_update']"] + test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/paper_trades') for f in fs)"] interval: 120s timeout: 10s retries: 3 diff --git a/docs/specs/2026-05-27-multi-strategy-paper-trader.md b/docs/specs/2026-05-27-multi-strategy-paper-trader.md new file mode 100644 index 0000000..1e6c128 --- /dev/null +++ b/docs/specs/2026-05-27-multi-strategy-paper-trader.md @@ -0,0 +1,174 @@ +# Multi-Strategy Paper Trader — Design Spec + +## Obiettivo + +Eseguire N strategie di trading in parallelo su Deribit testnet (paper trading locale), ognuna con capitale virtuale indipendente di €1000 USDC. Lo storico trade di ogni strategia persiste tra restart. Nuove strategie aggiungibili in corso d'opera via config YAML senza perdere lo storico delle esistenti. + +## Architettura + +Un singolo container Docker esegue un orchestratore (`MultiStrategyRunner`) che gestisce N `StrategyWorker`. Ogni worker è indipendente: proprio capital, propri trade, proprio stato. + +``` +Docker Container +├── MultiStrategyRunner (orchestratore, loop principale) +│ ├── StrategyWorker[SQ02_BTC_15m] → paper trade → JSONL +│ ├── StrategyWorker[ML01_ETH_15m] → paper trade → JSONL +│ └── ...altri worker da YAML +├── CerberoClient (condiviso, fetch prezzi) +└── TelegramNotifier (condiviso) +``` + +## Componenti + +### 1. `strategies.yml` — Configurazione + +```yaml +defaults: + capital: 1000 + position_size: 0.15 + leverage: 3 + hold_bars: 3 + poll_seconds: 60 + retrain_hours: 24 + +strategies: + - name: SQ02_antifake_vol + asset: BTC + tf: 15m + enabled: true + + - name: SQ02_antifake_vol + asset: ETH + tf: 15m + enabled: true + + - name: ML01_squeeze_gbm + asset: ETH + tf: 15m + enabled: true + position_size: 0.20 + params: + ml_threshold: 0.70 + bb_window: 14 + sq_threshold: 0.8 +``` + +Ogni entry eredita `defaults`. Override per-strategia possibile su tutti i campi. Il campo `params` passa kwargs a `generate_signals()` o al backtest ML. + +### 2. `StrategyWorker` — Worker per singola strategia + +Responsabilità: +- Importa la classe Strategy corrispondente da `scripts/strategies/` +- Mantiene stato: capital, posizione aperta, equity +- Al startup: ricarica `status.json` se esiste (resume), altrimenti inizia da zero +- Ad ogni tick: riceve DataFrame candele, genera segnali, paper-trade +- Logga ogni evento in `trades.jsonl` (append-only) +- Aggiorna `status.json` ad ogni tick + +Stato persistente (`status.json`): +```json +{ + "capital": 1023.45, + "in_position": true, + "direction": "long", + "entry_price": 2534.20, + "entry_time": "2026-05-27T14:30:00Z", + "bars_held": 1, + "total_trades": 15, + "total_wins": 12, + "started_at": "2026-05-27T10:00:00Z" +} +``` + +Trade log (`trades.jsonl`), append-only: +```json +{"ts": "2026-05-27T14:30:00Z", "event": "OPEN", "direction": "long", "price": 2534.20, "size": 0.18, "capital": 1023.45} +{"ts": "2026-05-27T15:15:00Z", "event": "CLOSE", "reason": "hold_limit", "entry": 2534.20, "exit": 2560.10, "pnl": 3.45, "fee": 0.92, "net_pnl": 2.53, "capital": 1025.98} +``` + +### 3. `MultiStrategyRunner` — Orchestratore + +Loop principale: +1. Carica `strategies.yml` +2. Per ogni entry, crea `StrategyWorker` (o riprende se già esiste) +3. Ogni 60s: + a. Fetch candele live da Cerbero (una volta per asset/tf unico) + b. Passa DataFrame a ogni worker + c. Ogni worker valuta segnali e gestisce posizione + d. Worker ML: retrain ogni 24h +4. Notifica Telegram per ogni trade + +Ottimizzazione: fetch candele raggruppato per (asset, tf). Se 3 strategie usano BTC 15m, fetch una volta sola. + +### 4. Persistenza + +``` +data/paper_trades/ + SQ02_antifake_vol__BTC__15m/ + trades.jsonl + status.json + SQ02_antifake_vol__ETH__15m/ + trades.jsonl + status.json + ML01_squeeze_gbm__ETH__15m/ + trades.jsonl + status.json +``` + +Directory naming: `{strategy_name}__{asset}__{tf}` con double underscore separatore. + +Volume Docker: `./data:/app/data` — persiste tra restart. + +### 5. Aggiunta strategia in corso + +1. Aggiungi entry in `strategies.yml` +2. `docker compose restart` +3. Runner carica YAML, trova nuova entry senza `status.json` → parte da €1000 +4. Strategie esistenti riprendono da `status.json` → storico intatto + +### 6. Docker + +`Dockerfile` — invariato, aggiunge `strategies.yml` alla COPY. + +`docker-compose.yml`: +```yaml +services: + paper-trader: + build: . + container_name: pythagoras-multi + restart: unless-stopped + volumes: + - ./data:/app/data + - ./strategies.yml:/app/strategies.yml:ro + env_file: + - .env + environment: + - PYTHONUNBUFFERED=1 +``` + +`CMD` cambia a: `uv run python -m src.live.multi_runner` + +### 7. Strategia-specifica: ML01 + +ML01 richiede training del modello GBM. Il worker ML01: +- Al primo avvio: train su storico (365 giorni via Cerbero) +- Ogni `retrain_hours`: retrain +- Usa `SignalEngine` esistente per check_signal() +- Le strategie SQ* non hanno training — solo regole deterministiche + +### 8. File da creare/modificare + +Nuovi: +- `src/live/multi_runner.py` — orchestratore +- `src/live/strategy_worker.py` — worker per singola strategia +- `strategies.yml` — config +- `src/live/strategy_loader.py` — import dinamico classi Strategy + +Modifiche: +- `docker-compose.yml` — nuovo CMD, volume strategies.yml +- `Dockerfile` — COPY strategies.yml + +Invariati: +- `src/live/cerbero_client.py` +- `src/live/telegram_notifier.py` +- `src/live/signal_engine.py` (usato da ML01 worker) diff --git a/pyproject.toml b/pyproject.toml index cafaff8..b77b758 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "torch>=2.0", "matplotlib>=3.7", "tqdm>=4.65", + "pyyaml>=6.0", ] [project.optional-dependencies] diff --git a/src/live/multi_runner.py b/src/live/multi_runner.py new file mode 100644 index 0000000..a1ead2a --- /dev/null +++ b/src/live/multi_runner.py @@ -0,0 +1,271 @@ +"""Multi-Strategy Paper Trader — orchestratore per N strategie in parallelo.""" +from __future__ import annotations + +import time +import yaml +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pandas as pd + +from src.live.cerbero_client import CerberoClient +from src.live.strategy_loader import load_strategy +from src.live.strategy_worker import StrategyWorker +from src.live.signal_engine import SignalEngine +from src.live.telegram_notifier import send_telegram + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DATA_DIR = PROJECT_ROOT / "data" / "paper_trades" + +RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"} +INSTRUMENT_MAP = { + "BTC": "BTC-PERPETUAL", + "ETH": "ETH-PERPETUAL", +} + + +class MLWorkerWrapper: + """Wrapper speciale per ML01 che usa SignalEngine con training.""" + + def __init__(self, worker: StrategyWorker, config: dict): + self.worker = worker + self.engine = SignalEngine( + bb_w=config.get("params", {}).get("bb_window", 14), + sq_thr=config.get("params", {}).get("sq_threshold", 0.8), + ml_thr=config.get("params", {}).get("ml_threshold", 0.70), + ) + self.trained = False + self.last_train: datetime | None = None + self.retrain_hours = config.get("retrain_hours", 24) + + def needs_training(self) -> bool: + if not self.trained: + return True + if self.last_train is None: + return True + elapsed = (datetime.now(timezone.utc) - self.last_train).total_seconds() + return elapsed > self.retrain_hours * 3600 + + def train(self, df: pd.DataFrame, hold: int = 3): + result = self.engine.train(df, lookahead=hold) + if "error" not in result: + self.trained = True + self.last_train = datetime.now(timezone.utc) + print(f" [{self.worker.worker_id}] TRAIN OK: {result}") + else: + print(f" [{self.worker.worker_id}] TRAIN FAIL: {result}") + + def tick(self, df: pd.DataFrame): + if not self.trained: + return + + worker = self.worker + c = df["close"].values + current_price = float(c[-1]) + current_ts = int(df["timestamp"].iloc[-1]) + + if worker.in_position: + if current_ts > worker.last_bar_ts: + worker.bars_held += 1 + worker.last_bar_ts = current_ts + if worker.bars_held >= worker.hold_bars: + worker._close_position(current_price, "hold_limit") + else: + pnl_pct = (current_price - worker.entry_price) / worker.entry_price * worker.direction + if pnl_pct <= -0.02: + worker._close_position(current_price, "stop_loss") + worker._save_state() + return + + signal = self.engine.check_signal(df) + if signal: + from src.strategies.base import Signal + direction = 1 if signal["direction"] == "buy" else -1 + sig = Signal(idx=len(df)-1, direction=direction, entry_price=current_price) + worker._open_position(sig, current_price) + worker.last_bar_ts = current_ts + + worker._save_state() + + +def load_config(path: Path) -> dict: + with open(path) as f: + return yaml.safe_load(f) + + +def build_workers(config: dict) -> tuple[list[StrategyWorker], list[MLWorkerWrapper]]: + """Crea worker da config YAML.""" + defaults = config.get("defaults", {}) + regular_workers: list[StrategyWorker] = [] + ml_workers: list[MLWorkerWrapper] = [] + + for entry in config.get("strategies", []): + if not entry.get("enabled", True): + continue + + name = entry["name"] + asset = entry["asset"] + tf = entry["tf"] + capital = entry.get("capital", defaults.get("capital", 1000)) + pos_size = entry.get("position_size", defaults.get("position_size", 0.15)) + leverage = entry.get("leverage", defaults.get("leverage", 3)) + hold = entry.get("hold_bars", defaults.get("hold_bars", 3)) + params = entry.get("params", {}) + + strategy = load_strategy(name) + + worker = StrategyWorker( + strategy=strategy, asset=asset, tf=tf, + capital=capital, position_size=pos_size, + leverage=leverage, hold_bars=hold, + params=params, data_dir=DATA_DIR, + ) + + if name == "ML01_squeeze_gbm": + ml_wrapper = MLWorkerWrapper(worker, {**defaults, **entry}) + ml_workers.append(ml_wrapper) + else: + regular_workers.append(worker) + + return regular_workers, ml_workers + + +def run(): + config_path = PROJECT_ROOT / "strategies.yml" + if not config_path.exists(): + print(f"ERRORE: {config_path} non trovato") + return + + config = load_config(config_path) + defaults = config.get("defaults", {}) + poll_seconds = defaults.get("poll_seconds", 60) + lookback_days = 60 + train_lookback_days = 365 + + regular_workers, ml_workers = build_workers(config) + all_worker_count = len(regular_workers) + len(ml_workers) + + if all_worker_count == 0: + print("Nessuna strategia abilitata in strategies.yml") + return + + client = CerberoClient() + + print("=" * 70) + print(f" MULTI-STRATEGY PAPER TRADER") + print(f" Strategie attive: {all_worker_count}") + print(f" Poll: ogni {poll_seconds}s") + print(f" Data dir: {DATA_DIR}") + print("=" * 70) + + for w in regular_workers: + print(f" • {w.status_summary}") + for mw in ml_workers: + print(f" • {mw.worker.status_summary} [ML]") + + send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie") + + # Raccogli asset/tf unici per fetch raggruppato + def _get_data_keys() -> set[tuple[str, str]]: + keys = set() + for w in regular_workers: + keys.add((w.asset, w.tf)) + for mw in ml_workers: + keys.add((mw.worker.asset, mw.worker.tf)) + return keys + + # Training iniziale ML + for mw in ml_workers: + asset = mw.worker.asset + instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL") + resolution = RESOLUTION_MAP.get(mw.worker.tf, "15") + end = datetime.now(timezone.utc) + start = end - timedelta(days=train_lookback_days) + candles = client.get_historical(instrument, start.strftime("%Y-%m-%d"), + end.strftime("%Y-%m-%d"), resolution) + if candles: + df_train = pd.DataFrame(candles) + df_train["timestamp"] = df_train["timestamp"].astype("int64") + df_train = df_train.sort_values("timestamp").reset_index(drop=True) + mw.train(df_train, hold=mw.worker.hold_bars) + + while True: + try: + data_keys = _get_data_keys() + candle_cache: dict[tuple[str, str], pd.DataFrame] = {} + + for asset, tf in data_keys: + instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL") + resolution = RESOLUTION_MAP.get(tf, "15") + end = datetime.now(timezone.utc) + start = end - timedelta(days=lookback_days) + + candles = client.get_historical( + instrument, start.strftime("%Y-%m-%d"), + end.strftime("%Y-%m-%d"), resolution, + ) + if candles: + df = pd.DataFrame(candles) + df["timestamp"] = df["timestamp"].astype("int64") + df = df.sort_values("timestamp").reset_index(drop=True) + candle_cache[(asset, tf)] = df + + # Tick regular workers + for w in regular_workers: + key = (w.asset, w.tf) + if key in candle_cache: + try: + w.tick(candle_cache[key]) + except Exception as e: + print(f" [{w.worker_id}] ERRORE: {e}") + + # Tick ML workers + for mw in ml_workers: + key = (mw.worker.asset, mw.worker.tf) + if key not in candle_cache: + continue + + if mw.needs_training(): + mw.train(candle_cache[key], hold=mw.worker.hold_bars) + + try: + mw.tick(candle_cache[key]) + except Exception as e: + print(f" [{mw.worker.worker_id}] ERRORE: {e}") + + # Status periodico + now = datetime.now(timezone.utc) + if now.minute == 0 and now.second < poll_seconds: + lines = [f"📊 Status {now.strftime('%H:%M')} UTC"] + for w in regular_workers: + lines.append(f" {w.status_summary}") + for mw in ml_workers: + lines.append(f" {mw.worker.status_summary} [ML]") + send_telegram("\n".join(lines)) + + except KeyboardInterrupt: + print("\nShutdown...") + for w in regular_workers: + if w.in_position: + df = candle_cache.get((w.asset, w.tf)) + if df is not None and not df.empty: + w._close_position(float(df["close"].iloc[-1]), "shutdown") + w._save_state() + for mw in ml_workers: + if mw.worker.in_position: + df = candle_cache.get((mw.worker.asset, mw.worker.tf)) + if df is not None and not df.empty: + mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown") + mw.worker._save_state() + send_telegram("🛑 Multi-Strategy arrestato") + break + except Exception as e: + print(f" ERRORE GLOBALE: {e}") + import traceback + traceback.print_exc() + + time.sleep(poll_seconds) + + +if __name__ == "__main__": + run() diff --git a/src/live/strategy_loader.py b/src/live/strategy_loader.py new file mode 100644 index 0000000..a949b02 --- /dev/null +++ b/src/live/strategy_loader.py @@ -0,0 +1,51 @@ +"""Import dinamico delle classi Strategy da scripts/strategies/.""" +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +from src.strategies.base import Strategy + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies" + +_REGISTRY: dict[str, type[Strategy]] = {} + +MODULE_MAP = { + "SQ01_squeeze_base": ("SQ01_squeeze_base", "SqueezeBase"), + "SQ02_antifake_vol": ("SQ02_squeeze_antifake_vol", "SqueezeAntifakeVol"), + "SQ03_filtered": ("SQ03_squeeze_all_filters", "SqueezeFiltered"), + "SQ04_ultimate": ("SQ04_squeeze_ultimate", "SqueezeUltimate"), + "ML01_squeeze_gbm": ("ML01_squeeze_gbm", "SqueezeGBM"), +} + + +def load_strategy(name: str) -> Strategy: + """Carica e istanzia una Strategy per nome.""" + if name in _REGISTRY: + return _REGISTRY[name]() + + if name not in MODULE_MAP: + raise ValueError(f"Strategia sconosciuta: {name}. Disponibili: {list(MODULE_MAP)}") + + module_file, class_name = MODULE_MAP[name] + module_path = STRATEGIES_DIR / f"{module_file}.py" + + if not module_path.exists(): + raise FileNotFoundError(f"File strategia non trovato: {module_path}") + + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + spec = importlib.util.spec_from_file_location(f"strategies.{module_file}", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + cls = getattr(module, class_name) + _REGISTRY[name] = cls + return cls() + + +def list_available() -> list[str]: + return list(MODULE_MAP.keys()) diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py new file mode 100644 index 0000000..76fe022 --- /dev/null +++ b/src/live/strategy_worker.py @@ -0,0 +1,226 @@ +"""Worker per singola strategia — paper trading con stato persistente.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np +import pandas as pd + +from src.strategies.base import Strategy, Signal +from src.live.telegram_notifier import notify_event + +FEE_RT = 0.002 + + +class StrategyWorker: + """Gestisce paper trading per una singola strategia/asset/tf.""" + + def __init__( + self, + strategy: Strategy, + asset: str, + tf: str, + capital: float = 1000.0, + position_size: float = 0.15, + leverage: float = 3.0, + hold_bars: int = 3, + params: dict | None = None, + data_dir: Path = Path("data/paper_trades"), + ): + self.strategy = strategy + self.asset = asset + self.tf = tf + self.initial_capital = capital + self.position_size = position_size + self.leverage = leverage + self.hold_bars = hold_bars + self.params = params or {} + + self.worker_id = f"{strategy.name}__{asset}__{tf}" + self.work_dir = data_dir / self.worker_id + self.work_dir.mkdir(parents=True, exist_ok=True) + self.trades_path = self.work_dir / "trades.jsonl" + self.status_path = self.work_dir / "status.json" + + self.capital = capital + self.in_position = False + self.direction: int = 0 + self.entry_price: float = 0 + self.entry_time: str = "" + self.bars_held: int = 0 + self.total_trades: int = 0 + self.total_wins: int = 0 + self.started_at = datetime.now(timezone.utc).isoformat() + self.last_bar_ts: int = 0 + + self._load_state() + self._save_state() + + def _load_state(self): + """Riprende stato da status.json se esiste.""" + if not self.status_path.exists(): + self._log("INIT", {"capital": self.capital, "strategy": self.strategy.name, + "asset": self.asset, "tf": self.tf}) + return + + with open(self.status_path) as f: + state = json.load(f) + + self.capital = state.get("capital", self.initial_capital) + self.in_position = state.get("in_position", False) + self.direction = state.get("direction", 0) + self.entry_price = state.get("entry_price", 0) + self.entry_time = state.get("entry_time", "") + self.bars_held = state.get("bars_held", 0) + self.total_trades = state.get("total_trades", 0) + self.total_wins = state.get("total_wins", 0) + self.started_at = state.get("started_at", self.started_at) + self.last_bar_ts = state.get("last_bar_ts", 0) + + self._log("RESUME", {"capital": round(self.capital, 2), + "total_trades": self.total_trades, + "in_position": self.in_position}) + + def _save_state(self): + state = { + "capital": round(self.capital, 2), + "in_position": self.in_position, + "direction": self.direction, + "entry_price": self.entry_price, + "entry_time": self.entry_time, + "bars_held": self.bars_held, + "total_trades": self.total_trades, + "total_wins": self.total_wins, + "started_at": self.started_at, + "last_bar_ts": self.last_bar_ts, + "last_update": datetime.now(timezone.utc).isoformat(), + } + with open(self.status_path, "w") as f: + json.dump(state, f, indent=2) + + def _log(self, event: str, data: dict | None = None): + entry = { + "ts": datetime.now(timezone.utc).isoformat(), + "worker": self.worker_id, + "event": event, + **(data or {}), + } + with open(self.trades_path, "a") as f: + f.write(json.dumps(entry) + "\n") + print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}") + + def _notify(self, event: str, data: dict | None = None): + enriched = {"worker": self.worker_id, **(data or {})} + notify_event(event, enriched) + + def _open_position(self, signal: Signal, current_price: float): + notional = self.capital * self.position_size * self.leverage + size = notional / current_price if current_price > 0 else 0 + + self.in_position = True + self.direction = signal.direction + self.entry_price = current_price + self.entry_time = datetime.now(timezone.utc).isoformat() + self.bars_held = 0 + + trade_data = { + "direction": "long" if signal.direction == 1 else "short", + "price": round(current_price, 2), + "size": round(size, 6), + "notional": round(notional, 2), + "capital": round(self.capital, 2), + } + self._log("OPEN", trade_data) + self._notify("OPENED", trade_data) + + def _close_position(self, current_price: float, reason: str): + if not self.in_position: + return + + price_change = (current_price - self.entry_price) / self.entry_price + trade_return = price_change * self.direction + net = trade_return * self.leverage - FEE_RT * self.leverage + pnl = self.capital * self.position_size * net + + is_win = trade_return > 0 + self.capital += pnl + self.capital = max(self.capital, 0) + self.total_trades += 1 + if is_win: + self.total_wins += 1 + + accuracy = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0 + + trade_data = { + "reason": reason, + "direction": "long" if self.direction == 1 else "short", + "entry": round(self.entry_price, 2), + "exit": round(current_price, 2), + "pnl": round(pnl, 2), + "net_return": round(net * 100, 3), + "capital": round(self.capital, 2), + "bars_held": self.bars_held, + "win": is_win, + "total_trades": self.total_trades, + "accuracy": round(accuracy, 1), + } + self._log("CLOSE", trade_data) + self._notify("CLOSED", trade_data) + + self.in_position = False + self.direction = 0 + self.entry_price = 0 + self.entry_time = "" + self.bars_held = 0 + + def tick(self, df: pd.DataFrame): + """Chiamato ad ogni poll con DataFrame OHLCV aggiornato.""" + if df.empty or len(df) < 100: + return + + c = df["close"].values + current_price = float(c[-1]) + current_ts = int(df["timestamp"].iloc[-1]) + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + + if self.in_position: + if current_ts > self.last_bar_ts: + self.bars_held += 1 + self.last_bar_ts = current_ts + + if self.bars_held >= self.hold_bars: + self._close_position(current_price, "hold_limit") + else: + pnl_pct = (current_price - self.entry_price) / self.entry_price * self.direction + if pnl_pct <= -0.02: + self._close_position(current_price, "stop_loss") + + self._save_state() + return + + # Genera segnali + signals = self.strategy.generate_signals( + df, ts, asset=self.asset, tf=self.tf, **self.params + ) + + if not signals: + self._save_state() + return + + last_signal = signals[-1] + last_idx = len(df) - 1 + + if last_signal.idx >= last_idx - 1: + self._open_position(last_signal, current_price) + self.last_bar_ts = current_ts + + self._save_state() + + @property + def status_summary(self) -> str: + acc = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0 + pos = "LONG" if self.direction == 1 else "SHORT" if self.direction == -1 else "FLAT" + return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t " + f"{acc:.0f}% | {pos}") diff --git a/strategies.yml b/strategies.yml new file mode 100644 index 0000000..22b4b84 --- /dev/null +++ b/strategies.yml @@ -0,0 +1,33 @@ +defaults: + capital: 1000 + position_size: 0.15 + leverage: 3 + hold_bars: 3 + poll_seconds: 60 + retrain_hours: 24 + +strategies: + - name: SQ02_antifake_vol + asset: BTC + tf: 15m + enabled: true + + - name: SQ02_antifake_vol + asset: ETH + tf: 15m + enabled: true + + - name: SQ01_squeeze_base + asset: BTC + tf: 15m + enabled: true + + - name: ML01_squeeze_gbm + asset: ETH + tf: 15m + enabled: true + position_size: 0.15 + params: + ml_threshold: 0.70 + bb_window: 14 + sq_threshold: 0.8 diff --git a/uv.lock b/uv.lock index 7fe79b6..44f78c9 100644 --- a/uv.lock +++ b/uv.lock @@ -2057,6 +2057,7 @@ dependencies = [ { name = "numpy" }, { name = "pandas" }, { name = "pyarrow" }, + { name = "pyyaml" }, { name = "requests" }, { name = "scikit-learn" }, { name = "scipy" }, @@ -2081,6 +2082,7 @@ requires-dist = [ { name = "pyarrow", specifier = ">=15.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.31" }, { name = "scikit-learn", specifier = ">=1.3" }, { name = "scipy", specifier = ">=1.11" }, @@ -2101,6 +2103,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2"