From a4e297f77aced42bf53f024d598f01c96cfbdb20 Mon Sep 17 00:00:00 2001 From: Alberto Balbo Date: Fri, 28 Aug 2026 13:01:47 +0200 Subject: [PATCH] Passa a Binance Futures con arbitraggio statistico su coppie cointegrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il bot smette di operare direzionalmente su un singolo asset e passa a coppie delta-neutral: long una gamba, short l'altra nel rapporto che il test di cointegrazione produce, scommettendo solo sul fatto che la distanza fra le due si richiuda. È questo che gli permette di girare da una connessione domestica, perché su barre da 15 minuti la latenza smette di contare. Cosa cambia - Encelado.Alpaca sostituito da Encelado.Binance: REST firmato in HMAC-SHA256 con correzione dello scarto d'orologio, uno stream combinato per kline, book e mark price, e lo stream ordini autenticato con listen key rinnovata. - Nuovo livello statistico in Core: OLS, test di Dickey-Fuller aumentato con scelta del ritardo per AIC, ed Engle-Granger con i valori critici di MacKinnon per la cointegrazione. - Il rischio ragiona per coppia: divide il controvalore fra le gambe secondo β, così le due si annullano invece di lasciare un residuo direzionale, e corregge la dimensione con il funding netto atteso. - Interfaccia da sette pagine a quattro. I grafici a candele sono spariti: su una coppia coperta la candela di una gamba non dice niente, lo z-score sì. - Ripristino dei valori predefiniti da Impostazioni, con copia datata del file precedente. Ripristina il documento, commenti compresi, non solo i numeri. L'ordine che non partiva Il segnale diceva di entrare e non succedeva niente perché il router registrava quasi tutti i rifiuti a livello debug: alla verbosità predefinita il bot annunciava l'ingresso, rinunciava per un motivo che nessuno poteva vedere, e sembrava aver ignorato la propria decisione. Adesso ogni intento produce una riga a info o warn con il nome della coppia e il motivo esatto, la frase che l'operatore legge e la decisione che il motore prende vengono dallo stesso stato, e una barra che lo stream non consegna viene recuperata via REST. Che cosa dice il backtest Il banco di prova rigioca le coppie attraverso la STESSA classe che gira in produzione, con la calibrazione che cammina in avanti. Su 6,6 anni di ETHUSDT, BTCUSDT, SOLUSDT e AVAXUSDT: a 5 minuti nessuna combinazione di soglie supera i filtri di taratura; a 15 minuti e a un'ora la griglia trova combinazioni che rendono in taratura e in verifica, ma nessuna delle prime dieci resta positiva sulla terza fetta. La finestra dello z-score va molte volte oltre l'emivita del rientro — le 100 barre della guida sono le peggiori misurate — e il filtro di cointegrazione è ciò che tiene in piedi tutto: senza, ogni combinazione passa da leggermente positiva a −73%/−87%. Per questo dryRun parte attivo. I valori consegnati sono i meglio supportati fra quelli provati, non una strategia dimostrata, e il file lo dice. Co-Authored-By: Claude Opus 5 --- Encelado/Encelado.slnx | 2 +- Encelado/Modifiche.txt | 248 +++- Encelado/build/Encelado.iss | 8 +- Encelado/build/Release.proj | 33 +- Encelado/config/encelado.json | 227 ++-- Encelado/config/encelado.local.json.example | 12 - Encelado/src/Encelado.Alpaca/AlpacaOptions.cs | 114 -- .../src/Encelado.Alpaca/Internal/JsonRead.cs | 98 -- .../src/Encelado.Alpaca/Internal/Rfc3339.cs | 121 -- .../Encelado.Alpaca/Rest/AlpacaDataClient.cs | 197 --- .../src/Encelado.Alpaca/Rest/AlpacaHttp.cs | 225 ---- .../src/Encelado.Alpaca/Rest/AlpacaModels.cs | 350 ------ .../Rest/AlpacaTradingClient.cs | 307 ----- .../Streaming/MarketDataStream.cs | 486 ------- .../Streaming/TradeUpdateStream.cs | 258 ---- .../src/Encelado.Binance/BinanceOptions.cs | 158 +++ .../Encelado.Binance.csproj} | 4 +- .../src/Encelado.Binance/Internal/JsonRead.cs | 155 +++ .../Rest/BinanceFuturesClient.cs | 439 +++++++ .../src/Encelado.Binance/Rest/BinanceHttp.cs | 346 +++++ .../Encelado.Binance/Rest/BinanceModels.cs | 396 ++++++ .../Streaming/MarketDataStream.cs | 439 +++++++ .../Streaming/SymbolTable.cs | 4 +- .../Streaming/UserDataStream.cs | 232 ++++ .../Streaming/WebSocketChannel.cs | 54 +- .../Encelado.Bot/Configuration/BotConfig.cs | 329 +++-- .../Configuration/ConfigDefaults.cs | 322 +++++ .../Configuration/ConfigLoader.cs | 215 ++-- .../Configuration/CredentialResolver.cs | 127 +- .../Configuration/CredentialStore.cs | 51 +- .../Encelado.Bot/Diagnostics/AnalyticsLog.cs | 319 ++--- Encelado/src/Encelado.Bot/Encelado.Bot.csproj | 2 +- .../src/Encelado.Bot/Engine/AccountState.cs | 82 +- .../src/Encelado.Bot/Engine/BarAggregator.cs | 83 -- .../src/Encelado.Bot/Engine/BotSnapshot.cs | 267 ++-- .../src/Encelado.Bot/Engine/BotSupervisor.cs | 371 +++--- .../Encelado.Bot/Engine/ExecutionRouter.cs | 477 ------- Encelado/src/Encelado.Bot/Engine/LegState.cs | 101 ++ .../src/Encelado.Bot/Engine/PairPipeline.cs | 216 ++++ .../src/Encelado.Bot/Engine/PairRouter.cs | 653 ++++++++++ .../src/Encelado.Bot/Engine/PriceHistory.cs | 139 -- .../src/Encelado.Bot/Engine/SessionGuard.cs | 92 -- .../src/Encelado.Bot/Engine/SymbolPipeline.cs | 216 ---- .../src/Encelado.Bot/Engine/TradingEngine.cs | 1117 +++++++++-------- Encelado/src/Encelado.Bot/GlobalUsings.cs | 1 + Encelado/src/Encelado.Bot/MainWindow.xaml.cs | 234 ++-- Encelado/src/Encelado.Bot/Ui/ChartWindow.xaml | 50 - .../src/Encelado.Bot/Ui/ChartWindow.xaml.cs | 33 - Encelado/src/Encelado.Bot/Ui/LoginWindow.xaml | 17 +- .../src/Encelado.Bot/Ui/LoginWindow.xaml.cs | 53 +- Encelado/src/Encelado.Bot/Ui/MainViewModel.cs | 221 ++-- Encelado/src/Encelado.Bot/Ui/Navigation.cs | 14 +- .../Encelado.Bot/Ui/Pages/AccountPage.xaml | 207 --- .../Encelado.Bot/Ui/Pages/AccountPage.xaml.cs | 24 - .../src/Encelado.Bot/Ui/Pages/ChartsPage.xaml | 71 -- .../Encelado.Bot/Ui/Pages/ChartsPage.xaml.cs | 44 - .../src/Encelado.Bot/Ui/Pages/OrdersPage.xaml | 96 -- .../Encelado.Bot/Ui/Pages/OrdersPage.xaml.cs | 8 - .../Encelado.Bot/Ui/Pages/PositionsPage.xaml | 227 ++-- .../Ui/Pages/PositionsPage.xaml.cs | 32 +- .../Encelado.Bot/Ui/Pages/SettingsPage.xaml | 11 +- .../Ui/Pages/SettingsPage.xaml.cs | 18 +- .../src/Encelado.Bot/Ui/Pages/StatusPage.xaml | 542 ++++---- .../Encelado.Bot/Ui/Pages/StatusPage.xaml.cs | 45 +- Encelado/src/Encelado.Bot/Ui/PriceChart.cs | 367 ------ .../src/Encelado.Bot/Ui/SettingsCatalogue.cs | 870 +++++++------ .../Encelado.Bot/Ui/SymbolChartViewModel.cs | 95 -- .../Encelado.Core/Backtest/BacktestModels.cs | 160 --- .../Encelado.Core/Backtest/PairBacktest.cs | 667 ++++++++++ .../src/Encelado.Core/Backtest/Replayer.cs | 371 ------ .../src/Encelado.Core/Market/MarketTypes.cs | 68 +- Encelado/src/Encelado.Core/Risk/RiskEngine.cs | 400 +++--- Encelado/src/Encelado.Core/Risk/RiskLimits.cs | 235 ++-- .../Encelado.Core/Statistics/Cointegration.cs | 215 ++++ .../Encelado.Core/Statistics/DickeyFuller.cs | 304 +++++ .../Encelado.Core/Statistics/Distributions.cs | 97 ++ Encelado/src/Encelado.Core/Statistics/Ols.cs | 301 +++++ .../src/Encelado.Core/Strategies/IStrategy.cs | 130 -- .../Strategies/Pairs/PairTypes.cs | 105 ++ .../Strategies/Pairs/StatArbStrategy.cs | 380 ++++++ .../Encelado.Core/Strategies/StrategyBase.cs | 98 -- .../Strategies/StrategyFactory.cs | 49 - .../Strategies/StrategyParameters.cs | 38 + .../Strategies/TrendFilterStrategy.cs | 209 --- .../tests/Encelado.Tests/AggregationTests.cs | 303 ----- Encelado/tests/Encelado.Tests/AlpacaTests.cs | 375 ------ .../tests/Encelado.Tests/BacktestTests.cs | 331 ----- Encelado/tests/Encelado.Tests/BinanceTests.cs | 285 +++++ .../Encelado.Tests/ConfigurationTests.cs | 350 ++++++ .../Encelado.Tests/CredentialStoreTests.cs | 72 +- .../Encelado.Tests/Encelado.Tests.csproj | 2 +- Encelado/tests/Encelado.Tests/EngineTests.cs | 308 ----- .../tests/Encelado.Tests/PairEngineTests.cs | 175 +++ .../tests/Encelado.Tests/PowerButtonTests.cs | 14 +- .../tests/Encelado.Tests/PriceChartTests.cs | 166 --- .../tests/Encelado.Tests/ReconnectTests.cs | 4 +- Encelado/tests/Encelado.Tests/RiskTests.cs | 414 +++--- .../tests/Encelado.Tests/SettingsFormTests.cs | 446 ------- .../ShutdownAndStrategyTests.cs | 355 ------ .../tests/Encelado.Tests/StakeSizingTests.cs | 321 ----- Encelado/tests/Encelado.Tests/StatArbTests.cs | 275 ++++ .../tests/Encelado.Tests/StatisticsTests.cs | 367 ++++++ .../tests/Encelado.Tests/TestSnapshots.cs | 146 +++ .../tests/Encelado.Tests/TrendFilterTests.cs | 283 ----- .../tests/Encelado.Tests/UiBindingTests.cs | 131 +- .../Encelado.Tests/WindowShutdownTests.cs | 174 +++ Encelado/tools/Encelado.Backtest/Explore.cs | 433 ------- Encelado/tools/Encelado.Backtest/Options.cs | 291 +++++ Encelado/tools/Encelado.Backtest/Program.cs | 1067 ++++++++-------- 109 files changed, 11991 insertions(+), 12296 deletions(-) delete mode 100644 Encelado/config/encelado.local.json.example delete mode 100644 Encelado/src/Encelado.Alpaca/AlpacaOptions.cs delete mode 100644 Encelado/src/Encelado.Alpaca/Internal/JsonRead.cs delete mode 100644 Encelado/src/Encelado.Alpaca/Internal/Rfc3339.cs delete mode 100644 Encelado/src/Encelado.Alpaca/Rest/AlpacaDataClient.cs delete mode 100644 Encelado/src/Encelado.Alpaca/Rest/AlpacaHttp.cs delete mode 100644 Encelado/src/Encelado.Alpaca/Rest/AlpacaModels.cs delete mode 100644 Encelado/src/Encelado.Alpaca/Rest/AlpacaTradingClient.cs delete mode 100644 Encelado/src/Encelado.Alpaca/Streaming/MarketDataStream.cs delete mode 100644 Encelado/src/Encelado.Alpaca/Streaming/TradeUpdateStream.cs create mode 100644 Encelado/src/Encelado.Binance/BinanceOptions.cs rename Encelado/src/{Encelado.Alpaca/Encelado.Alpaca.csproj => Encelado.Binance/Encelado.Binance.csproj} (64%) create mode 100644 Encelado/src/Encelado.Binance/Internal/JsonRead.cs create mode 100644 Encelado/src/Encelado.Binance/Rest/BinanceFuturesClient.cs create mode 100644 Encelado/src/Encelado.Binance/Rest/BinanceHttp.cs create mode 100644 Encelado/src/Encelado.Binance/Rest/BinanceModels.cs create mode 100644 Encelado/src/Encelado.Binance/Streaming/MarketDataStream.cs rename Encelado/src/{Encelado.Alpaca => Encelado.Binance}/Streaming/SymbolTable.cs (94%) create mode 100644 Encelado/src/Encelado.Binance/Streaming/UserDataStream.cs rename Encelado/src/{Encelado.Alpaca => Encelado.Binance}/Streaming/WebSocketChannel.cs (84%) create mode 100644 Encelado/src/Encelado.Bot/Configuration/ConfigDefaults.cs delete mode 100644 Encelado/src/Encelado.Bot/Engine/BarAggregator.cs delete mode 100644 Encelado/src/Encelado.Bot/Engine/ExecutionRouter.cs create mode 100644 Encelado/src/Encelado.Bot/Engine/LegState.cs create mode 100644 Encelado/src/Encelado.Bot/Engine/PairPipeline.cs create mode 100644 Encelado/src/Encelado.Bot/Engine/PairRouter.cs delete mode 100644 Encelado/src/Encelado.Bot/Engine/PriceHistory.cs delete mode 100644 Encelado/src/Encelado.Bot/Engine/SessionGuard.cs delete mode 100644 Encelado/src/Encelado.Bot/Engine/SymbolPipeline.cs delete mode 100644 Encelado/src/Encelado.Bot/Ui/ChartWindow.xaml delete mode 100644 Encelado/src/Encelado.Bot/Ui/ChartWindow.xaml.cs delete mode 100644 Encelado/src/Encelado.Bot/Ui/Pages/AccountPage.xaml delete mode 100644 Encelado/src/Encelado.Bot/Ui/Pages/AccountPage.xaml.cs delete mode 100644 Encelado/src/Encelado.Bot/Ui/Pages/ChartsPage.xaml delete mode 100644 Encelado/src/Encelado.Bot/Ui/Pages/ChartsPage.xaml.cs delete mode 100644 Encelado/src/Encelado.Bot/Ui/Pages/OrdersPage.xaml delete mode 100644 Encelado/src/Encelado.Bot/Ui/Pages/OrdersPage.xaml.cs delete mode 100644 Encelado/src/Encelado.Bot/Ui/PriceChart.cs delete mode 100644 Encelado/src/Encelado.Bot/Ui/SymbolChartViewModel.cs delete mode 100644 Encelado/src/Encelado.Core/Backtest/BacktestModels.cs create mode 100644 Encelado/src/Encelado.Core/Backtest/PairBacktest.cs delete mode 100644 Encelado/src/Encelado.Core/Backtest/Replayer.cs create mode 100644 Encelado/src/Encelado.Core/Statistics/Cointegration.cs create mode 100644 Encelado/src/Encelado.Core/Statistics/DickeyFuller.cs create mode 100644 Encelado/src/Encelado.Core/Statistics/Distributions.cs create mode 100644 Encelado/src/Encelado.Core/Statistics/Ols.cs delete mode 100644 Encelado/src/Encelado.Core/Strategies/IStrategy.cs create mode 100644 Encelado/src/Encelado.Core/Strategies/Pairs/PairTypes.cs create mode 100644 Encelado/src/Encelado.Core/Strategies/Pairs/StatArbStrategy.cs delete mode 100644 Encelado/src/Encelado.Core/Strategies/StrategyBase.cs delete mode 100644 Encelado/src/Encelado.Core/Strategies/StrategyFactory.cs create mode 100644 Encelado/src/Encelado.Core/Strategies/StrategyParameters.cs delete mode 100644 Encelado/src/Encelado.Core/Strategies/TrendFilterStrategy.cs delete mode 100644 Encelado/tests/Encelado.Tests/AggregationTests.cs delete mode 100644 Encelado/tests/Encelado.Tests/AlpacaTests.cs delete mode 100644 Encelado/tests/Encelado.Tests/BacktestTests.cs create mode 100644 Encelado/tests/Encelado.Tests/BinanceTests.cs create mode 100644 Encelado/tests/Encelado.Tests/ConfigurationTests.cs delete mode 100644 Encelado/tests/Encelado.Tests/EngineTests.cs create mode 100644 Encelado/tests/Encelado.Tests/PairEngineTests.cs delete mode 100644 Encelado/tests/Encelado.Tests/PriceChartTests.cs delete mode 100644 Encelado/tests/Encelado.Tests/SettingsFormTests.cs delete mode 100644 Encelado/tests/Encelado.Tests/ShutdownAndStrategyTests.cs delete mode 100644 Encelado/tests/Encelado.Tests/StakeSizingTests.cs create mode 100644 Encelado/tests/Encelado.Tests/StatArbTests.cs create mode 100644 Encelado/tests/Encelado.Tests/StatisticsTests.cs create mode 100644 Encelado/tests/Encelado.Tests/TestSnapshots.cs delete mode 100644 Encelado/tests/Encelado.Tests/TrendFilterTests.cs create mode 100644 Encelado/tests/Encelado.Tests/WindowShutdownTests.cs delete mode 100644 Encelado/tools/Encelado.Backtest/Explore.cs create mode 100644 Encelado/tools/Encelado.Backtest/Options.cs diff --git a/Encelado/Encelado.slnx b/Encelado/Encelado.slnx index ea35d18..99a46c3 100644 --- a/Encelado/Encelado.slnx +++ b/Encelado/Encelado.slnx @@ -1,6 +1,6 @@ - + diff --git a/Encelado/Modifiche.txt b/Encelado/Modifiche.txt index f12936f..6f9d6c0 100644 --- a/Encelado/Modifiche.txt +++ b/Encelado/Modifiche.txt @@ -1,2 +1,246 @@ -Altre cose da fare: -- \ No newline at end of file +Continua con le modifiche occupandoti anche della fase di backtesting. +Ora leggi la seguente documentazione per eseguire il backtesting: +Questo modulo effettua il backtest vettorializzato della strategia di Statistical Arbitrage, includendo la gestione dello stato della posizione (ingresso, take profit, stop loss) e il calcolo dell'impatto delle commissioni di trading (*fee* di Binance). + +### Dipendenze + +Assicurati di aver installato le librerie necessarie: + +```bash +pip install pandas numpy requests statsmodels + +``` + +--- + +### Script Python: Backtest StatArb con Metriche di Performance + +```python +import numpy as np +import pandas as pd +import requests +import statsmodels.api as sm + + +def fetch_binance_klines( + symbol: str, interval: str = "5m", limit: int = 1000 +) -> pd.Series: + """Scarica lo storico candele per Binance Futures.""" + url = "https://fapi.binance.com/fapi/v1/klines" + params = {"symbol": symbol, "interval": interval, "limit": limit} + res = requests.get(url, params=params) + res.raise_for_status() + + df = pd.DataFrame( + res.json(), + columns=[ + "open_time", + "open", + "high", + "low", + "close", + "volume", + "close_time", + "quote_asset_volume", + "number_of_trades", + "taker_buy_base_asset_volume", + "taker_buy_quote_asset_volume", + "ignore", + ], + ) + df["close"] = df["close"].astype(float) + df["open_time"] = pd.to_datetime(df["open_time"], unit="ms") + df.set_index("open_time", inplace=True) + return df["close"] + + +def run_pairs_backtest( + symbol_a: str = "ETHUSDT", + symbol_b: str = "BTCUSDT", + interval: str = "5m", + limit: int = 1000, + window: int = 100, + entry_threshold: float = 2.0, + exit_threshold: float = 0.2, + stop_loss_threshold: float = 3.5, + taker_fee: float = 0.0004, # Fee standard Binance Futures VIP0 (0.04%) +): + """Esegue il backtest vettorializzato/statale di Pairs Trading con calcolo di Sharpe, Max DD e PnL.""" + price_a = fetch_binance_klines(symbol_a, interval=interval, limit=limit) + price_b = fetch_binance_klines(symbol_b, interval=interval, limit=limit) + + df = pd.DataFrame({"price_a": price_a, "price_b": price_b}).dropna() + df["log_a"] = np.log(df["price_a"]) + df["log_b"] = np.log(df["price_b"]) + + # 1. Regressione OLS per calcolare Beta (Hedge Ratio) e Alpha + X = sm.add_constant(df["log_b"]) + model = sm.OLS(df["log_a"], X).fit() + alpha = model.params["const"] + beta = model.params["log_b"] + + # 2. Spread e Z-Score + df["spread"] = df["log_a"] - (beta * df["log_b"]) - alpha + df["rolling_mean"] = df["spread"].rolling(window=window).mean() + df["rolling_std"] = df["spread"].rolling(window=window).std() + df["z_score"] = (df["spread"] - df["rolling_mean"]) / df["rolling_std"] + + df.dropna(inplace=True) + + # 3. Simulazione Macchina a Stati per le Posizioni + # 1 = Long Spread (Long A, Short B) + # -1 = Short Spread (Short A, Long B) + # 0 = Flat / Fuori dal mercato + positions = np.zeros(len(df)) + current_pos = 0 + + z_vals = df["z_score"].values + + for i in range(1, len(df)): + z = z_vals[i] + + if current_pos == 0: + if z <= -entry_threshold: + current_pos = 1 # Entra Long Spread + elif z >= entry_threshold: + current_pos = -1 # Entra Short Spread + elif current_pos == 1: + # Exit Take Profit o Stop Loss + if z >= -exit_threshold or z <= -stop_loss_threshold: + current_pos = 0 + elif current_pos == -1: + # Exit Take Profit o Stop Loss + if z <= exit_threshold or z >= stop_loss_threshold: + current_pos = 0 + + positions[i] = current_pos + + df["position"] = positions + + # 4. Calcolo dei Rendimenti + # Rendimento percentuale dei singoli asset + df["ret_a"] = df["price_a"].pct_change() + df["ret_b"] = df["price_b"].pct_change() + + # Normalizzazione del capitale pesata per Beta: Asset A ha peso 1, Asset B ha peso Beta + total_weight = 1.0 + abs(beta) + + # Rendimento dello spread combinato: (R_a - beta * R_b) / total_weight + df["spread_ret"] = (df["ret_a"] - (beta * df["ret_b"])) / total_weight + + # Applicazione della posizione del periodo precedente (Shift per evitare look-ahead bias) + df["strat_ret"] = df["position"].shift(1) * df["spread_ret"] + + # Deduzione Commissioni Trading ad ogni cambio di posizione + trades = df["position"].diff().fillna(0) != 0 + # Ogni eseguito coinvolge due leg (A e B) + df.loc[trades, "strat_ret"] -= taker_fee * 2.0 + + df["strat_ret"].fillna(0, inplace=True) + + # 5. Metriche di Performance Metriche Finanziarie + df["equity_curve"] = (1.0 + df["strat_ret"]).cumprod() + + # PnL Cumulativo Totale + total_pnl = (df["equity_curve"].iloc[-1] - 1.0) * 100 + + # Max Drawdown + df["peak"] = df["equity_curve"].cummax() + df["drawdown"] = (df["equity_curve"] - df["peak"]) / df["peak"] + max_drawdown = df["drawdown"].min() * 100 + + # Sharpe Ratio Annualizzato + # Calcolo del fattore di annualizzazione in base al timeframe scelto + periods_per_day = { + "1m": 1440, + "5m": 288, + "15m": 96, + "1h": 24, + }.get(interval, 288) + annual_factor = np.sqrt(periods_per_day * 365) + + mean_ret = df["strat_ret"].mean() + std_ret = df["strat_ret"].std() + + sharpe_ratio = ( + (mean_ret / std_ret) * annual_factor if std_ret > 0 else 0.0 + ) + + # Conteggio Operazioni + num_trades = int((df["position"].diff().abs() > 0).sum() / 2) + + # Stampa dei Risultati + print("\n" + "=" * 50) + print(f" RISULTATI BACKTEST STATARB ({symbol_a} / {symbol_b})") + print("=" * 50) + print(f" Periodo / Candele analizzate : {len(df)} su timeframe {interval}") + print(f" Hedge Ratio (Beta) : {beta:.4f}") + print(f" Operazioni Eseguite Totalità : {num_trades}") + print(f" Net PnL Totale (con Fee) : {total_pnl:.2f}%") + print(f" Sharpe Ratio Annualizzato : {sharpe_ratio:.2f}") + print(f" Max Drawdown (Massimo Picco) : {max_drawdown:.2f}%") + print("=" * 50 + "\n") + + return df + + +if __name__ == "__main__": + # Esecuzione Backtest su 1000 candele da 5 minuti (~3.5 giorni di dati) + df_results = run_pairs_backtest( + symbol_a="ETHUSDT", + symbol_b="BTCUSDT", + interval="5m", + limit=1000, + entry_threshold=2.0, + exit_threshold=0.2, + stop_loss_threshold=3.5, + ) + +``` + +--- + +### Formula delle Metriche Calcolate + +* **Rendimento della Strategia ($R_t$):** + +$$R_t = \text{Posizione}_{t-1} \cdot \left( \frac{R_{A,t} - \beta R_{B,t}}{1 + \vert{}\beta\vert{}} \right) - \text{Fee}_t$$ + + +* **Sharpe Ratio ($S$):** +Misura il rendimento corretto per il rischio rispetto alla deviazione standard del portafoglio: + +$$S = \frac{\mu_{R}}{\sigma_{R}} \times \sqrt{N}$$ + + + +dove $N$ rappresenta il numero di periodi di trading in un anno solare (es. $288 \times 365$ per candele a 5m). +* **Maximum Drawdown ($\text{Max DD}$):** +Rappresenta la massima perdita percentuale registrata rispetto al picco precedente del capitale: + +$$\text{Max DD} = \min_{t} \left( \frac{\text{Equity}_t - \text{Peak}_t}{\text{Peak}_t} \right)$$ + +E + +Lo script può essere eseguito in **qualsiasi ambiente Python 3.9+** e **non richiede l'importazione manuale di file dati**: le funzioni interne scaricano automaticamente le candele storiche interrogando le API REST pubbliche di Binance. + +**Dove eseguire lo script** + +* **PC Locale (VS Code / PyCharm / Terminale):** La scelta ideale per lo sviluppo e i primi test. Salva il codice in un file `.py` (es. `backtest_statarb.py`) ed eseguilo direttamente con `python backtest_statarb.py`. +* **Google Colab / Jupyter Notebook:** Utile per test e analisi al volo nel browser senza configurare un ambiente di sviluppo locale. +* **Server H24 / Container Docker / VPS:** Necessario solo se trasformerai lo script in un bot live che monitora i mercati ed esegue ordini in modo continuativo. + +**Su quali dati opera lo script** + +* **Dati in tempo reale tramite API Binance:** Lo script interroga direttamente l'endpoint pubblico dei Futures Binance (`[fapi.binance.com/fapi/v1/klines](https://fapi.binance.com/fapi/v1/klines)`). Non servono API Key o Secret per leggere lo storico. +* **Coppie consigliate per il test:** +* `ETHUSDT` vs `BTCUSDT` (Grande cap, elevata stabilità della cointegrazione). +* `SOLUSDT` vs `AVAXUSDT` (Maggiore volatilità e ampiezza delle deviazioni dello Z-Score). +* `OPUSDT` vs `ARBUSDT` (Forte correlazione fondamentale tra Layer-2). + + +* **Timeframe e profondità dei dati:** +* Il parametro di default `limit=1000` con `interval="5m"` recupera le ultime **1000 candele da 5 minuti** (~3,5 giorni di dati). +* Puoi variare i parametri all'interno della funzione (es. `interval="15m"` o `interval="1h"`) per estendere la finestra temporale dell'analisi a diverse settimane. + +Puoi trovare i files .csv che contengono i dati delle coppie valutare per eseguire backtest nella cartella C:\Users\alber\Downloads\Binance. Effettua test approfonditi provando diversi parametri e cercando di trovare i parametri migliori per il bot, che diventeranno i parametri di default diff --git a/Encelado/build/Encelado.iss b/Encelado/build/Encelado.iss index f0c32b0..e9c3a90 100644 --- a/Encelado/build/Encelado.iss +++ b/Encelado/build/Encelado.iss @@ -1,4 +1,4 @@ -; ───────────────────────────────────────────────────────────────────────────── +; ───────────────────────────────────────────────────────────────────────────── ; Encelado — script di installazione (Inno Setup 6) ; ; Non si compila a mano: lo lancia build/Release.proj, che prima pubblica @@ -36,7 +36,7 @@ #define AppName "Encelado" #define AppPublisher "Alberto Balbo" #define AppExeName "Encelado.exe" -#define AppDescription "Bot di trading automatico su Alpaca" +#define AppDescription "Arbitraggio statistico su coppie, Binance Futures" [Setup] ; L'AppId identifica il prodotto fra una versione e l'altra: cambiarlo farebbe @@ -121,7 +121,7 @@ Type: filesandordirs; Name: "{app}\logs" Type: dirifempty; Name: "{app}" [Code] -{ Le credenziali Alpaca vivono in %LocalAppData%\Encelado, fuori dalla cartella +{ Le credenziali Binance vivono in %LocalAppData%\Encelado, fuori dalla cartella di installazione, quindi una disinstallazione normale non le toccherebbe. Lasciarle lì in silenzio però significa lasciare sul disco una chiave API cifrata di cui l'utente si è dimenticato. Glielo chiediamo, con il "no" come @@ -144,7 +144,7 @@ begin Exit; if MsgBox( - 'Vuoi eliminare anche le credenziali Alpaca salvate?' + #13#10#13#10 + + 'Vuoi eliminare anche le credenziali Binance salvate?' + #13#10#13#10 + DataDir + #13#10#13#10 + 'Scegli No se hai intenzione di reinstallare Encelado: le credenziali ' + 'verranno riconosciute dalla nuova installazione.', diff --git a/Encelado/build/Release.proj b/Encelado/build/Release.proj index 8c33eba..eda425f 100644 --- a/Encelado/build/Release.proj +++ b/Encelado/build/Release.proj @@ -15,7 +15,8 @@ progetti. Le differenze rispetto a quel file sono tre, tutte segnate sul posto: la versione vive in Directory.Build.props e non nel .csproj, la pubblicazione produce una cartella e non un singolo eseguibile, e il target - Backtest rigioca serie storiche di prezzi invece dei dossier delle aste. + Backtest rigioca coppie di serie storiche di prezzi invece dei dossier + delle aste. ── Perché MSBuild e non uno script ────────────────────────────────────── La catena vive accanto al codice che rilascia ed è versionata con lui: fra @@ -418,24 +419,36 @@ - split - 1d + pairs + 15m $(Radice)\tools\Encelado.Backtest\bin\Release\net10.0\backtest.exe + Text="Serve la cartella con i CSV: -p:Dati="C:\percorso\Binance", con un file <SIMBOLO>.csv per gamba.%0AComandi disponibili in -p:Comando= : run, pairs, explore, sweep, confirm, basket." /> - + - + @@ -443,7 +456,7 @@ Command="dotnet build "$(BacktestProj)" -c Release --nologo -v q" /> + Command=""$(BacktestExe)" $(Comando) --data "$(Dati)" --tf $(Barre) $(Extra)" /> diff --git a/Encelado/config/encelado.json b/Encelado/config/encelado.json index 150eee6..9b44cb4 100644 --- a/Encelado/config/encelado.json +++ b/Encelado/config/encelado.json @@ -1,149 +1,204 @@ { - "_comment": "Encelado — configurazione unica. Ogni numero qui sotto è stato verificato su due dataset indipendenti: 4.756 barre giornaliere Bitstamp (2012-2025, ripiegate da 6,8 milioni di barre da un minuto) e 3.260 barre Binance (2017-2026). Vedi il README.", + "_comment": "Encelado — arbitraggio statistico su coppie cointegrate, Binance Futures USDⓈ-M. Il bot non prende posizione sulla direzione del mercato: è long una gamba e short l'altra nel rapporto che il test di cointegrazione produce, e scommette solo sul fatto che la distanza fra le due si richiuda.", - "alpaca": { - "paper": true, - "dataFeed": "iex", - "requestsPerMinute": 180, - "httpTimeoutSeconds": 15, - "maxRetries": 4 + "_misura": "IMPORTANTE — che cosa dice il backtest, e perché dryRun parte ATTIVO. Misurato su 6,6 anni di barre da un minuto (2020-2026) di ETHUSDT, BTCUSDT, SOLUSDT e AVAXUSDT, ripiegate su 5m, 15m, 30m, 1h e 4h, con commissione taker 4 bps più 1 bp di slippage per lato. Il risultato: su 5m nessuna combinazione di soglie supera nemmeno i filtri di taratura; su 15m e 1h la ricerca a griglia trova combinazioni che rendono in taratura e in verifica, ma NESSUNA delle prime dieci resta positiva sulla terza fetta, quella che non entra mai nella scelta. Con i valori qui sotto, sulle quattro coppie misurabili, la conferma dà tre risultati negativi e uno positivo del +0,40% su una sola operazione. La ragione di fondo è che il test di cointegrazione passa solo l'8-13% del tempo, quindi in sei anni una coppia produce qualche decina di operazioni: troppo poche per distinguere un margine reale da una serie fortunata. Questi valori sono i meglio supportati fra quelli provati, non una strategia dimostrata. Falla girare in dry-run finché non hai visto coi tuoi occhi come si comporta sul tuo conto. Lo strumento è in tools/Encelado.Backtest: 'backtest basket --data ' rifa la scelta da capo sui tuoi dati.", + + "binance": { + "_testnet": "true = testnet (denaro finto, stesse API). Metterlo a false opera con denaro reale. Le chiavi dei due ambienti sono diverse e vengono salvate separatamente.", + "testnet": true, + + "_leverage": "Leva applicata a ogni simbolo all'avvio. La guida indica 2x-3x: un libro delta-neutral si liquida comunque se una sola gamba fa un salto isolato abbastanza grande. Alzarla non aumenta il margine della strategia, moltiplica soltanto guadagno e perdita.", + "leverage": 2, + + "_marginType": "CROSSED mette le due gambe nello stesso pool di margine, così si compensano invece di avere ciascuna il proprio prezzo di liquidazione.", + "marginType": "CROSSED", + + "recvWindowMs": 5000, + "requestsPerMinute": 1200, + "httpTimeoutSeconds": 10, + "maxRetries": 3 }, "engine": { - "assetClass": "crypto", + "_timeFrame": "15m. La guida indica 5m o 15m; il backtest ha bocciato 5m senza appello — nessuna combinazione di soglie supera nemmeno i filtri di taratura, perché a cinque minuti la commissione vale circa una deviazione standard dello spread. A 15m il numero di operazioni è almeno misurabile. A 1h e 4h le operazioni scendono a poche decine in sei anni: troppo poche per dire alcunché. '1m' è rifiutato dal validatore.", + "timeFrame": "15m", - "_timeFrame": "Giornaliero. Le stesse regole su barre orarie perdono il 99% del capitale: con ~50 bps di costo per giro completo la frequenza uccide prima della direzione.", - "timeFrame": "1Day", + "_warmupBars": "Barre storiche scaricate all'avvio per riempire la finestra dello z-score prima della prima decisione. Deve coprire zWindow.", + "warmupBars": 1500, - "_warmup": "La media è a 100 giorni. 220 barre danno margine.", - "warmupBars": 220, + "_calibrationBars": "Su quante barre gira la regressione di cointegrazione che produce beta e p-value. 500 è il valore della guida ed è anche il migliore misurato: a 1000 lo stesso paniere peggiora nettamente su tutte e tre le fette. Questa finestra è indipendente da zWindow, che è molto più lunga: la prima stabilisce il rapporto di copertura, la seconda dice quanto è insolito lo scostamento di adesso.", + "calibrationBars": 500, - "tradeOnlyRegularHours": false, - "flattenBeforeCloseMinutes": 0, - - "_crypto": "Alpaca sulle crypto vuole quantità frazionarie e non supporta i bracket order: lo stop lo tiene l'engine e lo verifica a ogni quotazione.", - "allowFractionalShares": true, - "useBracketOrders": false, + "_recalibrateHours": "Ogni quanto l'intero paniere viene rifittato e ritestato. La guida dice 24 ore; provate anche una settimana, senza differenze sostanziali.", + "recalibrateHours": 24, + "_entryOrderType": "'limit' invia un limite marcabile, prezzato oltre il touch di limitOffsetBps: si comporta come un ordine a mercato ma non può eseguire a un prezzo assurdo. 'market' attraversa e basta.", "entryOrderType": "limit", - "limitOffsetBps": 8, + "limitOffsetBps": 2, - "dryRun": false, + "_postOnly": "Solo maker (GTX). Fa risparmiare la commissione ma un ordine che non esegue lascia una gamba scoperta, che costa molto di più. Lasciare false se non hai misurato il tuo tasso di riempimento.", + "postOnlyEntries": false, - "_reconcile": "Ogni quanto il bot ricontrolla conto, posizioni e ordini contro il broker, e chiede le barre già chiuse. È anche il momento in cui si accorge che una barra nuova è disponibile da valutare, quindi abbassarlo lo rende più reattivo all'apertura di una barra.", - "reconcileSeconds": 30, + "_dryRun": "ATTIVO DI FABBRICA, e la ragione è nella nota _misura qui sopra. Calcola e registra tutto, non invia nessun ordine. Toglilo solo dopo aver visto in Stato che le tue coppie passano davvero il test di cointegrazione.", + "dryRun": true, - "_status": "Riepilogo periodico nel log: contatori, latenze, stato delle connessioni.", + "_reconcile": "Ogni quanto il bot ricontrolla conto, posizioni e ordini contro Binance. È anche quando recupera le barre che lo stream non ha consegnato e chiude le coppie rimaste con una gamba sola.", + "reconcileSeconds": 20, "statusSeconds": 60, - "_explain": "Ogni quanto il bot rilegge cosa farebbe al prezzo attuale e lo scrive nel log, se è cambiato rispetto a prima. Su barre giornaliere il bot è legittimamente silenzioso per settimane, e da fuori il silenzio è indistinguibile da un blocco: questa riga lo trasforma in una frase.", + "_explain": "Ogni quanto il bot rilegge cosa farebbe adesso e lo scrive nel log, se è cambiato. È la riga che distingue un bot che aspetta da uno bloccato.", "explainSeconds": 5, - "maxQuoteAgeSeconds": 120, - "closeOnShutdown": false + "_maxQuoteAge": "Rifiuta un ingresso se il book di una gamba è più vecchio di così. 0 disattiva il controllo.", + "maxQuoteAgeSeconds": 15, + + "closeOnShutdown": false, + "logEveryBar": true }, "risk": { - "_sizing": "Questa strategia compete con il comprare e tenere, quindi quando è dentro deve esserci per intero: qualunque frazione inferiore perde la gara in partenza. stakePct 1.0 impegna tutto il saldo disponibile; il risk engine si ferma comunque al 98% per lasciare spazio alle commissioni.", - "stakePct": 1.0, + "_stake": "Frazione dell'equity impegnata come MARGINE su una coppia. Con leva 2 il controvalore effettivamente al lavoro è il doppio, diviso fra le due gambe secondo β. Con 4 coppie al 15% il margine totale impegnato arriva al 60% dell'equity.", + "stakePct": 0.15, "stakeAmount": 0, - "_risk": "Non usato finché stakePct è impostato, ma deve restare valido: è il criterio di riserva se un giorno azzeri stakePct.", - "maxRiskPerTradePct": 0.05, + "_funding": "Una coppia delta-neutral incassa il funding su una gamba e lo paga sull'altra. Quando il netto è a favore la posizione rende per il solo fatto di esistere. 0.12 è il centro dell'intervallo 10-15% indicato dalla guida. 0 disattiva.", + "fundingTiltPct": 0.12, + "fundingTiltThreshold": 0.0001, - "_caps": "A 1.0 perché con un solo asset e stake pieno la posizione È il portafoglio. Abbassarli qui significa restare parzialmente liquidi e perdere rendimento senza guadagnare protezione: la protezione la dà l'uscita sotto la media.", - "maxPositionNotionalPct": 1.0, - "maxGrossExposurePct": 1.0, + "_exposure": "Controvalore lordo totale come multiplo dell'equity. Con leva 2 e 4 coppie al 15% si arriva a 1.2×: 2.0 lascia margine senza permettere il raddoppio.", + "maxGrossExposurePct": 2.0, + "maxOpenPairs": 4, - "_openPositions": "0 = nessun limite. Nota però che con un solo simbolo il numero di posizioni contemporanee resta 1 comunque: il risk engine rifiuta un secondo ingresso sullo stesso strumento con 'already in position'. E con stakePct 1.0 la prima posizione impegna tutto il saldo, quindi una seconda non avrebbe con cosa aprirsi. Questo limite torna a contare quando aggiungi simboli.", - "maxOpenPositions": 0, + "_frequency": "0 = nessun limite. Sono reti contro un difetto, non contro la strategia: un ciclo che riapre la stessa coppia cento volte costa cento volte le commissioni.", + "maxTradesPerDay": 40, + "maxTradesPerPairPerDay": 8, + "minSecondsBetweenEntries": 60, - "_frequency": "0 = nessun limite. Il bot può aprire quante posizioni vuole e fare quante operazioni vuole: a fermarlo è la strategia, non un contatore. Attenzione: erano una rete contro un bug (un ciclo che riapre la stessa posizione mille volte costa mille commissioni). Con 0 quella rete non c'è più.", - "maxTradesPerDay": 0, - "maxTradesPerSymbolPerDay": 0, - "minSecondsBetweenEntries": 0, - - "_dailyLoss": "Kill switch giornaliero. Al 25% perché su BTC un -20% in un giorno è successo più volte e non è una ragione per smettere: la strategia esce quando cede la media, non quando fa male. Troppo stretto qui significa liquidare sul minimo.", - "maxDailyLossPct": 0.25, + "_dailyLoss": "Kill switch giornaliero, non disattivabile. Su futures con leva è l'ultima fermata prima di una liquidazione.", + "maxDailyLossPct": 0.06, "maxDailyProfitPct": 0, - "maxRelativeSpread": 0.0015, - "minPrice": 0.01, - "maxPrice": 10000000, + "_spread": "Il book più largo che una gamba può mostrare ed essere comunque entrata. Conta molto più che su un modello direzionale: un giro completo attraversa lo spread QUATTRO volte, quindi un book da 10 bps costa 40 bps contro un rientro che spesso vale meno.", + "maxRelativeSpread": 0.0006, + + "_notional": "Controvalore minimo e massimo per gamba, in USDT. Binance ha anche i suoi minimi per simbolo, più stringenti su BTCUSDT.", "minOrderNotional": 25, "maxOrderNotional": 0, - "_shorting": "Alpaca non consente lo short sulle crypto. La strategia è long/flat.", - "allowShorting": false, + "_margin": "Rapporto massimo fra margine di mantenimento ed equity oltre il quale non si aprono nuove coppie.", + "maxMarginRatio": 0.5, - "_stop": "Rete di sicurezza per un gap, non il controllo del rischio. Quello vero è l'uscita sotto la media: uno stop stretto venderebbe e poi aspetterebbe un nuovo incrocio per rientrare, che è esattamente come il modello precedente trasformava le oscillazioni in perdite realizzate.", - "defaultStopPct": 0.35, - "maxStopDistancePct": 0.60 + "_hedge": "β fuori da [1/5, 5] viene rifiutato: non è una copertura, è una scommessa sulla seconda gamba travestita da copertura.", + "maxHedgeRatio": 5.0 }, "logging": { - "_level": "trace | debug | info | warn | error | none. 'debug' registra anche ogni segnale scartato e ogni rifiuto del risk engine: utile per capire perché il bot NON ha fatto qualcosa.", - "level": "debug", + "_level": "trace | debug | info | warn | error | none. Ogni rifiuto che ferma un ordine viene scritto a 'info' o sopra, quindi 'debug' serve per il flusso dati, non per capire perché il bot non ha operato.", + "level": "info", - "_directory": "Dove salvare tutti gli output. Relativa all'eseguibile, oppure un percorso assoluto tipo D:\\encelado-logs. Si cambia anche da Impostazioni → Log, che verifica di potervi scrivere prima di salvare.", + "_directory": "Dove salvare tutti gli output. Relativa all'eseguibile, oppure un percorso assoluto. Si cambia anche da Impostazioni.", "directory": "logs", "console": false, "file": "encelado.log", - - "_rotation": "Ruota encelado.log in encelado.1.log e così via, tenendo gli ultimi 10.", "maxFileSizeMb": 32, "maxFiles": 10, - "_analysis": "decisions.csv ha una riga per ogni barra valutata con tutti gli indicatori; executions.csv ha una riga per ogni segnale arrivato agli ordini, con il verdetto del risk engine. Si uniscono su decisionId. Sono il materiale per migliorare il modello.", + "_analysis": "decisions.csv ha una riga per ogni barra valutata con spread, z-score e calibrazione; executions.csv una riga per ogni segnale arrivato agli ordini, con il verdetto del risk engine. Si uniscono su decisionId.", "tradeJournal": "trades.jsonl", "decisionLog": "decisions.csv", "executionLog": "executions.csv", - "_verbose": "Con logMarketData attivo e level=trace registra ogni singola quotazione e ogni print. File enormi: serve solo per diagnosticare il flusso dati.", "logMarketData": false, - - "_everyBar": "Scrive una riga per ogni barra da un minuto che arriva dallo stream, non solo per quelle che chiudono una barra della strategia. Su barre giornaliere 1439 minuti su 1440 vengono assorbiti in silenzio: senza questo il log non mostra nulla per ventiquattr'ore e il bot sembra fermo.", - "logEveryBar": true, - - "_inApp": "Quante righe tiene la striscia ATTIVITÀ nella pagina Stato e quante ne tiene la scheda Log. La seconda è il tetto di memoria del log in-app. Il file su disco resta completo comunque.", "statusLines": 200, "bufferedLines": 5000 }, - "ui": { - "url": "http://localhost:5088", - "autoStartBot": false, - "openBrowser": false - }, + "_pairs": "Il paniere della guida. Ogni coppia si legge come ln(A) − β·ln(B): A è la gamba su cui si misura lo spread, B quella di copertura. Il test di cointegrazione decide da solo, a ogni ricalibrazione, se una coppia è operabile: quelle che non passano restano in elenco e non vengono aperte. Sui dati disponibili passano il test solo l'8-17% del tempo, quindi il bot resta fermo a lungo — è il comportamento previsto.", - "_symbols": "Solo BTC/USD. ETH è stato tolto: la strategia è tarata e verificata su BTC, e con stakePct 1.0 un secondo asset dimezzerebbe l'esposizione al primo senza che nessun backtest lo giustifichi.", - "symbols": [ + "_parametri": "I valori qui sotto vengono dalla ricerca a griglia (tools/Encelado.Backtest, comando 'basket'), non dalla guida. Le differenze rispetto alla guida sono tre e sono tutte misurate: zWindow 1500 invece di 100, entryZ 2.5 invece di 2.0, stopZ 6.0 invece di 3.5. La prima è la più importante: con una finestra vicina all'emivita del rientro (30-40 barre) la media mobile insegue lo scostamento e lo assorbe, e il margine sparisce prima ancora delle commissioni.", + + "pairs": [ { - "symbol": "BTC/USD", - "strategy": "trend-filter", + "_note": "Benchmark. È l'unica coppia del paniere risultata positiva sull'intero periodo, ma con sole 35-50 operazioni in 6,6 anni: troppe poche perché il risultato significhi qualcosa.", + "symbolA": "ETHUSDT", + "symbolB": "BTCUSDT", "enabled": true, "parameters": { - "_period": "Media a 100 giorni. È l'unico valore che batte il comprare e tenere su ENTRAMBI i dataset: 120 rende di più su Bitstamp ma perde su Binance, 200 perde su tutti e due. La riga dei 100 giorni vince su entrambi a qualunque banda.", - "period": 100, + "_zWindow": "Finestra mobile su cui si normalizza lo spread. 1500 barre da 15 minuti sono circa 15 giorni. Deve essere molte volte l'emivita del rientro (qui 30-40 barre), altrimenti la media insegue lo scostamento e lo cancella.", + "zWindow": 1500, - "_band": "Isteresi, non un filtro: si entra il 2% sopra la media e si esce il 2% sotto, così un prezzo appoggiato alla media non genera un'operazione ogni due giorni. Dimezza gli scambi lasciando il rendimento dov'era.", - "band": 0.02, + "_entryZ": "Quante deviazioni standard di scostamento servono per aprire. 2.5-3.0 ha battuto costantemente 2.0.", + "entryZ": 2.5, - "_stop": "Rete per un gap. La vera uscita è la media.", - "stopPct": 0.35, + "_exitZ": "Sotto questo valore lo spread è rientrato: si chiude in guadagno.", + "exitZ": 0.5, - "_cvd": "Gate di order flow, disattivato. Misurato su Binance con il volume taker: alzandolo il Calmar scende da 0,71 a 0,66 a 0,64. Serviva al modello precedente, che operava di rado e poteva permettersi di aspettare conferma; qui ogni barra passata ad aspettare è una barra che non compone. Il valore resta calcolato e registrato nei log.", - "cvdThreshold": 0, - "cvdPeriod": 10, - "cvdNormPeriod": 60, + "_stopZ": "Stop statistico: non è uno stop di prezzo, è l'affermazione che la relazione ha smesso di valere. 6.0 ha battuto 3.5 e 4.0 — uno stop stretto su uno spread che rientra lentamente realizza perdite che sarebbero rientrate.", + "stopZ": 6.0, - "_diagnostics": "Solo per il pannello e i log, non entrano in nessuna decisione.", - "volPeriod": 30, - "barsPerYear": 365, - "atrPeriod": 14, + "_maxPValue": "Soglia di cointegrazione. È il filtro che tiene in piedi tutto: senza, ogni combinazione provata passa da leggermente positiva a −73%/−87%.", + "maxPValue": 0.05, - "allowShort": 0 + "_halfLife": "Emivita del rientro, in barre. Sotto il minimo lo spread rientra troppo in fretta per ripagare le commissioni; sopra il massimo il capitale resta impegnato più a lungo di quanto duri la relazione.", + "minHalfLife": 3, + "maxHalfLife": 200, + + "_maxBarsInTrade": "Stop temporale in barre. 0 disattiva: nei test non ha migliorato nulla, ma è la rete contro il caso peggiore — uno spread che si ferma a metà strada e ci resta per giorni.", + "maxBarsInTrade": 0, + + "requireCointegration": 1 + } + }, + { + "_note": "Layer-1. Verificata sul backtest: negativa sull'intero periodo con questi parametri.", + "symbolA": "SOLUSDT", + "symbolB": "AVAXUSDT", + "enabled": true, + "parameters": { + "zWindow": 1500, + "entryZ": 2.5, + "exitZ": 0.5, + "stopZ": 6.0, + "maxPValue": 0.05, + "minHalfLife": 3, + "maxHalfLife": 200, + "maxBarsInTrade": 0, + "requireCointegration": 1 + } + }, + { + "_note": "Layer-2 su Ethereum. NON verificata: non avevo dati storici di OP e ARB. È la coppia con le premesse migliori — stesso ecosistema, stessa età, stessi flussi — ma finché non la misuri resta un'ipotesi.", + "symbolA": "OPUSDT", + "symbolB": "ARBUSDT", + "enabled": true, + "parameters": { + "zWindow": 1500, + "entryZ": 2.5, + "exitZ": 0.5, + "stopZ": 6.0, + "maxPValue": 0.05, + "minHalfLife": 3, + "maxHalfLife": 200, + "maxBarsInTrade": 0, + "requireCointegration": 1 + } + }, + { + "_note": "DeFi blue-chip. NON verificata: non avevo dati storici di LINK e UNI.", + "symbolA": "LINKUSDT", + "symbolB": "UNIUSDT", + "enabled": true, + "parameters": { + "zWindow": 1500, + "entryZ": 2.5, + "exitZ": 0.5, + "stopZ": 6.0, + "maxPValue": 0.05, + "minHalfLife": 3, + "maxHalfLife": 200, + "maxBarsInTrade": 0, + "requireCointegration": 1 } } ] diff --git a/Encelado/config/encelado.local.json.example b/Encelado/config/encelado.local.json.example deleted file mode 100644 index 7838d4f..0000000 --- a/Encelado/config/encelado.local.json.example +++ /dev/null @@ -1,12 +0,0 @@ -{ - "_comment": "Copy this file to encelado.local.json (gitignored) next to encelado.json. It is merged on top of the main config, so it only needs the keys you want to override. Environment variables still win over both.", - - "alpaca": { - "keyId": "PK...........", - "secretKey": "................................" - }, - - "engine": { - "dryRun": true - } -} diff --git a/Encelado/src/Encelado.Alpaca/AlpacaOptions.cs b/Encelado/src/Encelado.Alpaca/AlpacaOptions.cs deleted file mode 100644 index 25c93f6..0000000 --- a/Encelado/src/Encelado.Alpaca/AlpacaOptions.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Encelado.Core.Market; - -namespace Encelado.Alpaca; - -/// Connection settings for every Alpaca endpoint the bot talks to. -public sealed class AlpacaOptions -{ - public const string PaperTradingBase = "https://paper-api.alpaca.markets"; - public const string LiveTradingBase = "https://api.alpaca.markets"; - public const string MarketDataBase = "https://data.alpaca.markets"; - public const string MarketDataStreamBase = "wss://stream.data.alpaca.markets"; - - public string KeyId { get; set; } = string.Empty; - - public string SecretKey { get; set; } = string.Empty; - - /// Paper trading is the default. Flipping this to risks real money. - public bool Paper { get; set; } = true; - - /// - /// Equity data feed: iex (free), sip (full tape, paid), - /// delayed_sip, or test (Alpaca's synthetic FAKEPACA stream). - /// - public string DataFeed { get; set; } = "iex"; - - /// Overrides the trading REST base URL. Leave empty to derive it from . - public string TradingBaseUrlOverride { get; set; } = string.Empty; - - /// Overrides the market-data REST base URL. - public string DataBaseUrlOverride { get; set; } = string.Empty; - - /// Client-side throttle. Alpaca allows 200 requests/minute per account on the basic plan. - public int RequestsPerMinute { get; set; } = 180; - - public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(15); - - /// Number of retries for transient failures (429 / 5xx / socket errors). - public int MaxRetries { get; set; } = 4; - - public string TradingBaseUrl => - string.IsNullOrWhiteSpace(TradingBaseUrlOverride) - ? (Paper ? PaperTradingBase : LiveTradingBase) - : TradingBaseUrlOverride.TrimEnd('/'); - - public string DataBaseUrl => - string.IsNullOrWhiteSpace(DataBaseUrlOverride) - ? MarketDataBase - : DataBaseUrlOverride.TrimEnd('/'); - - /// Order/position event stream. Lives on the trading host, not the data host. - public Uri TradeUpdatesStreamUri => - new(TradingBaseUrl.Replace("https://", "wss://", StringComparison.Ordinal) + "/stream"); - - public Uri MarketDataStreamUri(AssetClass assetClass) => assetClass switch - { - AssetClass.Crypto => new Uri($"{MarketDataStreamBase}/v1beta3/crypto/us"), - _ => new Uri($"{MarketDataStreamBase}/v2/{DataFeed}"), - }; - - public AlpacaOptions Validate() - { - if (string.IsNullOrWhiteSpace(KeyId) || string.IsNullOrWhiteSpace(SecretKey)) - { - throw new InvalidOperationException( - "Alpaca credentials are missing. Set APCA_API_KEY_ID and APCA_API_SECRET_KEY " + - "(or alpaca.keyId / alpaca.secretKey in the config file)."); - } - - // Credentials travel as HTTP headers. A stray non-ASCII character (a smart quote - // from a copy/paste, a BOM, a UTF-16 artefact from a pipe) would otherwise - // surface much later as an opaque "invalid char encoding" transport failure. - RequirePrintableAscii(KeyId, nameof(KeyId)); - RequirePrintableAscii(SecretKey, nameof(SecretKey)); - - if (DataFeed is not ("iex" or "sip" or "delayed_sip" or "otc" or "test")) - { - throw new InvalidOperationException( - $"alpaca.dataFeed '{DataFeed}' is not one of: iex, sip, delayed_sip, otc, test."); - } - - if (RequestsPerMinute is < 1 or > 1000) - { - throw new InvalidOperationException("alpaca.requestsPerMinute must be between 1 and 1000."); - } - - return this; - } - - private static void RequirePrintableAscii(string value, string field) - { - foreach (char c in value) - { - if (c is < ' ' or > '~') - { - throw new InvalidOperationException( - $"alpaca.{char.ToLowerInvariant(field[0])}{field[1..]} contains a character that is not " + - $"printable ASCII (U+{(int)c:X4}). Re-copy the key from the Alpaca dashboard — " + - "invisible characters are usually picked up by copy/paste."); - } - } - } -} - -/// Raised when Alpaca answers with a non-success status or an unusable payload. -public sealed class AlpacaApiException(string message, int statusCode = 0, string? body = null) - : Exception(message) -{ - public int StatusCode { get; } = statusCode; - - public string? Body { get; } = body; - - /// Transient conditions worth retrying. - public bool IsTransient => StatusCode is 429 or >= 500; -} diff --git a/Encelado/src/Encelado.Alpaca/Internal/JsonRead.cs b/Encelado/src/Encelado.Alpaca/Internal/JsonRead.cs deleted file mode 100644 index e927ab2..0000000 --- a/Encelado/src/Encelado.Alpaca/Internal/JsonRead.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Globalization; -using System.Text.Json; - -namespace Encelado.Alpaca.Internal; - -/// -/// Reading helpers for Alpaca's REST payloads. Alpaca encodes most numeric fields as -/// JSON strings ("qty": "10"), and omits or nulls fields liberally, so -/// every accessor tolerates both shapes and a missing property. -/// -public static class JsonRead -{ - public static string? StringOrNull(this JsonElement e, string name) => - e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String - ? v.GetString() - : null; - - public static string StringOrEmpty(this JsonElement e, string name) => - e.StringOrNull(name) ?? string.Empty; - - public static double Double(this JsonElement e, string name, double fallback = 0) - { - if (!e.TryGetProperty(name, out JsonElement v)) - { - return fallback; - } - - return v.ValueKind switch - { - JsonValueKind.Number => v.GetDouble(), - JsonValueKind.String => double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) - ? d - : fallback, - _ => fallback, - }; - } - - public static decimal Decimal(this JsonElement e, string name, decimal fallback = 0) - { - if (!e.TryGetProperty(name, out JsonElement v)) - { - return fallback; - } - - return v.ValueKind switch - { - JsonValueKind.Number => v.GetDecimal(), - JsonValueKind.String => decimal.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out decimal d) - ? d - : fallback, - _ => fallback, - }; - } - - public static int Int32(this JsonElement e, string name, int fallback = 0) - { - if (!e.TryGetProperty(name, out JsonElement v)) - { - return fallback; - } - - return v.ValueKind switch - { - JsonValueKind.Number => v.TryGetInt32(out int i) ? i : (int)v.GetDouble(), - JsonValueKind.String => int.TryParse(v.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int i) - ? i - : fallback, - _ => fallback, - }; - } - - public static bool Bool(this JsonElement e, string name, bool fallback = false) - { - if (!e.TryGetProperty(name, out JsonElement v)) - { - return fallback; - } - - return v.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.String => bool.TryParse(v.GetString(), out bool b) ? b : fallback, - _ => fallback, - }; - } - - public static DateTime Timestamp(this JsonElement e, string name) => - e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String - ? Rfc3339.ParseUtc(v.GetString()) - : DateTime.MinValue; - - public static DateTime? TimestampOrNull(this JsonElement e, string name) - { - DateTime dt = e.Timestamp(name); - return dt == DateTime.MinValue ? null : dt; - } -} diff --git a/Encelado/src/Encelado.Alpaca/Internal/Rfc3339.cs b/Encelado/src/Encelado.Alpaca/Internal/Rfc3339.cs deleted file mode 100644 index 0682b77..0000000 --- a/Encelado/src/Encelado.Alpaca/Internal/Rfc3339.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Globalization; - -namespace Encelado.Alpaca.Internal; - -/// -/// Hand-rolled RFC3339 parser for the shape Alpaca actually emits -/// (2024-05-17T13:04:56.334262119Z). It runs on every tick of the market-data -/// stream, so it avoids the general-purpose date parser and its culture lookups. -/// Falls back to -/// for anything unusual (offsets, missing fractions, non-UTC). -/// -public static class Rfc3339 -{ - /// Parses a UTC timestamp from UTF-8 bytes. Returns on failure. - public static DateTime ParseUtc(ReadOnlySpan utf8) - { - // Fast path: exactly "YYYY-MM-DDTHH:MM:SS" plus optional ".fraction" and a "Z". - if (utf8.Length >= 20 && utf8[^1] == (byte)'Z' && - utf8[4] == (byte)'-' && utf8[7] == (byte)'-' && - (utf8[10] == (byte)'T' || utf8[10] == (byte)' ') && - utf8[13] == (byte)':' && utf8[16] == (byte)':') - { - if (TryDigits(utf8, 0, 4, out int year) && - TryDigits(utf8, 5, 2, out int month) && - TryDigits(utf8, 8, 2, out int day) && - TryDigits(utf8, 11, 2, out int hour) && - TryDigits(utf8, 14, 2, out int minute) && - TryDigits(utf8, 17, 2, out int second)) - { - long fractionTicks = 0; - if (utf8.Length > 20 && utf8[19] == (byte)'.') - { - // Consume up to 7 fractional digits (100 ns resolution); ignore the rest. - int i = 20; - int digits = 0; - long value = 0; - while (i < utf8.Length - 1 && utf8[i] >= (byte)'0' && utf8[i] <= (byte)'9') - { - if (digits < 7) - { - value = (value * 10) + (utf8[i] - (byte)'0'); - digits++; - } - - i++; - } - - while (digits < 7) - { - value *= 10; - digits++; - } - - fractionTicks = value; - } - - try - { - return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc) - .AddTicks(fractionTicks); - } - catch (ArgumentOutOfRangeException) - { - return DateTime.MinValue; - } - } - } - - return SlowParse(utf8); - } - - public static DateTime ParseUtc(string? text) - { - if (string.IsNullOrEmpty(text)) - { - return DateTime.MinValue; - } - - return DateTime.TryParse( - text, - CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, - out DateTime dt) - ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) - : DateTime.MinValue; - } - - private static DateTime SlowParse(ReadOnlySpan utf8) - { - Span chars = utf8.Length <= 64 ? stackalloc char[utf8.Length] : new char[utf8.Length]; - for (int i = 0; i < utf8.Length; i++) - { - chars[i] = (char)utf8[i]; - } - - return DateTime.TryParse( - chars, - CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, - out DateTime dt) - ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) - : DateTime.MinValue; - } - - private static bool TryDigits(ReadOnlySpan utf8, int offset, int count, out int value) - { - value = 0; - for (int i = offset; i < offset + count; i++) - { - byte b = utf8[i]; - if (b < (byte)'0' || b > (byte)'9') - { - return false; - } - - value = (value * 10) + (b - (byte)'0'); - } - - return true; - } -} diff --git a/Encelado/src/Encelado.Alpaca/Rest/AlpacaDataClient.cs b/Encelado/src/Encelado.Alpaca/Rest/AlpacaDataClient.cs deleted file mode 100644 index 2314702..0000000 --- a/Encelado/src/Encelado.Alpaca/Rest/AlpacaDataClient.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System.Globalization; -using System.Text; -using System.Text.Json; -using Encelado.Alpaca.Internal; -using Encelado.Core.Market; - -namespace Encelado.Alpaca.Rest; - -/// -/// Historical and latest-snapshot market data. Used to warm indicators up before the -/// live stream takes over, and by the replay/backtest mode. -/// -public sealed class AlpacaDataClient(AlpacaOptions options) : IDisposable -{ - private readonly AlpacaHttp _http = new(options.Validate(), options.DataBaseUrl); - private readonly string _feed = options.DataFeed; - - /// Alpaca caps a single bars page at 10 000 rows. - private const int PageLimit = 10_000; - - public Task WarmupAsync(CancellationToken ct) => - _http.WarmupAsync("v2/stocks/bars?symbols=SPY&timeframe=1Day&limit=1", ct); - - /// - /// Fetches historical bars for one or more symbols in chronological order, - /// following pagination across the whole requested range. - /// - /// keeps the most recent N bars, which is - /// what indicator warm-up needs — trimming while paging would keep the oldest ones - /// and leave the strategies primed with stale state. - /// - /// - public async Task>> GetBarsAsync( - IReadOnlyList symbols, - TimeFrame timeFrame, - DateTime startUtc, - DateTime? endUtc, - AssetClass assetClass, - int maxBarsPerSymbol, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(symbols); - Dictionary> result = new(StringComparer.OrdinalIgnoreCase); - if (symbols.Count == 0 || maxBarsPerSymbol <= 0) - { - return result; - } - - string basePath = assetClass == AssetClass.Crypto - ? "v1beta3/crypto/us/bars" - : "v2/stocks/bars"; - - string? pageToken = null; - int guard = 0; - - do - { - StringBuilder path = new(256); - path.Append(basePath) - .Append("?symbols=").Append(Uri.EscapeDataString(string.Join(',', symbols))) - .Append("&timeframe=").Append(timeFrame.ToAlpaca()) - .Append("&limit=").Append(PageLimit) - .Append("&sort=asc") - .Append("&start=").Append(Uri.EscapeDataString(FormatInstant(startUtc))); - - if (endUtc is { } end) - { - path.Append("&end=").Append(Uri.EscapeDataString(FormatInstant(end))); - } - - if (assetClass != AssetClass.Crypto) - { - path.Append("&adjustment=raw&feed=").Append(_feed == "test" ? "iex" : _feed); - } - - if (pageToken is not null) - { - path.Append("&page_token=").Append(Uri.EscapeDataString(pageToken)); - } - - using JsonDocument doc = await _http.GetAsync(path.ToString(), ct).ConfigureAwait(false); - JsonElement root = doc.RootElement; - - if (root.TryGetProperty("bars", out JsonElement barsBySymbol) && - barsBySymbol.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty symbolBars in barsBySymbol.EnumerateObject()) - { - if (!result.TryGetValue(symbolBars.Name, out List? list)) - { - list = new List(Math.Min(maxBarsPerSymbol, 1024)); - result[symbolBars.Name] = list; - } - - foreach (JsonElement b in symbolBars.Value.EnumerateArray()) - { - list.Add(ParseBar(b)); - } - } - } - - pageToken = root.TryGetProperty("next_page_token", out JsonElement token) && - token.ValueKind == JsonValueKind.String - ? token.GetString() - : null; - } - while (pageToken is not null && ++guard < 500); - - // Keep only the newest slice, preserving chronological order. - foreach (string key in result.Keys) - { - List bars = result[key]; - if (bars.Count > maxBarsPerSymbol) - { - result[key] = bars.GetRange(bars.Count - maxBarsPerSymbol, maxBarsPerSymbol); - } - } - - return result; - } - - public async Task> GetLatestQuotesAsync( - IReadOnlyList symbols, - AssetClass assetClass, - CancellationToken ct) - { - Dictionary result = new(StringComparer.OrdinalIgnoreCase); - if (symbols.Count == 0) - { - return result; - } - - string path = assetClass == AssetClass.Crypto - ? $"v1beta3/crypto/us/latest/quotes?symbols={Uri.EscapeDataString(string.Join(',', symbols))}" - : $"v2/stocks/quotes/latest?symbols={Uri.EscapeDataString(string.Join(',', symbols))}&feed={(_feed == "test" ? "iex" : _feed)}"; - - using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false); - if (doc.RootElement.TryGetProperty("quotes", out JsonElement quotes) && - quotes.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty p in quotes.EnumerateObject()) - { - result[p.Name] = new Quote( - p.Value.Timestamp("t"), - p.Value.Double("bp"), - p.Value.Double("bs"), - p.Value.Double("ap"), - p.Value.Double("as")); - } - } - - return result; - } - - public async Task> GetLatestTradesAsync( - IReadOnlyList symbols, - AssetClass assetClass, - CancellationToken ct) - { - Dictionary result = new(StringComparer.OrdinalIgnoreCase); - if (symbols.Count == 0) - { - return result; - } - - string path = assetClass == AssetClass.Crypto - ? $"v1beta3/crypto/us/latest/trades?symbols={Uri.EscapeDataString(string.Join(',', symbols))}" - : $"v2/stocks/trades/latest?symbols={Uri.EscapeDataString(string.Join(',', symbols))}&feed={(_feed == "test" ? "iex" : _feed)}"; - - using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false); - if (doc.RootElement.TryGetProperty("trades", out JsonElement trades) && - trades.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty p in trades.EnumerateObject()) - { - result[p.Name] = new Tick(p.Value.Timestamp("t"), p.Value.Double("p"), p.Value.Double("s")); - } - } - - return result; - } - - internal static Bar ParseBar(JsonElement e) => new( - e.Timestamp("t"), - e.Double("o"), - e.Double("h"), - e.Double("l"), - e.Double("c"), - e.Double("v"), - e.Double("vw"), - e.Int32("n")); - - private static string FormatInstant(DateTime utc) => - DateTime.SpecifyKind(utc, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); - - public void Dispose() => _http.Dispose(); -} diff --git a/Encelado/src/Encelado.Alpaca/Rest/AlpacaHttp.cs b/Encelado/src/Encelado.Alpaca/Rest/AlpacaHttp.cs deleted file mode 100644 index 756c088..0000000 --- a/Encelado/src/Encelado.Alpaca/Rest/AlpacaHttp.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Net.Http.Headers; -using System.Text.Json; - -namespace Encelado.Alpaca.Rest; - -/// -/// Shared HTTP transport for the Alpaca REST APIs: one pooled, pre-warmed HTTP/2 -/// connection per host, a client-side rate limiter that keeps us under Alpaca's -/// 200 req/min, and bounded retries for transient failures. -/// -public sealed class AlpacaHttp : IDisposable -{ - private readonly HttpClient _http; - private readonly MinuteRateLimiter _limiter; - private readonly int _maxRetries; - - public AlpacaHttp(AlpacaOptions options, string baseUrl) - { - ArgumentNullException.ThrowIfNull(options); - - SocketsHttpHandler handler = new() - { - // Long-lived pooled connections: TLS handshakes are the single biggest - // source of order latency, so we never want to pay one on the hot path. - PooledConnectionLifetime = TimeSpan.FromMinutes(10), - PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5), - MaxConnectionsPerServer = 16, - EnableMultipleHttp2Connections = true, - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - ConnectTimeout = TimeSpan.FromSeconds(10), - KeepAlivePingDelay = TimeSpan.FromSeconds(30), - KeepAlivePingTimeout = TimeSpan.FromSeconds(10), - KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests, - }; - - _http = new HttpClient(handler, disposeHandler: true) - { - BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"), - Timeout = options.HttpTimeout, - DefaultRequestVersion = HttpVersion.Version20, - DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower, - }; - - _http.DefaultRequestHeaders.Add("APCA-API-KEY-ID", options.KeyId); - _http.DefaultRequestHeaders.Add("APCA-API-SECRET-KEY", options.SecretKey); - _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _http.DefaultRequestHeaders.UserAgent.ParseAdd("Encelado/2.0"); - - _limiter = new MinuteRateLimiter(options.RequestsPerMinute); - _maxRetries = Math.Max(0, options.MaxRetries); - } - - /// - /// Opens the TLS connection ahead of the first real request so the first order - /// does not pay for the handshake. - /// - public async Task WarmupAsync(string probePath, CancellationToken ct) - { - try - { - using JsonDocument _ = await GetAsync(probePath, ct).ConfigureAwait(false); - } - catch (AlpacaApiException) - { - // A 4xx still means the socket is up, which is all warm-up needs. - } - } - - public Task GetAsync(string path, CancellationToken ct) => - SendAsync(HttpMethod.Get, path, null, ct); - - public Task PostAsync(string path, ReadOnlyMemory json, CancellationToken ct) => - SendAsync(HttpMethod.Post, path, json, ct); - - public Task PatchAsync(string path, ReadOnlyMemory json, CancellationToken ct) => - SendAsync(HttpMethod.Patch, path, json, ct); - - public Task DeleteAsync(string path, CancellationToken ct) => - SendAsync(HttpMethod.Delete, path, null, ct); - - /// Like but maps HTTP 404 to . - public async Task GetOrNullAsync(string path, CancellationToken ct) - { - try - { - return await GetAsync(path, ct).ConfigureAwait(false); - } - catch (AlpacaApiException ex) when (ex.StatusCode == 404) - { - return null; - } - } - - private async Task SendAsync( - HttpMethod method, - string path, - ReadOnlyMemory? body, - CancellationToken ct) - { - AlpacaApiException? last = null; - - for (int attempt = 0; attempt <= _maxRetries; attempt++) - { - await _limiter.WaitAsync(ct).ConfigureAwait(false); - - using HttpRequestMessage request = new(method, path); - if (body is { } payload) - { - request.Content = new ReadOnlyMemoryContent(payload); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - } - - HttpResponseMessage? response = null; - try - { - response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct) - .ConfigureAwait(false); - - if (response.IsSuccessStatusCode) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.NoContent || response.Content.Headers.ContentLength == 0) - { - return JsonDocument.Parse("{}"u8.ToArray()); - } - - return await JsonDocument.ParseAsync(stream, default, ct).ConfigureAwait(false); - } - - string errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - last = new AlpacaApiException( - $"{method} {path} -> {(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(errorBody)}", - (int)response.StatusCode, - errorBody); - - if (!last.IsTransient || attempt == _maxRetries) - { - throw last; - } - - await BackoffAsync(attempt, response.Headers.RetryAfter, ct).ConfigureAwait(false); - } - catch (HttpRequestException ex) when (attempt < _maxRetries) - { - last = new AlpacaApiException($"{method} {path} -> transport failure: {ex.Message}"); - await BackoffAsync(attempt, null, ct).ConfigureAwait(false); - } - catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < _maxRetries) - { - last = new AlpacaApiException($"{method} {path} -> timed out after {_http.Timeout.TotalSeconds:F0}s"); - await BackoffAsync(attempt, null, ct).ConfigureAwait(false); - } - finally - { - response?.Dispose(); - } - } - - throw last ?? new AlpacaApiException($"{method} {path} failed without a response."); - } - - private static async Task BackoffAsync(int attempt, RetryConditionHeaderValue? retryAfter, CancellationToken ct) - { - TimeSpan delay; - if (retryAfter?.Delta is { } delta && delta > TimeSpan.Zero) - { - delay = delta; - } - else - { - double baseMs = 200 * Math.Pow(2, attempt); - delay = TimeSpan.FromMilliseconds(baseMs + Random.Shared.Next(0, 150)); - } - - await Task.Delay(delay > TimeSpan.FromSeconds(30) ? TimeSpan.FromSeconds(30) : delay, ct) - .ConfigureAwait(false); - } - - private static string Truncate(string s) => s.Length <= 400 ? s : s[..400] + "…"; - - public void Dispose() => _http.Dispose(); -} - -/// -/// Sliding-window limiter: remembers when each of the last N requests went out and -/// blocks until the oldest falls out of the 60 second window. -/// -internal sealed class MinuteRateLimiter(int permitsPerMinute) -{ - private static readonly long WindowTicks = Stopwatch.Frequency * 60; - - private readonly long[] _sentAt = new long[Math.Max(1, permitsPerMinute)]; - private readonly SemaphoreSlim _gate = new(1, 1); - private int _index; - - public async ValueTask WaitAsync(CancellationToken ct) - { - await _gate.WaitAsync(ct).ConfigureAwait(false); - try - { - long now = Stopwatch.GetTimestamp(); - long oldest = _sentAt[_index]; - - if (oldest != 0) - { - long elapsed = now - oldest; - if (elapsed < WindowTicks) - { - double waitSeconds = (WindowTicks - elapsed) / (double)Stopwatch.Frequency; - await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct).ConfigureAwait(false); - now = Stopwatch.GetTimestamp(); - } - } - - _sentAt[_index] = now; - _index = _index + 1 == _sentAt.Length ? 0 : _index + 1; - } - finally - { - _gate.Release(); - } - } -} diff --git a/Encelado/src/Encelado.Alpaca/Rest/AlpacaModels.cs b/Encelado/src/Encelado.Alpaca/Rest/AlpacaModels.cs deleted file mode 100644 index c165c8b..0000000 --- a/Encelado/src/Encelado.Alpaca/Rest/AlpacaModels.cs +++ /dev/null @@ -1,350 +0,0 @@ -using System.Text.Json; -using Encelado.Alpaca.Internal; -using Encelado.Core.Market; - -namespace Encelado.Alpaca.Rest; - -public enum OrderStatus : byte -{ - Unknown = 0, - New, - PendingNew, - Accepted, - AcceptedForBidding, - PartiallyFilled, - Filled, - DoneForDay, - Canceled, - PendingCancel, - Expired, - Replaced, - PendingReplace, - Rejected, - Suspended, - Stopped, - Calculated, - Held, -} - -public static class OrderStatusParser -{ - public static OrderStatus Parse(string? s) => s switch - { - "new" => OrderStatus.New, - "pending_new" => OrderStatus.PendingNew, - "accepted" => OrderStatus.Accepted, - "accepted_for_bidding" => OrderStatus.AcceptedForBidding, - "partially_filled" => OrderStatus.PartiallyFilled, - "filled" => OrderStatus.Filled, - "done_for_day" => OrderStatus.DoneForDay, - "canceled" => OrderStatus.Canceled, - "pending_cancel" => OrderStatus.PendingCancel, - "expired" => OrderStatus.Expired, - "replaced" => OrderStatus.Replaced, - "pending_replace" => OrderStatus.PendingReplace, - "rejected" => OrderStatus.Rejected, - "suspended" => OrderStatus.Suspended, - "stopped" => OrderStatus.Stopped, - "calculated" => OrderStatus.Calculated, - "held" => OrderStatus.Held, - _ => OrderStatus.Unknown, - }; - - /// True once the order can no longer change state. - public static bool IsTerminal(this OrderStatus s) => - s is OrderStatus.Filled or OrderStatus.Canceled or OrderStatus.Expired - or OrderStatus.Rejected or OrderStatus.Replaced or OrderStatus.DoneForDay; - - public static bool IsWorking(this OrderStatus s) => - s is OrderStatus.New or OrderStatus.PendingNew or OrderStatus.Accepted - or OrderStatus.AcceptedForBidding or OrderStatus.PartiallyFilled - or OrderStatus.PendingCancel or OrderStatus.PendingReplace or OrderStatus.Held; -} - -public sealed record AlpacaAccount( - string Id, - string AccountNumber, - string Status, - string Currency, - decimal Cash, - decimal Equity, - decimal LastEquity, - decimal BuyingPower, - decimal DaytradingBuyingPower, - decimal PortfolioValue, - decimal Multiplier, - int DaytradeCount, - bool PatternDayTrader, - bool TradingBlocked, - bool AccountBlocked, - bool TransfersBlocked, - bool TradeSuspendedByUser, - bool ShortingEnabled) -{ - /// True when the broker will refuse new orders for any reason. - public bool CanTrade => !TradingBlocked && !AccountBlocked && !TradeSuspendedByUser && - string.Equals(Status, "ACTIVE", StringComparison.OrdinalIgnoreCase); - - public static AlpacaAccount FromJson(JsonElement e) => new( - e.StringOrEmpty("id"), - e.StringOrEmpty("account_number"), - e.StringOrEmpty("status"), - e.StringOrEmpty("currency"), - e.Decimal("cash"), - e.Decimal("equity"), - e.Decimal("last_equity"), - e.Decimal("buying_power"), - e.Decimal("daytrading_buying_power"), - e.Decimal("portfolio_value"), - e.Decimal("multiplier", 1), - e.Int32("daytrade_count"), - e.Bool("pattern_day_trader"), - e.Bool("trading_blocked"), - e.Bool("account_blocked"), - e.Bool("transfers_blocked"), - e.Bool("trade_suspended_by_user"), - e.Bool("shorting_enabled")); -} - -public sealed record AlpacaPosition( - string Symbol, - string AssetClass, - double Quantity, - double AverageEntryPrice, - double CurrentPrice, - double MarketValue, - double UnrealizedPnl, - double UnrealizedPnlPct) -{ - public static AlpacaPosition FromJson(JsonElement e) - { - double qty = e.Double("qty"); - - // Alpaca reports short positions with a negative qty already, but be explicit. - if (string.Equals(e.StringOrNull("side"), "short", StringComparison.OrdinalIgnoreCase) && qty > 0) - { - qty = -qty; - } - - return new AlpacaPosition( - e.StringOrEmpty("symbol"), - e.StringOrEmpty("asset_class"), - qty, - e.Double("avg_entry_price"), - e.Double("current_price"), - e.Double("market_value"), - e.Double("unrealized_pl"), - e.Double("unrealized_plpc")); - } -} - -public sealed record AlpacaOrder( - string Id, - string ClientOrderId, - string Symbol, - Side Side, - string Type, - string OrderClass, - OrderStatus Status, - double Quantity, - double FilledQuantity, - double FilledAveragePrice, - double LimitPrice, - double StopPrice, - DateTime SubmittedAtUtc, - DateTime? FilledAtUtc, - IReadOnlyList Legs) -{ - private static readonly AlpacaOrder[] NoLegs = []; - - public bool IsWorking => Status.IsWorking(); - - public static AlpacaOrder FromJson(JsonElement e) - { - AlpacaOrder[] legs = NoLegs; - if (e.TryGetProperty("legs", out JsonElement legsElement) && legsElement.ValueKind == JsonValueKind.Array) - { - int n = legsElement.GetArrayLength(); - if (n > 0) - { - legs = new AlpacaOrder[n]; - int i = 0; - foreach (JsonElement leg in legsElement.EnumerateArray()) - { - legs[i++] = FromJson(leg); - } - } - } - - return new AlpacaOrder( - e.StringOrEmpty("id"), - e.StringOrEmpty("client_order_id"), - e.StringOrEmpty("symbol"), - string.Equals(e.StringOrNull("side"), "sell", StringComparison.OrdinalIgnoreCase) ? Side.Sell : Side.Buy, - e.StringOrEmpty("type"), - e.StringOrEmpty("order_class"), - OrderStatusParser.Parse(e.StringOrNull("status")), - e.Double("qty"), - e.Double("filled_qty"), - e.Double("filled_avg_price"), - e.Double("limit_price", double.NaN), - e.Double("stop_price", double.NaN), - e.Timestamp("submitted_at"), - e.TimestampOrNull("filled_at"), - legs); - } -} - -public sealed record AlpacaClock( - DateTime TimestampUtc, - bool IsOpen, - DateTime NextOpenUtc, - DateTime NextCloseUtc) -{ - public static AlpacaClock FromJson(JsonElement e) => new( - e.Timestamp("timestamp"), - e.Bool("is_open"), - e.Timestamp("next_open"), - e.Timestamp("next_close")); -} - -public sealed record AlpacaAsset( - string Symbol, - string Name, - string Exchange, - string Class, - string Status, - bool Tradable, - bool Marginable, - bool Shortable, - bool EasyToBorrow, - bool Fractionable, - double MinOrderSize, - double MinTradeIncrement, - double PriceIncrement) -{ - public bool IsActive => Tradable && string.Equals(Status, "active", StringComparison.OrdinalIgnoreCase); - - public static AlpacaAsset FromJson(JsonElement e) => new( - e.StringOrEmpty("symbol"), - e.StringOrEmpty("name"), - e.StringOrEmpty("exchange"), - e.StringOrEmpty("class"), - e.StringOrEmpty("status"), - e.Bool("tradable"), - e.Bool("marginable"), - e.Bool("shortable"), - e.Bool("easy_to_borrow"), - e.Bool("fractionable"), - e.Double("min_order_size"), - e.Double("min_trade_increment"), - e.Double("price_increment")); -} - -/// -/// Account equity over time, straight from Alpaca. is the -/// equity at the start of the requested window, so lifetime P&L is -/// Equity[^1] - BaseValue — with the usual caveat that deposits and withdrawals -/// move equity without being profit. -/// -public sealed record AlpacaPortfolioHistory( - IReadOnlyList TimestampsUnix, - IReadOnlyList Equity, - IReadOnlyList ProfitLoss, - double BaseValue, - string Timeframe) -{ - public static readonly AlpacaPortfolioHistory Empty = - new([], [], [], 0, string.Empty); - - public bool HasData => Equity.Count > 0; - - public double LastEquity => Equity.Count > 0 ? Equity[^1] : 0; - - /// Change over the whole window in absolute terms. - public double TotalProfitLoss => HasData && BaseValue > 0 ? LastEquity - BaseValue : 0; - - public double TotalProfitLossPct => BaseValue > 0 ? TotalProfitLoss / BaseValue : 0; - - public static AlpacaPortfolioHistory FromJson(JsonElement e) - { - return new AlpacaPortfolioHistory( - ReadLongs(e, "timestamp"), - ReadDoubles(e, "equity"), - ReadDoubles(e, "profit_loss"), - e.Double("base_value"), - e.StringOrEmpty("timeframe")); - - static double[] ReadDoubles(JsonElement root, string name) - { - if (!root.TryGetProperty(name, out JsonElement array) || array.ValueKind != JsonValueKind.Array) - { - return []; - } - - double[] values = new double[array.GetArrayLength()]; - int i = 0; - foreach (JsonElement item in array.EnumerateArray()) - { - values[i++] = item.ValueKind == JsonValueKind.Number ? item.GetDouble() : 0; - } - - return values; - } - - static long[] ReadLongs(JsonElement root, string name) - { - if (!root.TryGetProperty(name, out JsonElement array) || array.ValueKind != JsonValueKind.Array) - { - return []; - } - - long[] values = new long[array.GetArrayLength()]; - int i = 0; - foreach (JsonElement item in array.EnumerateArray()) - { - values[i++] = item.ValueKind == JsonValueKind.Number ? item.GetInt64() : 0; - } - - return values; - } - } -} - -/// An order about to be submitted. Built by the execution router, never by strategies. -public sealed record NewOrder -{ - public required string Symbol { get; init; } - - public required Side Side { get; init; } - - public required double Quantity { get; init; } - - public OrderType Type { get; init; } = OrderType.Market; - - public TimeInForce TimeInForce { get; init; } = TimeInForce.Day; - - public double LimitPrice { get; init; } = double.NaN; - - public double StopPrice { get; init; } = double.NaN; - - /// Idempotency key. Alpaca rejects duplicates, which is exactly what we want on a retry. - public string? ClientOrderId { get; init; } - - public bool ExtendedHours { get; init; } - - /// Attached protective stop. Turns the order into a bracket/OTO order. - public double TakeProfitLimitPrice { get; init; } = double.NaN; - - public double StopLossStopPrice { get; init; } = double.NaN; - - public double StopLossLimitPrice { get; init; } = double.NaN; - - public bool HasBracket => !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice); - - /// Alpaca's order_class implied by the attached legs. - public string OrderClass => - !double.IsNaN(TakeProfitLimitPrice) && !double.IsNaN(StopLossStopPrice) ? "bracket" - : !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice) ? "oto" - : "simple"; -} diff --git a/Encelado/src/Encelado.Alpaca/Rest/AlpacaTradingClient.cs b/Encelado/src/Encelado.Alpaca/Rest/AlpacaTradingClient.cs deleted file mode 100644 index 341f54c..0000000 --- a/Encelado/src/Encelado.Alpaca/Rest/AlpacaTradingClient.cs +++ /dev/null @@ -1,307 +0,0 @@ -using System.Buffers; -using System.Globalization; -using System.Text.Json; -using Encelado.Core.Market; - -namespace Encelado.Alpaca.Rest; - -/// -/// Typed wrapper over Alpaca's trading REST API (/v2/account, /v2/orders, -/// /v2/positions, …). Request bodies are written straight to UTF-8 with -/// — no serializer, no reflection, no per-order allocation -/// beyond a pooled buffer. -/// -public sealed class AlpacaTradingClient(AlpacaOptions options) : IDisposable -{ - private readonly AlpacaHttp _http = new(options.Validate(), options.TradingBaseUrl); - - public string BaseUrl { get; } = options.TradingBaseUrl; - - public bool IsPaper { get; } = options.Paper; - - /// Opens the TLS/HTTP2 connection before the session starts. - public Task WarmupAsync(CancellationToken ct) => _http.WarmupAsync("v2/clock", ct); - - public async Task GetAccountAsync(CancellationToken ct) - { - using JsonDocument doc = await _http.GetAsync("v2/account", ct).ConfigureAwait(false); - return AlpacaAccount.FromJson(doc.RootElement); - } - - public async Task GetClockAsync(CancellationToken ct) - { - using JsonDocument doc = await _http.GetAsync("v2/clock", ct).ConfigureAwait(false); - return AlpacaClock.FromJson(doc.RootElement); - } - - /// - /// Equity curve for the account. uses Alpaca's notation - /// (1D, 1M, 1A, all) and the - /// bucket size (1Min, 15Min, 1H, 1D). - /// - public async Task GetPortfolioHistoryAsync( - string period, - string timeframe, - CancellationToken ct) - { - string path = $"v2/account/portfolio/history?period={Uri.EscapeDataString(period)}" + - $"&timeframe={Uri.EscapeDataString(timeframe)}&intraday_reporting=continuous"; - - try - { - using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false); - return AlpacaPortfolioHistory.FromJson(doc.RootElement); - } - catch (AlpacaApiException) - { - // History is a nice-to-have for the dashboard, never a reason to stop trading. - return AlpacaPortfolioHistory.Empty; - } - } - - public async Task GetAssetAsync(string symbol, CancellationToken ct) - { - using JsonDocument? doc = await _http.GetOrNullAsync($"v2/assets/{Uri.EscapeDataString(symbol)}", ct) - .ConfigureAwait(false); - return doc is null ? null : AlpacaAsset.FromJson(doc.RootElement); - } - - public async Task> ListPositionsAsync(CancellationToken ct) - { - using JsonDocument doc = await _http.GetAsync("v2/positions", ct).ConfigureAwait(false); - List positions = []; - if (doc.RootElement.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement e in doc.RootElement.EnumerateArray()) - { - positions.Add(AlpacaPosition.FromJson(e)); - } - } - - return positions; - } - - public async Task GetPositionAsync(string symbol, CancellationToken ct) - { - using JsonDocument? doc = await _http.GetOrNullAsync($"v2/positions/{Uri.EscapeDataString(symbol)}", ct) - .ConfigureAwait(false); - return doc is null ? null : AlpacaPosition.FromJson(doc.RootElement); - } - - /// Liquidates a position at market. Alpaca cancels the open legs for us. - public async Task ClosePositionAsync(string symbol, double? quantity, CancellationToken ct) - { - string path = $"v2/positions/{Uri.EscapeDataString(symbol)}"; - if (quantity is > 0) - { - path += $"?qty={FormatQuantity(quantity.Value)}"; - } - - try - { - using JsonDocument doc = await _http.DeleteAsync(path, ct).ConfigureAwait(false); - return doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("id", out _) - ? AlpacaOrder.FromJson(doc.RootElement) - : null; - } - catch (AlpacaApiException ex) when (ex.StatusCode == 404) - { - // Already flat: treat as success so the caller's exit path is idempotent. - return null; - } - } - - public async Task CloseAllPositionsAsync(bool cancelOrders, CancellationToken ct) - { - using JsonDocument _ = await _http - .DeleteAsync($"v2/positions?cancel_orders={(cancelOrders ? "true" : "false")}", ct) - .ConfigureAwait(false); - } - - public async Task> ListOrdersAsync( - string status, - int limit, - string? symbols, - CancellationToken ct) - { - string path = $"v2/orders?status={status}&limit={limit}&nested=true"; - if (!string.IsNullOrWhiteSpace(symbols)) - { - path += $"&symbols={Uri.EscapeDataString(symbols)}"; - } - - using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false); - List orders = []; - if (doc.RootElement.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement e in doc.RootElement.EnumerateArray()) - { - orders.Add(AlpacaOrder.FromJson(e)); - } - } - - return orders; - } - - public Task> ListOpenOrdersAsync(CancellationToken ct) => - ListOrdersAsync("open", 500, null, ct); - - public async Task SubmitOrderAsync(NewOrder order, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(order); - - byte[] body = WriteOrderJson(order); - using JsonDocument doc = await _http.PostAsync("v2/orders", body, ct).ConfigureAwait(false); - return AlpacaOrder.FromJson(doc.RootElement); - } - - /// Moves an open order's stop/limit — used to trail protective stops. - public async Task ReplaceOrderAsync( - string orderId, - double? quantity, - double? limitPrice, - double? stopPrice, - string? clientOrderId, - CancellationToken ct) - { - ArrayBufferWriter buffer = new(192); - using (Utf8JsonWriter w = new(buffer)) - { - w.WriteStartObject(); - if (quantity is > 0) - { - w.WriteString("qty", FormatQuantity(quantity.Value)); - } - - if (limitPrice is > 0) - { - w.WriteString("limit_price", FormatPrice(limitPrice.Value)); - } - - if (stopPrice is > 0) - { - w.WriteString("stop_price", FormatPrice(stopPrice.Value)); - } - - if (!string.IsNullOrEmpty(clientOrderId)) - { - w.WriteString("client_order_id", clientOrderId); - } - - w.WriteEndObject(); - } - - using JsonDocument doc = await _http - .PatchAsync($"v2/orders/{Uri.EscapeDataString(orderId)}", buffer.WrittenMemory, ct) - .ConfigureAwait(false); - return AlpacaOrder.FromJson(doc.RootElement); - } - - public async Task CancelOrderAsync(string orderId, CancellationToken ct) - { - try - { - using JsonDocument _ = await _http - .DeleteAsync($"v2/orders/{Uri.EscapeDataString(orderId)}", ct) - .ConfigureAwait(false); - return true; - } - catch (AlpacaApiException ex) when (ex.StatusCode is 404 or 422) - { - // 404 = gone, 422 = already in a terminal state. Both mean "not working any more". - return false; - } - } - - public async Task CancelAllOrdersAsync(CancellationToken ct) - { - using JsonDocument _ = await _http.DeleteAsync("v2/orders", ct).ConfigureAwait(false); - } - - /// Serialises an order to Alpaca's wire format. Public so it can be asserted on in tests. - public static byte[] WriteOrderJson(NewOrder order) - { - ArrayBufferWriter buffer = new(384); - using (Utf8JsonWriter w = new(buffer)) - { - w.WriteStartObject(); - w.WriteString("symbol", order.Symbol); - w.WriteString("qty", FormatQuantity(order.Quantity)); - w.WriteString("side", order.Side.ToAlpaca()); - w.WriteString("type", order.Type.ToAlpaca()); - w.WriteString("time_in_force", order.TimeInForce.ToAlpaca()); - - if (order.Type is OrderType.Limit or OrderType.StopLimit && !double.IsNaN(order.LimitPrice)) - { - w.WriteString("limit_price", FormatPrice(order.LimitPrice)); - } - - if (order.Type is OrderType.Stop or OrderType.StopLimit && !double.IsNaN(order.StopPrice)) - { - w.WriteString("stop_price", FormatPrice(order.StopPrice)); - } - - if (!string.IsNullOrEmpty(order.ClientOrderId)) - { - w.WriteString("client_order_id", order.ClientOrderId); - } - - if (order.ExtendedHours) - { - w.WriteBoolean("extended_hours", true); - } - - if (order.HasBracket) - { - w.WriteString("order_class", order.OrderClass); - - if (!double.IsNaN(order.TakeProfitLimitPrice)) - { - w.WriteStartObject("take_profit"); - w.WriteString("limit_price", FormatPrice(order.TakeProfitLimitPrice)); - w.WriteEndObject(); - } - - if (!double.IsNaN(order.StopLossStopPrice)) - { - w.WriteStartObject("stop_loss"); - w.WriteString("stop_price", FormatPrice(order.StopLossStopPrice)); - if (!double.IsNaN(order.StopLossLimitPrice)) - { - w.WriteString("limit_price", FormatPrice(order.StopLossLimitPrice)); - } - - w.WriteEndObject(); - } - } - - w.WriteEndObject(); - } - - return buffer.WrittenSpan.ToArray(); - } - - /// - /// Alpaca rejects prices that are not a valid sub-penny increment: two decimals at - /// or above $1, four decimals below it. - /// - public static string FormatPrice(double price) - { - double rounded = price >= 1.0 - ? Math.Round(price, 2, MidpointRounding.AwayFromZero) - : Math.Round(price, 4, MidpointRounding.AwayFromZero); - - return rounded.ToString(price >= 1.0 ? "0.##" : "0.####", CultureInfo.InvariantCulture); - } - - /// Whole shares stay integral; fractional sizes get at most 9 decimals. - public static string FormatQuantity(double quantity) - { - double abs = Math.Abs(quantity); - return abs == Math.Floor(abs) - ? abs.ToString("0", CultureInfo.InvariantCulture) - : Math.Round(abs, 9, MidpointRounding.ToZero).ToString("0.#########", CultureInfo.InvariantCulture); - } - - public void Dispose() => _http.Dispose(); -} diff --git a/Encelado/src/Encelado.Alpaca/Streaming/MarketDataStream.cs b/Encelado/src/Encelado.Alpaca/Streaming/MarketDataStream.cs deleted file mode 100644 index 926ad28..0000000 --- a/Encelado/src/Encelado.Alpaca/Streaming/MarketDataStream.cs +++ /dev/null @@ -1,486 +0,0 @@ -using System.Buffers; -using System.Globalization; -using System.Text.Json; -using Encelado.Alpaca.Internal; -using Encelado.Core.Market; - -namespace Encelado.Alpaca.Streaming; - -/// Which side crossed the spread on a print. Unknown when the feed omits it. -public enum Aggressor : byte -{ - Unknown = 0, - Buy = 1, - Sell = 2, -} - -public delegate void TickHandler(int symbolId, string symbol, in Tick tick, Aggressor aggressor); - -public delegate void QuoteHandler(int symbolId, string symbol, in Quote quote); - -public delegate void BarHandler(int symbolId, string symbol, in Bar bar); - -/// -/// Alpaca's real-time market data socket. Frames are decoded straight out of the -/// receive buffer with and symbols are resolved through -/// a , so a live tape produces no garbage per tick. -/// -public sealed class MarketDataStream : WebSocketChannel -{ - private enum MsgKind : byte - { - Unknown = 0, - Trade, - Quote, - Bar, - UpdatedBar, - DailyBar, - Status, - Success, - Error, - Subscription, - } - - private readonly byte[] _authPayload; - private readonly byte[] _subscribePayload; - private readonly SymbolTable _symbols; - private CancellationToken _channelToken; - - public MarketDataStream( - AlpacaOptions options, - IReadOnlyList symbols, - AssetClass assetClass, - bool subscribeTrades = true, - bool subscribeQuotes = true, - bool subscribeBars = true) - : base(options.MarketDataStreamUri(assetClass), $"data:{(assetClass == AssetClass.Crypto ? "crypto" : options.DataFeed)}") - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(symbols); - - _symbols = new SymbolTable(symbols); - AssetClass = assetClass; - _authPayload = BuildAuth(options.KeyId, options.SecretKey); - _subscribePayload = BuildSubscribe(symbols, subscribeTrades, subscribeQuotes, subscribeBars); - } - - public AssetClass AssetClass { get; } - - public SymbolTable Symbols => _symbols; - - /// Fired for every print on the tape. - public TickHandler? OnTrade { get; set; } - - /// Fired on every top-of-book change. - public QuoteHandler? OnQuote { get; set; } - - /// Fired when a minute bar closes — the engine's main decision trigger. - public BarHandler? OnBar { get; set; } - - /// Fired for Alpaca's rolling daily bar. - public BarHandler? OnDailyBar { get; set; } - - public long TradesReceived { get; private set; } - - public long QuotesReceived { get; private set; } - - public long BarsReceived { get; private set; } - - protected override async ValueTask OnOpenAsync(CancellationToken ct) - { - _channelToken = ct; - - // Alpaca accepts the auth frame immediately; the "connected" greeting and the - // "authenticated" acknowledgement both arrive on the receive loop. - await SendAsync(_authPayload, ct).ConfigureAwait(false); - } - - protected override void OnMessage(ReadOnlySpan payload, bool isText) - { - if (!isText) - { - Log($"[{Name}] ignoring a binary frame ({payload.Length} bytes); expected JSON."); - return; - } - - Utf8JsonReader reader = new(payload, isFinalBlock: true, state: default); - if (!reader.Read()) - { - return; - } - - if (reader.TokenType == JsonTokenType.StartArray) - { - while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) - { - if (reader.TokenType == JsonTokenType.StartObject) - { - DecodeObject(ref reader); - } - else - { - reader.Skip(); - } - } - } - else if (reader.TokenType == JsonTokenType.StartObject) - { - DecodeObject(ref reader); - } - } - - private void DecodeObject(ref Utf8JsonReader r) - { - MsgKind kind = MsgKind.Unknown; - int symbolId = -1; - double open = 0, high = 0, low = 0, close = 0, volume = 0, vwap = 0; - double price = 0, size = 0, bidPrice = 0, bidSize = 0, askPrice = 0, askSize = 0; - int tradeCount = 0; - DateTime timestamp = default; - string? message = null; - int code = 0; - Aggressor aggressor = Aggressor.Unknown; - - while (r.Read() && r.TokenType != JsonTokenType.EndObject) - { - if (r.TokenType != JsonTokenType.PropertyName) - { - r.Skip(); - continue; - } - - if (r.ValueTextEquals("T"u8)) - { - r.Read(); - kind = ParseKind(r.ValueSpan); - } - else if (r.ValueTextEquals("S"u8)) - { - r.Read(); - symbolId = _symbols.Resolve(r.ValueSpan); - } - else if (r.ValueTextEquals("t"u8)) - { - r.Read(); - timestamp = Rfc3339.ParseUtc(r.ValueSpan); - } - else if (r.ValueTextEquals("p"u8)) - { - r.Read(); - price = ReadNumber(ref r); - } - else if (r.ValueTextEquals("s"u8)) - { - r.Read(); - size = ReadNumber(ref r); - } - else if (r.ValueTextEquals("bp"u8)) - { - r.Read(); - bidPrice = ReadNumber(ref r); - } - else if (r.ValueTextEquals("bs"u8)) - { - r.Read(); - bidSize = ReadNumber(ref r); - } - else if (r.ValueTextEquals("ap"u8)) - { - r.Read(); - askPrice = ReadNumber(ref r); - } - else if (r.ValueTextEquals("as"u8)) - { - r.Read(); - askSize = ReadNumber(ref r); - } - else if (r.ValueTextEquals("o"u8)) - { - r.Read(); - open = ReadNumber(ref r); - } - else if (r.ValueTextEquals("h"u8)) - { - r.Read(); - high = ReadNumber(ref r); - } - else if (r.ValueTextEquals("l"u8)) - { - r.Read(); - low = ReadNumber(ref r); - } - else if (r.ValueTextEquals("c"u8)) - { - r.Read(); - - // On a bar "c" is the close; on a trade/quote it is the condition array. - if (r.TokenType == JsonTokenType.Number) - { - close = r.GetDouble(); - } - else - { - r.Skip(); - } - } - else if (r.ValueTextEquals("v"u8)) - { - r.Read(); - volume = ReadNumber(ref r); - } - else if (r.ValueTextEquals("vw"u8)) - { - r.Read(); - vwap = ReadNumber(ref r); - } - else if (r.ValueTextEquals("n"u8)) - { - r.Read(); - tradeCount = (int)ReadNumber(ref r); - } - else if (r.ValueTextEquals("tks"u8)) - { - r.Read(); - - // Alpaca's crypto feed reports the taker side as "B" or "S". It is the - // only way to know whether a print lifted an offer or hit a bid, which - // is what the volume delta is built from. - if (r.TokenType == JsonTokenType.String && r.ValueSpan.Length > 0) - { - aggressor = r.ValueSpan[0] switch - { - (byte)'B' or (byte)'b' => Aggressor.Buy, - (byte)'S' or (byte)'s' => Aggressor.Sell, - _ => Aggressor.Unknown, - }; - } - } - else if (r.ValueTextEquals("msg"u8)) - { - r.Read(); - message = r.TokenType == JsonTokenType.String ? r.GetString() : null; - } - else if (r.ValueTextEquals("code"u8)) - { - r.Read(); - code = (int)ReadNumber(ref r); - } - else - { - r.Read(); - r.Skip(); - } - } - - Dispatch(kind, symbolId, timestamp, message, code, - open, high, low, close, volume, vwap, tradeCount, - price, size, bidPrice, bidSize, askPrice, askSize, aggressor); - } - - private void Dispatch( - MsgKind kind, int symbolId, DateTime timestamp, string? message, int code, - double open, double high, double low, double close, double volume, double vwap, int tradeCount, - double price, double size, double bidPrice, double bidSize, double askPrice, double askSize, - Aggressor aggressor) - { - switch (kind) - { - case MsgKind.Trade when symbolId >= 0: - { - TradesReceived++; - Tick tick = new(timestamp, price, size); - OnTrade?.Invoke(symbolId, _symbols.Name(symbolId), in tick, aggressor); - break; - } - - case MsgKind.Quote when symbolId >= 0: - { - QuotesReceived++; - Quote quote = new(timestamp, bidPrice, bidSize, askPrice, askSize); - OnQuote?.Invoke(symbolId, _symbols.Name(symbolId), in quote); - break; - } - - case MsgKind.Bar when symbolId >= 0: - { - BarsReceived++; - Bar bar = new(timestamp, open, high, low, close, volume, vwap, tradeCount); - OnBar?.Invoke(symbolId, _symbols.Name(symbolId), in bar); - break; - } - - case MsgKind.DailyBar when symbolId >= 0: - { - Bar bar = new(timestamp, open, high, low, close, volume, vwap, tradeCount); - OnDailyBar?.Invoke(symbolId, _symbols.Name(symbolId), in bar); - break; - } - - case MsgKind.Success: - if (string.Equals(message, "authenticated", StringComparison.Ordinal)) - { - Log($"[{Name}] authenticated; subscribing to {_symbols.Count} symbol(s)"); - _ = SendSubscribeAsync(); - } - else - { - Log($"[{Name}] {message}"); - } - - break; - - case MsgKind.Subscription: - SetState(ChannelState.Live); - Log($"[{Name}] subscription confirmed"); - break; - - case MsgKind.Error: - OnServerError(code, message); - break; - - case MsgKind.Status: - case MsgKind.UpdatedBar: - case MsgKind.Unknown: - default: - break; - } - } - - /// - /// Turns an Alpaca stream error into either a refusal or a note. - /// - /// The distinction is the whole point. Codes in the 400s here mean the server has - /// decided about this session: reconnecting straight away cannot change its mind, - /// and — because an unauthenticated socket keeps the account's single market-data - /// slot busy for ten seconds — trying again quickly is what keeps the refusal true. - /// Treating these as informational is what produced an endless connect / 406 / - /// auth-timeout loop that never recovered on its own. - /// - /// - private void OnServerError(int code, string? message) - { - string? refusal = code switch - { - 406 => "un'altra connessione sta già usando i dati di mercato di questo conto " + - "(Alpaca ne consente una sola). Chiudi l'altra istanza di Encelado, oppure " + - "attendi: una sessione interrotta male viene liberata dal server dopo poco.", - 401 or 403 => "credenziali rifiutate dallo stream dati. Controlla le chiavi in " + - "Impostazioni e che siano quelle dell'ambiente giusto (paper o live).", - 409 => "abbonamento dati insufficiente per i simboli richiesti.", - _ => null, - }; - - if (refusal is null) - { - Log($"[{Name}] server error {code}: {message}"); - return; - } - - Log($"[{Name}] {code}: {refusal}"); - Reject(refusal); - } - - private async Task SendSubscribeAsync() - { - try - { - await SendAsync(_subscribePayload, _channelToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Log($"[{Name}] subscribe failed: {ex.Message}", ex); - } - } - - private static double ReadNumber(ref Utf8JsonReader r) => r.TokenType switch - { - JsonTokenType.Number => r.GetDouble(), - JsonTokenType.String => double.TryParse( - r.ValueSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out double d) ? d : 0, - _ => 0, - }; - - private static MsgKind ParseKind(ReadOnlySpan value) - { - if (value.Length == 1) - { - return value[0] switch - { - (byte)'t' => MsgKind.Trade, - (byte)'q' => MsgKind.Quote, - (byte)'b' => MsgKind.Bar, - (byte)'u' => MsgKind.UpdatedBar, - (byte)'d' => MsgKind.DailyBar, - (byte)'s' => MsgKind.Status, - _ => MsgKind.Unknown, - }; - } - - if (value.SequenceEqual("success"u8)) - { - return MsgKind.Success; - } - - if (value.SequenceEqual("error"u8)) - { - return MsgKind.Error; - } - - return value.SequenceEqual("subscription"u8) ? MsgKind.Subscription : MsgKind.Unknown; - } - - private static byte[] BuildAuth(string key, string secret) - { - ArrayBufferWriter buffer = new(160); - using (Utf8JsonWriter w = new(buffer)) - { - w.WriteStartObject(); - w.WriteString("action", "auth"); - w.WriteString("key", key); - w.WriteString("secret", secret); - w.WriteEndObject(); - } - - return buffer.WrittenSpan.ToArray(); - } - - private static byte[] BuildSubscribe(IReadOnlyList symbols, bool trades, bool quotes, bool bars) - { - ArrayBufferWriter buffer = new(256); - using (Utf8JsonWriter w = new(buffer)) - { - w.WriteStartObject(); - w.WriteString("action", "subscribe"); - - if (trades) - { - WriteArray(w, "trades", symbols); - } - - if (quotes) - { - WriteArray(w, "quotes", symbols); - } - - if (bars) - { - WriteArray(w, "bars", symbols); - } - - w.WriteEndObject(); - } - - return buffer.WrittenSpan.ToArray(); - - static void WriteArray(Utf8JsonWriter w, string name, IReadOnlyList values) - { - w.WriteStartArray(name); - foreach (string v in values) - { - w.WriteStringValue(v); - } - - w.WriteEndArray(); - } - } -} diff --git a/Encelado/src/Encelado.Alpaca/Streaming/TradeUpdateStream.cs b/Encelado/src/Encelado.Alpaca/Streaming/TradeUpdateStream.cs deleted file mode 100644 index 7f50a6b..0000000 --- a/Encelado/src/Encelado.Alpaca/Streaming/TradeUpdateStream.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System.Buffers; -using System.Text.Json; -using Encelado.Alpaca.Internal; -using Encelado.Alpaca.Rest; -using Encelado.Core.Market; - -namespace Encelado.Alpaca.Streaming; - -/// One order lifecycle event pushed by Alpaca. -public sealed record TradeUpdate( - string Event, - DateTime TimestampUtc, - string Symbol, - Side Side, - double Price, - double Quantity, - double PositionQuantity, - AlpacaOrder Order) -{ - /// True when shares actually changed hands. - public bool IsExecution => Event is "fill" or "partial_fill"; - - public bool IsTerminal => Event is "fill" or "canceled" or "expired" or "rejected" or "done_for_day"; -} - -/// -/// Order and position events straight from the broker, so the bot learns about fills -/// in milliseconds instead of polling. The reconciler still sweeps REST periodically: -/// this stream is the fast path, not the source of truth. -/// -public sealed class TradeUpdateStream : WebSocketChannel -{ - private readonly byte[] _authPrimary; - private readonly byte[] _authAlternate; - private readonly byte[] _listenPayload; - private CancellationToken _channelToken; - private volatile bool _authorized; - private bool _warnedBinary; - - public TradeUpdateStream(AlpacaOptions options) - : base(options.TradeUpdatesStreamUri, "trade-updates") - { - ArgumentNullException.ThrowIfNull(options); - - _authPrimary = BuildEnvelopeAuth(options.KeyId, options.SecretKey); - _authAlternate = BuildFlatAuth(options.KeyId, options.SecretKey); - _listenPayload = BuildListen(); - } - - /// Raised for every order lifecycle event. Runs on the receive thread. - public Action? OnTradeUpdate { get; set; } - - public long UpdatesReceived { get; private set; } - - protected override async ValueTask OnOpenAsync(CancellationToken ct) - { - _channelToken = ct; - _authorized = false; - - // The documented handshake for the trading /stream endpoint. - await SendAsync(_authPrimary, ct).ConfigureAwait(false); - - // Alpaca has shipped two auth shapes for this endpoint over the years. If the - // first one is not acknowledged shortly, try the other before giving up. - _ = FallbackAuthAsync(); - } - - private async Task FallbackAuthAsync() - { - try - { - await Task.Delay(TimeSpan.FromSeconds(3), _channelToken).ConfigureAwait(false); - if (!_authorized) - { - Log($"[{Name}] no auth acknowledgement yet; retrying with the alternate handshake"); - await SendAsync(_authAlternate, _channelToken).ConfigureAwait(false); - } - } - catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException) - { - // Socket closed while we were waiting; the reconnect loop takes over. - } - catch (Exception ex) - { - Log($"[{Name}] fallback auth failed: {ex.Message}", ex); - } - } - - protected override void OnMessage(ReadOnlySpan payload, bool isText) - { - if (!isText) - { - if (!_warnedBinary) - { - _warnedBinary = true; - Log($"[{Name}] received a binary (msgpack) frame; falling back to REST reconciliation for fills."); - } - - return; - } - - JsonDocument doc; - try - { - Utf8JsonReader reader = new(payload, isFinalBlock: true, state: default); - doc = JsonDocument.ParseValue(ref reader); - } - catch (JsonException ex) - { - Log($"[{Name}] undecodable frame: {ex.Message}"); - return; - } - - using (doc) - { - JsonElement root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) - { - return; - } - - string stream = root.StringOrEmpty("stream"); - if (!root.TryGetProperty("data", out JsonElement data)) - { - return; - } - - switch (stream) - { - case "authorization": - HandleAuthorization(data); - break; - - case "listening": - SetState(ChannelState.Live); - Log($"[{Name}] listening for trade updates"); - break; - - case "trade_updates": - HandleTradeUpdate(data); - break; - - default: - break; - } - } - } - - private void HandleAuthorization(JsonElement data) - { - string status = data.StringOrEmpty("status"); - if (string.Equals(status, "authorized", StringComparison.OrdinalIgnoreCase)) - { - _authorized = true; - Log($"[{Name}] authorized"); - _ = SendListenAsync(); - } - else - { - Log($"[{Name}] authorization refused: {status}"); - } - } - - private void HandleTradeUpdate(JsonElement data) - { - UpdatesReceived++; - - AlpacaOrder order = data.TryGetProperty("order", out JsonElement orderElement) && - orderElement.ValueKind == JsonValueKind.Object - ? AlpacaOrder.FromJson(orderElement) - : new AlpacaOrder(string.Empty, string.Empty, string.Empty, Side.Buy, string.Empty, string.Empty, - OrderStatus.Unknown, 0, 0, 0, double.NaN, double.NaN, DateTime.MinValue, null, []); - - DateTime timestamp = data.Timestamp("timestamp"); - TradeUpdate update = new( - data.StringOrEmpty("event"), - timestamp == DateTime.MinValue ? DateTime.UtcNow : timestamp, - order.Symbol, - order.Side, - data.Double("price", order.FilledAveragePrice), - data.Double("qty"), - data.Double("position_qty"), - order); - - try - { - OnTradeUpdate?.Invoke(update); - } - catch (Exception ex) - { - Log($"[{Name}] trade-update handler threw: {ex.Message}", ex); - } - } - - private async Task SendListenAsync() - { - try - { - await SendAsync(_listenPayload, _channelToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - Log($"[{Name}] listen failed: {ex.Message}", ex); - } - } - - /// {"action":"authenticate","data":{"key_id":…,"secret_key":…}} - private static byte[] BuildEnvelopeAuth(string key, string secret) - { - ArrayBufferWriter buffer = new(192); - using (Utf8JsonWriter w = new(buffer)) - { - w.WriteStartObject(); - w.WriteString("action", "authenticate"); - w.WriteStartObject("data"); - w.WriteString("key_id", key); - w.WriteString("secret_key", secret); - w.WriteEndObject(); - w.WriteEndObject(); - } - - return buffer.WrittenSpan.ToArray(); - } - - /// {"action":"auth","key":…,"secret":…} - private static byte[] BuildFlatAuth(string key, string secret) - { - ArrayBufferWriter buffer = new(160); - using (Utf8JsonWriter w = new(buffer)) - { - w.WriteStartObject(); - w.WriteString("action", "auth"); - w.WriteString("key", key); - w.WriteString("secret", secret); - w.WriteEndObject(); - } - - return buffer.WrittenSpan.ToArray(); - } - - private static byte[] BuildListen() - { - ArrayBufferWriter buffer = new(96); - using (Utf8JsonWriter w = new(buffer)) - { - w.WriteStartObject(); - w.WriteString("action", "listen"); - w.WriteStartObject("data"); - w.WriteStartArray("streams"); - w.WriteStringValue("trade_updates"); - w.WriteEndArray(); - w.WriteEndObject(); - w.WriteEndObject(); - } - - return buffer.WrittenSpan.ToArray(); - } -} diff --git a/Encelado/src/Encelado.Binance/BinanceOptions.cs b/Encelado/src/Encelado.Binance/BinanceOptions.cs new file mode 100644 index 0000000..7e2e114 --- /dev/null +++ b/Encelado/src/Encelado.Binance/BinanceOptions.cs @@ -0,0 +1,158 @@ +namespace Encelado.Binance; + +/// +/// Connection settings for the Binance USDⓈ-M futures endpoints the bot talks to. +/// +/// Only the futures venue is modelled. The strategy is delta-neutral — it is short one +/// leg and long the other at all times — and spot cannot express that without borrowing. +/// +/// +public sealed class BinanceOptions +{ + public const string LiveRestBase = "https://fapi.binance.com"; + public const string LiveStreamBase = "wss://fstream.binance.com"; + public const string TestnetRestBase = "https://testnet.binancefuture.com"; + public const string TestnetStreamBase = "wss://fstream.binancefuture.com"; + + public string ApiKey { get; set; } = string.Empty; + + public string ApiSecret { get; set; } = string.Empty; + + /// + /// Testnet is the default, and the analogue of the old paper flag: same API, same + /// order types, fake money. Flipping it to risks real funds. + /// + public bool Testnet { get; set; } = true; + + /// Overrides the REST base URL. Empty derives it from . + public string RestBaseUrlOverride { get; set; } = string.Empty; + + /// Overrides the websocket base URL. + public string StreamBaseUrlOverride { get; set; } = string.Empty; + + /// + /// How stale a signed request may be when it reaches Binance, in milliseconds. + /// Binance rejects anything older; the client also corrects for clock skew, so this + /// only has to cover the round trip. + /// + public int RecvWindowMs { get; set; } = 5_000; + + /// + /// Client-side throttle. Binance futures allows 2400 request-weight per minute per + /// IP; the orders the bot sends are weight 1 and the polls weight 1-5, so a cap on + /// request count with plenty of headroom is enough. + /// + public int RequestsPerMinute { get; set; } = 1_200; + + public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// Retries for transient failures (429 / 418 / 5xx / socket errors). + public int MaxRetries { get; set; } = 3; + + /// + /// Leverage requested on every traded symbol at startup. The guide caps this at 2-3x: + /// a delta-neutral book still liquidates if one leg gaps far enough on its own. + /// + public int Leverage { get; set; } = 2; + + /// + /// CROSSED or ISOLATED. Crossed is what a hedged pair wants: the two + /// legs offset inside one margin pool instead of each carrying its own liquidation + /// price. + /// + public string MarginType { get; set; } = "CROSSED"; + + public string RestBaseUrl => + string.IsNullOrWhiteSpace(RestBaseUrlOverride) + ? (Testnet ? TestnetRestBase : LiveRestBase) + : RestBaseUrlOverride.TrimEnd('/'); + + public string StreamBaseUrl => + string.IsNullOrWhiteSpace(StreamBaseUrlOverride) + ? (Testnet ? TestnetStreamBase : LiveStreamBase) + : StreamBaseUrlOverride.TrimEnd('/'); + + /// The combined-stream endpoint, which multiplexes every subscription onto one socket. + public Uri CombinedStreamUri(string streams) => + new($"{StreamBaseUrl}/stream?streams={streams}"); + + /// The single-stream endpoint used by the user-data (listen key) socket. + public Uri UserStreamUri(string listenKey) => + new($"{StreamBaseUrl}/ws/{listenKey}"); + + public BinanceOptions Validate() + { + if (string.IsNullOrWhiteSpace(ApiKey) || string.IsNullOrWhiteSpace(ApiSecret)) + { + throw new InvalidOperationException( + "Binance credentials are missing. Set BINANCE_API_KEY and BINANCE_API_SECRET " + + "(or binance.apiKey / binance.apiSecret in the config file)."); + } + + // The key travels as an HTTP header and the secret keys an HMAC. A stray + // non-ASCII character — a smart quote from a copy/paste, a BOM, a zero-width + // space — would otherwise surface much later as an opaque transport failure or, + // worse, as a signature that is silently wrong. + RequirePrintableAscii(ApiKey, "apiKey"); + RequirePrintableAscii(ApiSecret, "apiSecret"); + + if (RecvWindowMs is < 1_000 or > 60_000) + { + throw new InvalidOperationException("binance.recvWindowMs must be between 1000 and 60000."); + } + + if (RequestsPerMinute is < 1 or > 2_400) + { + throw new InvalidOperationException("binance.requestsPerMinute must be between 1 and 2400."); + } + + if (Leverage is < 1 or > 20) + { + throw new InvalidOperationException( + "binance.leverage must be between 1 and 20. The strategy is validated at 2-3x; " + + "above that a single leg gapping is enough to liquidate a hedged book."); + } + + if (MarginType.Trim().ToUpperInvariant() is not ("CROSSED" or "ISOLATED")) + { + throw new InvalidOperationException("binance.marginType must be 'CROSSED' or 'ISOLATED'."); + } + + return this; + } + + private static void RequirePrintableAscii(string value, string field) + { + foreach (char c in value) + { + if (c is < ' ' or > '~') + { + throw new InvalidOperationException( + $"binance.{field} contains a character that is not printable ASCII " + + $"(U+{(int)c:X4}). Re-copy it from the Binance API management page — " + + "invisible characters are routinely picked up by copy/paste."); + } + } + } +} + +/// Raised when Binance answers with a non-success status or an unusable payload. +public sealed class BinanceApiException(string message, int statusCode = 0, int errorCode = 0, string? body = null) + : Exception(message) +{ + public int StatusCode { get; } = statusCode; + + /// Binance's own error code, e.g. -2019 for insufficient margin. 0 when absent. + public int ErrorCode { get; } = errorCode; + + public string? Body { get; } = body; + + /// Transient conditions worth retrying. 418 is an IP ban after repeated 429s. + public bool IsTransient => StatusCode is 429 or 418 or >= 500; + + /// + /// The signature was rejected because our clock is off. Worth one immediate retry + /// after resynchronising, which is not the same as a generic transient failure. + /// + public bool IsClockSkew => ErrorCode is -1021 or -1022; +} diff --git a/Encelado/src/Encelado.Alpaca/Encelado.Alpaca.csproj b/Encelado/src/Encelado.Binance/Encelado.Binance.csproj similarity index 64% rename from Encelado/src/Encelado.Alpaca/Encelado.Alpaca.csproj rename to Encelado/src/Encelado.Binance/Encelado.Binance.csproj index 3823770..d131756 100644 --- a/Encelado/src/Encelado.Alpaca/Encelado.Alpaca.csproj +++ b/Encelado/src/Encelado.Binance/Encelado.Binance.csproj @@ -1,8 +1,8 @@ - Encelado.Alpaca - Encelado.Alpaca + Encelado.Binance + Encelado.Binance diff --git a/Encelado/src/Encelado.Binance/Internal/JsonRead.cs b/Encelado/src/Encelado.Binance/Internal/JsonRead.cs new file mode 100644 index 0000000..e5ad8e1 --- /dev/null +++ b/Encelado/src/Encelado.Binance/Internal/JsonRead.cs @@ -0,0 +1,155 @@ +using System.Globalization; +using System.Text.Json; + +namespace Encelado.Binance.Internal; + +/// +/// Reading helpers for Binance's REST and websocket payloads. Binance encodes every +/// price and quantity as a JSON string ("price": "42123.50") but counters +/// and timestamps as numbers, and omits fields liberally, so every accessor tolerates +/// both shapes and a missing property. +/// +public static class JsonRead +{ + public static string? StringOrNull(this JsonElement e, string name) => + e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String + ? v.GetString() + : null; + + public static string StringOrEmpty(this JsonElement e, string name) => + e.StringOrNull(name) ?? string.Empty; + + public static double Double(this JsonElement e, string name, double fallback = 0) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.Number => v.GetDouble(), + JsonValueKind.String => double.TryParse( + v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) ? d : fallback, + _ => fallback, + }; + } + + public static decimal Decimal(this JsonElement e, string name, decimal fallback = 0) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.Number => v.GetDecimal(), + JsonValueKind.String => decimal.TryParse( + v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out decimal d) ? d : fallback, + _ => fallback, + }; + } + + public static int Int32(this JsonElement e, string name, int fallback = 0) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.Number => v.TryGetInt32(out int i) ? i : (int)v.GetDouble(), + JsonValueKind.String => int.TryParse( + v.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int i) ? i : fallback, + _ => fallback, + }; + } + + public static long Int64(this JsonElement e, string name, long fallback = 0) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.Number => v.TryGetInt64(out long i) ? i : (long)v.GetDouble(), + JsonValueKind.String => long.TryParse( + v.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long i) ? i : fallback, + _ => fallback, + }; + } + + public static bool Bool(this JsonElement e, string name, bool fallback = false) + { + if (!e.TryGetProperty(name, out JsonElement v)) + { + return fallback; + } + + return v.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => bool.TryParse(v.GetString(), out bool b) ? b : fallback, + _ => fallback, + }; + } + + /// Binance timestamps are milliseconds since the Unix epoch, always UTC. + public static DateTime Timestamp(this JsonElement e, string name) + { + long ms = e.Int64(name, 0); + return ms <= 0 ? DateTime.MinValue : DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime; + } + + public static DateTime? TimestampOrNull(this JsonElement e, string name) + { + DateTime dt = e.Timestamp(name); + return dt == DateTime.MinValue ? null : dt; + } + + public static DateTime FromUnixMs(long milliseconds) => + milliseconds <= 0 ? DateTime.MinValue : DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime; + + /// Parses a Binance numeric string (never culture dependent). + public static double ParseDouble(string? text) => + double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double d) ? d : 0; + + /// Reads element of a kline array as a double. + public static double ArrayDouble(this JsonElement array, int index) + { + if (array.ValueKind != JsonValueKind.Array || index >= array.GetArrayLength()) + { + return 0; + } + + JsonElement v = array[index]; + return v.ValueKind switch + { + JsonValueKind.Number => v.GetDouble(), + JsonValueKind.String => ParseDouble(v.GetString()), + _ => 0, + }; + } + + public static long ArrayInt64(this JsonElement array, int index) + { + if (array.ValueKind != JsonValueKind.Array || index >= array.GetArrayLength()) + { + return 0; + } + + JsonElement v = array[index]; + return v.ValueKind switch + { + JsonValueKind.Number => v.TryGetInt64(out long i) ? i : (long)v.GetDouble(), + JsonValueKind.String => long.TryParse( + v.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long i) ? i : 0, + _ => 0, + }; + } +} diff --git a/Encelado/src/Encelado.Binance/Rest/BinanceFuturesClient.cs b/Encelado/src/Encelado.Binance/Rest/BinanceFuturesClient.cs new file mode 100644 index 0000000..3534077 --- /dev/null +++ b/Encelado/src/Encelado.Binance/Rest/BinanceFuturesClient.cs @@ -0,0 +1,439 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Encelado.Binance.Internal; +using Encelado.Core.Market; + +namespace Encelado.Binance.Rest; + +/// +/// Everything the bot asks of Binance USDⓈ-M futures over REST: the account, positions, +/// klines, funding, symbol rules, and the orders themselves. +/// +/// Deliberately a thin, explicit surface. Every method builds its own query string and +/// parses its own response — no serializer, no reflection, so the whole path stays +/// trim- and AOT-safe and a change in Binance's payload shows up as a compile-time edit +/// in one place rather than a runtime surprise. +/// +/// +public sealed class BinanceFuturesClient : IDisposable +{ + private readonly BinanceHttp _http; + private readonly Dictionary _filters = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _filterGate = new(); + + public BinanceFuturesClient(BinanceOptions options) + { + ArgumentNullException.ThrowIfNull(options); + Options = options; + _http = new BinanceHttp(options); + } + + public BinanceOptions Options { get; } + + public string BaseUrl => _http.BaseUrl; + + public long ClockOffsetMs => _http.ClockOffsetMs; + + public Task WarmupAsync(CancellationToken ct) => _http.WarmupAsync(ct); + + // ----------------------------------------------------------------------- + // Account + // ----------------------------------------------------------------------- + + public async Task GetAccountAsync(CancellationToken ct) + { + using JsonDocument doc = await _http.GetSignedAsync("fapi/v2/account", string.Empty, ct) + .ConfigureAwait(false); + return FuturesAccount.Parse(doc.RootElement); + } + + public async Task> ListPositionsAsync(CancellationToken ct) + { + using JsonDocument doc = await _http.GetSignedAsync("fapi/v2/positionRisk", string.Empty, ct) + .ConfigureAwait(false); + + List positions = []; + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return positions; + } + + foreach (JsonElement e in doc.RootElement.EnumerateArray()) + { + FuturesPosition p = FuturesPosition.Parse(e); + if (p.IsOpen) + { + positions.Add(p); + } + } + + return positions; + } + + /// + /// Sets the leverage for one symbol. Idempotent, and safe to call at every start: + /// Binance answers with the resulting leverage rather than an error when it is + /// already what was asked for. + /// + public async Task SetLeverageAsync(string symbol, int leverage, CancellationToken ct) + { + string query = string.Create(CultureInfo.InvariantCulture, + $"symbol={symbol}&leverage={leverage}"); + + using JsonDocument _ = await _http.PostSignedAsync("fapi/v1/leverage", query, ct).ConfigureAwait(false); + } + + /// + /// Sets cross or isolated margin for one symbol. + /// + /// Binance rejects this with code -4046 when the symbol is already on the requested + /// mode, which is not a failure — it is the desired state. Swallowed for that code + /// only, so a genuine refusal still surfaces. + /// + /// + public async Task SetMarginTypeAsync(string symbol, string marginType, CancellationToken ct) + { + string query = $"symbol={symbol}&marginType={marginType.ToUpperInvariant()}"; + + try + { + using JsonDocument _ = await _http.PostSignedAsync("fapi/v1/marginType", query, ct) + .ConfigureAwait(false); + } + catch (BinanceApiException ex) when (ex.ErrorCode == -4046) + { + // "No need to change margin type." + } + } + + // ----------------------------------------------------------------------- + // Market data + // ----------------------------------------------------------------------- + + /// + /// Historical klines for one symbol, oldest first. + /// + /// The last kline Binance returns is the one still forming. It is dropped here + /// rather than at every call site: a strategy that decides on a partial close and + /// then decides again when it finishes is a strategy that trades twice on one bar. + /// + /// + public async Task> GetKlinesAsync( + string symbol, + TimeFrame timeFrame, + int limit, + DateTime? startUtc, + CancellationToken ct) + { + StringBuilder query = new(96); + query.Append("symbol=").Append(symbol); + query.Append("&interval=").Append(timeFrame.ToBinance()); + query.Append("&limit=").Append(Math.Clamp(limit, 1, 1500).ToString(CultureInfo.InvariantCulture)); + + if (startUtc is { } start) + { + long ms = new DateTimeOffset(DateTime.SpecifyKind(start, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); + query.Append("&startTime=").Append(ms.ToString(CultureInfo.InvariantCulture)); + } + + using JsonDocument doc = await _http.GetAsync("fapi/v1/klines", query.ToString(), ct) + .ConfigureAwait(false); + + List bars = []; + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return bars; + } + + long bucketMs = timeFrame.Seconds() * 1000L; + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long currentBucketOpen = nowMs - (nowMs % bucketMs); + + foreach (JsonElement k in doc.RootElement.EnumerateArray()) + { + long openMs = k.ArrayInt64(0); + if (openMs >= currentBucketOpen) + { + continue; + } + + bars.Add(ParseKline(k)); + } + + return bars; + } + + /// + /// Decodes one kline array. Binance's layout is positional: + /// [openTime, open, high, low, close, volume, closeTime, quoteVolume, trades, + /// takerBuyBase, takerBuyQuote, ignore]. + /// + /// Index 9 is the reason this venue was worth moving to: it is the volume that + /// crossed the spread upwards, so every bar carries its own aggressor breakdown + /// without having to rebuild it from the tape. + /// + /// + public static Bar ParseKline(JsonElement k) + { + double volume = k.ArrayDouble(5); + double quoteVolume = k.ArrayDouble(7); + + return new Bar( + JsonRead.FromUnixMs(k.ArrayInt64(0)), + k.ArrayDouble(1), + k.ArrayDouble(2), + k.ArrayDouble(3), + k.ArrayDouble(4), + volume, + volume > 0 ? quoteVolume / volume : 0, + (int)k.ArrayInt64(8), + k.ArrayDouble(9)); + } + + /// Mark price and funding for one symbol. + public async Task GetFundingAsync(string symbol, CancellationToken ct) + { + using JsonDocument doc = await _http.GetAsync("fapi/v1/premiumIndex", $"symbol={symbol}", ct) + .ConfigureAwait(false); + + return doc.RootElement.ValueKind == JsonValueKind.Object + ? FundingInfo.Parse(doc.RootElement) + : FundingInfo.None; + } + + /// + /// Loads the exchange's rules for every symbol and caches them for the process + /// lifetime. They change when Binance relists an instrument, which is measured in + /// months, so one fetch at startup is enough. + /// + public async Task LoadSymbolRulesAsync(CancellationToken ct) + { + using JsonDocument doc = await _http.GetAsync("fapi/v1/exchangeInfo", string.Empty, ct) + .ConfigureAwait(false); + + if (!doc.RootElement.TryGetProperty("symbols", out JsonElement symbols) || + symbols.ValueKind != JsonValueKind.Array) + { + return; + } + + lock (_filterGate) + { + foreach (JsonElement s in symbols.EnumerateArray()) + { + SymbolFilters filters = SymbolFilters.Parse(s); + if (filters.Symbol.Length > 0) + { + _filters[filters.Symbol] = filters; + } + } + } + } + + /// + /// The exchange rules for one symbol. Falls back to conservative defaults when + /// has not run or the symbol is unlisted — the + /// order will then be refused by Binance rather than by us, which is the right + /// place for a symbol that does not exist to fail. + /// + public SymbolFilters Filters(string symbol) + { + lock (_filterGate) + { + return _filters.TryGetValue(symbol, out SymbolFilters? f) + ? f + : SymbolFilters.Unknown with { Symbol = symbol }; + } + } + + public bool KnowsSymbol(string symbol) + { + lock (_filterGate) + { + return _filters.ContainsKey(symbol); + } + } + + // ----------------------------------------------------------------------- + // Orders + // ----------------------------------------------------------------------- + + public async Task SubmitOrderAsync(NewFuturesOrder order, CancellationToken ct) + { + SymbolFilters filters = Filters(order.Symbol); + + StringBuilder query = new(192); + query.Append("symbol=").Append(order.Symbol); + query.Append("&side=").Append(order.Side == Side.Buy ? "BUY" : "SELL"); + query.Append("&quantity=").Append(filters.FormatQuantity(order.Quantity)); + + if (order.Type == OrderType.Limit) + { + query.Append("&type=LIMIT"); + query.Append("&price=").Append(filters.FormatPrice(filters.RoundPrice(order.LimitPrice))); + query.Append("&timeInForce=").Append( + string.IsNullOrWhiteSpace(order.TimeInForce) ? "GTC" : order.TimeInForce); + } + else + { + query.Append("&type=MARKET"); + } + + if (order.ReduceOnly) + { + query.Append("&reduceOnly=true"); + } + + if (!string.IsNullOrWhiteSpace(order.ClientOrderId)) + { + query.Append("&newClientOrderId=").Append(order.ClientOrderId); + } + + using JsonDocument doc = await _http.PostSignedAsync("fapi/v1/order", query.ToString(), ct) + .ConfigureAwait(false); + + return BinanceOrder.Parse(doc.RootElement); + } + + public async Task> ListOpenOrdersAsync(string? symbol, CancellationToken ct) + { + string query = string.IsNullOrWhiteSpace(symbol) ? string.Empty : $"symbol={symbol}"; + + using JsonDocument doc = await _http.GetSignedAsync("fapi/v1/openOrders", query, ct) + .ConfigureAwait(false); + + return ParseOrders(doc.RootElement); + } + + /// Recent orders for one symbol, newest last as Binance returns them. + public async Task> ListOrdersAsync(string symbol, int limit, CancellationToken ct) + { + string query = string.Create(CultureInfo.InvariantCulture, + $"symbol={symbol}&limit={Math.Clamp(limit, 1, 1000)}"); + + using JsonDocument doc = await _http.GetSignedAsync("fapi/v1/allOrders", query, ct) + .ConfigureAwait(false); + + return ParseOrders(doc.RootElement); + } + + public async Task CancelOrderAsync(string symbol, long orderId, CancellationToken ct) + { + string query = string.Create(CultureInfo.InvariantCulture, $"symbol={symbol}&orderId={orderId}"); + + try + { + using JsonDocument _ = await _http.DeleteSignedAsync("fapi/v1/order", query, ct) + .ConfigureAwait(false); + } + catch (BinanceApiException ex) when (ex.ErrorCode == -2011) + { + // "Unknown order sent" — it filled or was cancelled between listing and now. + // The desired end state has been reached either way. + } + } + + public async Task CancelAllOrdersAsync(string symbol, CancellationToken ct) + { + try + { + using JsonDocument _ = await _http + .DeleteSignedAsync("fapi/v1/allOpenOrders", $"symbol={symbol}", ct) + .ConfigureAwait(false); + } + catch (BinanceApiException ex) when (ex.ErrorCode == -2011) + { + // Nothing was open. + } + } + + /// + /// Closes one symbol's position at market. + /// + /// There is no "close position" endpoint on futures: closing is a reduce-only market + /// order for the exact size held, in the opposite direction. Reduce-only is what + /// makes it safe against a stale quantity — a size larger than the position shrinks + /// to fit instead of opening the other side. + /// + /// + public async Task ClosePositionAsync(string symbol, CancellationToken ct) + { + List positions = await ListPositionsAsync(ct).ConfigureAwait(false); + + FuturesPosition? held = null; + foreach (FuturesPosition p in positions) + { + if (p.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase)) + { + held = p; + break; + } + } + + if (held is null || !held.IsOpen) + { + return null; + } + + SymbolFilters filters = Filters(symbol); + double quantity = filters.RoundQuantity(Math.Abs(held.Quantity)); + if (quantity <= 0) + { + // Smaller than one lot step: nothing sendable is left, and Binance will not + // accept a residue below the step in either direction. + return null; + } + + return await SubmitOrderAsync( + new NewFuturesOrder + { + Symbol = symbol, + Side = held.Quantity > 0 ? Side.Sell : Side.Buy, + Quantity = quantity, + Type = OrderType.Market, + ReduceOnly = true, + ClientOrderId = null, + TimeInForce = string.Empty, + }, + ct).ConfigureAwait(false); + } + + // ----------------------------------------------------------------------- + // User data stream + // ----------------------------------------------------------------------- + + public async Task CreateListenKeyAsync(CancellationToken ct) + { + using JsonDocument doc = await _http.PostKeyedAsync("fapi/v1/listenKey", ct).ConfigureAwait(false); + return doc.RootElement.StringOrEmpty("listenKey"); + } + + /// + /// Extends the listen key's lifetime. Binance expires it after 60 minutes of + /// silence, and an expired key closes the order stream without an error — which is + /// how a bot ends up not seeing its own fills. + /// + public async Task KeepListenKeyAliveAsync(CancellationToken ct) + { + using JsonDocument _ = await _http.PutKeyedAsync("fapi/v1/listenKey", ct).ConfigureAwait(false); + } + + private static List ParseOrders(JsonElement root) + { + List orders = []; + if (root.ValueKind != JsonValueKind.Array) + { + return orders; + } + + foreach (JsonElement e in root.EnumerateArray()) + { + orders.Add(BinanceOrder.Parse(e)); + } + + return orders; + } + + public void Dispose() => _http.Dispose(); +} diff --git a/Encelado/src/Encelado.Binance/Rest/BinanceHttp.cs b/Encelado/src/Encelado.Binance/Rest/BinanceHttp.cs new file mode 100644 index 0000000..890de46 --- /dev/null +++ b/Encelado/src/Encelado.Binance/Rest/BinanceHttp.cs @@ -0,0 +1,346 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Encelado.Binance.Internal; + +namespace Encelado.Binance.Rest; + +/// +/// Shared HTTP transport for the Binance futures REST API: one pooled, pre-warmed +/// connection, HMAC-SHA256 request signing, clock-skew correction against the exchange +/// clock, a client-side rate limiter and bounded retries. +/// +/// Signing is the whole reason this exists rather than a bare . +/// Binance authenticates by hashing the exact query string with the API secret, +/// so the string that is hashed and the string that is sent must be byte-identical — +/// building the query twice, or letting re-encode it, produces a +/// signature that is silently wrong and a rejection that says nothing useful. +/// +/// +public sealed class BinanceHttp : IDisposable +{ + private readonly HttpClient _http; + private readonly MinuteRateLimiter _limiter; + private readonly byte[] _secret; + private readonly int _maxRetries; + private readonly int _recvWindow; + + /// Exchange clock minus local clock, in milliseconds. Applied to every signed request. + private long _clockOffsetMs; + + public BinanceHttp(BinanceOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + SocketsHttpHandler handler = new() + { + // Long-lived pooled connections: a TLS handshake on the order path is the + // single largest avoidable source of latency, and this strategy sends two + // orders at once and wants them to land together. + PooledConnectionLifetime = TimeSpan.FromMinutes(10), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5), + MaxConnectionsPerServer = 16, + EnableMultipleHttp2Connections = true, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + ConnectTimeout = TimeSpan.FromSeconds(10), + KeepAlivePingDelay = TimeSpan.FromSeconds(30), + KeepAlivePingTimeout = TimeSpan.FromSeconds(10), + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests, + }; + + _http = new HttpClient(handler, disposeHandler: true) + { + BaseAddress = new Uri(options.RestBaseUrl.TrimEnd('/') + "/"), + Timeout = options.HttpTimeout, + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower, + }; + + _http.DefaultRequestHeaders.Add("X-MBX-APIKEY", options.ApiKey); + _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _http.DefaultRequestHeaders.UserAgent.ParseAdd("Encelado/4.0"); + + _secret = Encoding.UTF8.GetBytes(options.ApiSecret); + _limiter = new MinuteRateLimiter(options.RequestsPerMinute); + _maxRetries = Math.Max(0, options.MaxRetries); + _recvWindow = options.RecvWindowMs; + } + + public string BaseUrl => _http.BaseAddress?.ToString().TrimEnd('/') ?? string.Empty; + + /// Exchange clock minus local clock, in milliseconds, as last measured. + public long ClockOffsetMs => Interlocked.Read(ref _clockOffsetMs); + + /// + /// Opens the TLS connection and measures the clock offset before the first real + /// request, so neither the first order nor the first signature pays for it. + /// + public async Task WarmupAsync(CancellationToken ct) + { + try + { + await SyncClockAsync(ct).ConfigureAwait(false); + } + catch (BinanceApiException) + { + // A refusal still means the socket is up, which is all warm-up needs. + } + } + + /// + /// Measures the difference between the exchange clock and ours. + /// + /// A machine that has not synchronised for a while drifts by seconds, and Binance + /// rejects any signed request whose timestamp falls outside the receive window with + /// code -1021. Correcting the offset here turns a class of failures that look like + /// bad credentials into nothing at all. + /// + /// + public async Task SyncClockAsync(CancellationToken ct) + { + long before = Now(); + using JsonDocument doc = await SendAsync(HttpMethod.Get, "fapi/v1/time", null, signed: false, ct) + .ConfigureAwait(false); + long after = Now(); + + long serverMs = doc.RootElement.Int64("serverTime", 0); + if (serverMs <= 0) + { + return; + } + + // Assume the round trip is symmetric and compare against its midpoint, so the + // network latency does not get folded into the offset. + long offset = serverMs - ((before + after) / 2); + Interlocked.Exchange(ref _clockOffsetMs, offset); + } + + // ----------------------------------------------------------------------- + // Public verbs + // ----------------------------------------------------------------------- + + public Task GetAsync(string path, string query, CancellationToken ct) => + SendAsync(HttpMethod.Get, Combine(path, query), null, signed: false, ct); + + public Task GetSignedAsync(string path, string query, CancellationToken ct) => + SendAsync(HttpMethod.Get, path, query, signed: true, ct); + + public Task PostSignedAsync(string path, string query, CancellationToken ct) => + SendAsync(HttpMethod.Post, path, query, signed: true, ct); + + public Task DeleteSignedAsync(string path, string query, CancellationToken ct) => + SendAsync(HttpMethod.Delete, path, query, signed: true, ct); + + /// Key-authenticated but unsigned. Only the listen-key endpoints work this way. + public Task PostKeyedAsync(string path, CancellationToken ct) => + SendAsync(HttpMethod.Post, path, null, signed: false, ct); + + public Task PutKeyedAsync(string path, CancellationToken ct) => + SendAsync(HttpMethod.Put, path, null, signed: false, ct); + + // ----------------------------------------------------------------------- + // Transport + // ----------------------------------------------------------------------- + + private async Task SendAsync( + HttpMethod method, + string path, + string? signedQuery, + bool signed, + CancellationToken ct) + { + BinanceApiException? last = null; + + for (int attempt = 0; attempt <= _maxRetries; attempt++) + { + await _limiter.WaitAsync(ct).ConfigureAwait(false); + + // The signature covers the timestamp, so a retried request has to be signed + // again: replaying the first signature would send a stale timestamp and be + // refused for a reason unrelated to the original failure. + string url = signed ? Sign(path, signedQuery) : path; + + using HttpRequestMessage request = new(method, url); + + HttpResponseMessage? response = null; + try + { + response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.NoContent || + response.Content.Headers.ContentLength == 0) + { + return JsonDocument.Parse("{}"u8.ToArray()); + } + + await using Stream stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + return await JsonDocument.ParseAsync(stream, default, ct).ConfigureAwait(false); + } + + string errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + last = Describe(method, path, (int)response.StatusCode, response.ReasonPhrase, errorBody); + + // A clock that has drifted is fixable, and fixable right now. Resync and + // spend one attempt on it rather than surfacing "invalid signature" to + // an operator who would have no way to act on it. + if (last.IsClockSkew && attempt < _maxRetries) + { + await SyncClockAsync(ct).ConfigureAwait(false); + continue; + } + + if (!last.IsTransient || attempt == _maxRetries) + { + throw last; + } + + await BackoffAsync(attempt, response.Headers.RetryAfter, ct).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (attempt < _maxRetries) + { + last = new BinanceApiException($"{method} {path} -> transport failure: {ex.Message}"); + await BackoffAsync(attempt, null, ct).ConfigureAwait(false); + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < _maxRetries) + { + last = new BinanceApiException( + $"{method} {path} -> timed out after {_http.Timeout.TotalSeconds:F0}s"); + await BackoffAsync(attempt, null, ct).ConfigureAwait(false); + } + finally + { + response?.Dispose(); + } + } + + throw last ?? new BinanceApiException($"{method} {path} failed without a response."); + } + + /// + /// Appends the timestamp and receive window, hashes the resulting query with the + /// API secret, and returns the full path. The signature must cover exactly what is + /// transmitted, so the signed string is built once and concatenated — never re-parsed. + /// + private string Sign(string path, string? query) + { + long timestamp = Now() + ClockOffsetMs; + + StringBuilder payload = new((query?.Length ?? 0) + 64); + if (!string.IsNullOrEmpty(query)) + { + payload.Append(query).Append('&'); + } + + payload.Append("recvWindow=").Append(_recvWindow.ToString(CultureInfo.InvariantCulture)); + payload.Append("×tamp=").Append(timestamp.ToString(CultureInfo.InvariantCulture)); + + string body = payload.ToString(); + + Span hash = stackalloc byte[32]; + HMACSHA256.HashData(_secret, Encoding.UTF8.GetBytes(body), hash); + + return string.Concat(path, "?", body, "&signature=", Convert.ToHexStringLower(hash)); + } + + private static string Combine(string path, string query) => + string.IsNullOrEmpty(query) ? path : string.Concat(path, "?", query); + + private static BinanceApiException Describe( + HttpMethod method, string path, int status, string? reason, string body) + { + int code = 0; + string message = body; + + // Binance answers errors as a small object carrying its own code and message. + // The message is the only part worth showing; the code is what a caller branches on. + try + { + using JsonDocument doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + code = doc.RootElement.Int32("code", 0); + message = doc.RootElement.StringOrNull("msg") ?? body; + } + } + catch (JsonException) + { + // An HTML error page from a proxy. Fall back to the raw body. + } + + return new BinanceApiException( + $"{method} {path} -> {status} {reason}: {Truncate(message)}" + + (code != 0 ? $" [code {code}]" : string.Empty), + status, code, body); + } + + private static async Task BackoffAsync(int attempt, RetryConditionHeaderValue? retryAfter, CancellationToken ct) + { + TimeSpan delay; + if (retryAfter?.Delta is { } delta && delta > TimeSpan.Zero) + { + delay = delta; + } + else + { + double baseMs = 200 * Math.Pow(2, attempt); + delay = TimeSpan.FromMilliseconds(baseMs + Random.Shared.Next(0, 150)); + } + + await Task.Delay(delay > TimeSpan.FromSeconds(30) ? TimeSpan.FromSeconds(30) : delay, ct) + .ConfigureAwait(false); + } + + private static long Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static string Truncate(string s) => s.Length <= 400 ? s : s[..400] + "…"; + + public void Dispose() => _http.Dispose(); +} + +/// +/// Sliding-window limiter: remembers when each of the last N requests went out and +/// blocks until the oldest falls out of the 60 second window. +/// +internal sealed class MinuteRateLimiter(int permitsPerMinute) +{ + private static readonly long WindowTicks = Stopwatch.Frequency * 60; + + private readonly long[] _sentAt = new long[Math.Max(1, permitsPerMinute)]; + private readonly SemaphoreSlim _gate = new(1, 1); + private int _index; + + public async ValueTask WaitAsync(CancellationToken ct) + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + long now = Stopwatch.GetTimestamp(); + long oldest = _sentAt[_index]; + + if (oldest != 0) + { + long elapsed = now - oldest; + if (elapsed < WindowTicks) + { + double waitSeconds = (WindowTicks - elapsed) / (double)Stopwatch.Frequency; + await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct).ConfigureAwait(false); + now = Stopwatch.GetTimestamp(); + } + } + + _sentAt[_index] = now; + _index = _index + 1 == _sentAt.Length ? 0 : _index + 1; + } + finally + { + _gate.Release(); + } + } +} diff --git a/Encelado/src/Encelado.Binance/Rest/BinanceModels.cs b/Encelado/src/Encelado.Binance/Rest/BinanceModels.cs new file mode 100644 index 0000000..788573e --- /dev/null +++ b/Encelado/src/Encelado.Binance/Rest/BinanceModels.cs @@ -0,0 +1,396 @@ +using System.Globalization; +using System.Text.Json; +using Encelado.Binance.Internal; +using Encelado.Core.Market; + +namespace Encelado.Binance.Rest; + +public enum OrderStatus : byte +{ + Unknown = 0, + New, + PartiallyFilled, + Filled, + Canceled, + Rejected, + Expired, +} + +/// +/// The futures wallet, as /fapi/v2/account reports it. +/// +/// On a futures account "equity" is the margin balance — wallet plus unrealised P&L — +/// because that is the number a liquidation is measured against. Wallet balance alone +/// ignores an open position that is currently under water, which is exactly the moment +/// the distinction matters. +/// +/// +public sealed record FuturesAccount( + decimal WalletBalance, + decimal MarginBalance, + decimal AvailableBalance, + decimal UnrealizedPnl, + decimal MaintenanceMargin, + decimal InitialMargin, + int FeeTier, + bool CanTrade, + bool CanDeposit, + bool CanWithdraw, + bool MultiAssetsMargin, + DateTime UpdatedUtc) +{ + public static readonly FuturesAccount Empty = + new(0, 0, 0, 0, 0, 0, 0, false, false, false, false, DateTime.MinValue); + + /// What the risk engine sizes against. + public decimal Equity => MarginBalance; + + /// + /// How close the account is to a margin call, as a fraction. Above 1 means the + /// maintenance requirement exceeds the balance, which is the liquidation condition. + /// + public double MarginRatio => + MarginBalance > 0 ? (double)(MaintenanceMargin / MarginBalance) : 0; + + public static FuturesAccount Parse(JsonElement e) => new( + e.Decimal("totalWalletBalance"), + e.Decimal("totalMarginBalance"), + e.Decimal("availableBalance"), + e.Decimal("totalUnrealizedProfit"), + e.Decimal("totalMaintMargin"), + e.Decimal("totalInitialMargin"), + e.Int32("feeTier"), + e.Bool("canTrade", true), + e.Bool("canDeposit", true), + e.Bool("canWithdraw", true), + e.Bool("multiAssetsMargin"), + DateTime.UtcNow); +} + +/// One symbol's open exposure, as /fapi/v2/positionRisk reports it. +public sealed record FuturesPosition( + string Symbol, + double Quantity, + double EntryPrice, + double MarkPrice, + double UnrealizedPnl, + double LiquidationPrice, + int Leverage, + string MarginType) +{ + public bool IsOpen => Math.Abs(Quantity) > 1e-12; + + public Side Side => Quantity > 0 ? Side.Buy : Quantity < 0 ? Side.Sell : Side.None; + + public double Notional => Math.Abs(Quantity) * MarkPrice; + + public static FuturesPosition Parse(JsonElement e) => new( + e.StringOrEmpty("symbol"), + e.Double("positionAmt"), + e.Double("entryPrice"), + e.Double("markPrice"), + e.Double("unRealizedProfit"), + e.Double("liquidationPrice"), + e.Int32("leverage", 1), + e.StringOrEmpty("marginType")); +} + +/// An order as Binance reports it. +public sealed record BinanceOrder( + long Id, + string ClientOrderId, + string Symbol, + Side Side, + string Type, + OrderStatus Status, + double Quantity, + double FilledQuantity, + double AverageFillPrice, + double LimitPrice, + bool ReduceOnly, + DateTime SubmittedUtc, + DateTime UpdatedUtc) +{ + /// True while the order can still fill, i.e. it holds margin and blocks a re-entry. + public bool IsWorking => Status is OrderStatus.New or OrderStatus.PartiallyFilled; + + public bool IsTerminal => Status is OrderStatus.Filled or OrderStatus.Canceled + or OrderStatus.Rejected or OrderStatus.Expired; + + public static BinanceOrder Parse(JsonElement e) + { + DateTime submitted = e.Timestamp("time"); + DateTime updated = e.Timestamp("updateTime"); + + return new BinanceOrder( + e.Int64("orderId"), + e.StringOrEmpty("clientOrderId"), + e.StringOrEmpty("symbol"), + ParseSide(e.StringOrNull("side")), + e.StringOrEmpty("type"), + ParseStatus(e.StringOrNull("status")), + e.Double("origQty"), + e.Double("executedQty"), + e.Double("avgPrice"), + e.Double("price"), + e.Bool("reduceOnly"), + submitted == DateTime.MinValue ? updated : submitted, + updated); + } + + public static Side ParseSide(string? side) => side?.ToUpperInvariant() switch + { + "BUY" => Side.Buy, + "SELL" => Side.Sell, + _ => Side.None, + }; + + public static OrderStatus ParseStatus(string? status) => status?.ToUpperInvariant() switch + { + "NEW" => OrderStatus.New, + "PARTIALLY_FILLED" => OrderStatus.PartiallyFilled, + "FILLED" => OrderStatus.Filled, + "CANCELED" => OrderStatus.Canceled, + "REJECTED" => OrderStatus.Rejected, + "EXPIRED" or "EXPIRED_IN_MATCH" => OrderStatus.Expired, + _ => OrderStatus.Unknown, + }; +} + +/// +/// The exchange's own rules for one symbol: how finely a quantity and a price may be +/// expressed, and how small an order may be. +/// +/// These are not optional niceties. A hedge ratio produces a quantity like +/// 0.0473819…, and sending that to a symbol whose step is 0.001 is +/// rejected outright. Rounding to the step is what makes a computed size an order. +/// +/// +public sealed record SymbolFilters( + string Symbol, + double TickSize, + double StepSize, + double MinQuantity, + double MaxQuantity, + double MinNotional, + int PricePrecision, + int QuantityPrecision) +{ + public static readonly SymbolFilters Unknown = + new(string.Empty, 0.01, 0.001, 0.001, double.MaxValue, 5, 2, 3); + + /// Rounds a quantity down onto the exchange's lot step. + public double RoundQuantity(double quantity) + { + if (StepSize <= 0) + { + return quantity; + } + + double sign = quantity < 0 ? -1 : 1; + double magnitude = Math.Abs(quantity); + + // Down, never up: rounding up can push the notional past the margin actually + // available, and a rejected leg on a two-leg trade leaves the book directional. + double steps = Math.Floor((magnitude / StepSize) + 1e-9); + double rounded = steps * StepSize; + + return sign * Math.Round(rounded, QuantityPrecision, MidpointRounding.ToZero); + } + + /// Rounds a price onto the exchange's tick. + public double RoundPrice(double price) + { + if (TickSize <= 0) + { + return price; + } + + double ticks = Math.Round(price / TickSize, MidpointRounding.ToEven); + return Math.Round(ticks * TickSize, PricePrecision, MidpointRounding.ToEven); + } + + /// Whether a rounded quantity is actually sendable at this price. + public bool IsTradable(double quantity, double price, out string problem) + { + double magnitude = Math.Abs(quantity); + + if (magnitude <= 0) + { + problem = "la quantità arrotondata sul passo del lotto è zero"; + return false; + } + + if (magnitude < MinQuantity) + { + problem = string.Create(CultureInfo.InvariantCulture, + $"quantità {magnitude} sotto il minimo {MinQuantity} di {Symbol}"); + return false; + } + + if (magnitude > MaxQuantity) + { + problem = string.Create(CultureInfo.InvariantCulture, + $"quantità {magnitude} sopra il massimo {MaxQuantity} di {Symbol}"); + return false; + } + + double notional = magnitude * price; + if (notional < MinNotional) + { + problem = string.Create(CultureInfo.InvariantCulture, + $"controvalore {notional:F2} sotto il minimo {MinNotional:F2} di {Symbol}"); + return false; + } + + problem = string.Empty; + return true; + } + + public string FormatQuantity(double quantity) => + Math.Abs(quantity).ToString("F" + QuantityPrecision.ToString(CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); + + public string FormatPrice(double price) => + price.ToString("F" + PricePrecision.ToString(CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); + + public static SymbolFilters Parse(JsonElement e) + { + string symbol = e.StringOrEmpty("symbol"); + int pricePrecision = e.Int32("pricePrecision", 2); + int quantityPrecision = e.Int32("quantityPrecision", 3); + + double tick = 0; + double step = 0; + double minQty = 0; + double maxQty = double.MaxValue; + double minNotional = 0; + + if (e.TryGetProperty("filters", out JsonElement filters) && + filters.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement f in filters.EnumerateArray()) + { + switch (f.StringOrNull("filterType")) + { + case "PRICE_FILTER": + tick = f.Double("tickSize"); + break; + case "LOT_SIZE": + step = f.Double("stepSize"); + minQty = f.Double("minQty"); + maxQty = f.Double("maxQty", double.MaxValue); + break; + case "MARKET_LOT_SIZE": + // Only tightens the market-order bounds; keep the stricter pair. + minQty = Math.Max(minQty, f.Double("minQty")); + maxQty = Math.Min(maxQty, f.Double("maxQty", double.MaxValue)); + break; + case "MIN_NOTIONAL": + minNotional = f.Double("notional", f.Double("minNotional")); + break; + default: + break; + } + } + } + + return new SymbolFilters( + symbol, + tick > 0 ? tick : Math.Pow(10, -pricePrecision), + step > 0 ? step : Math.Pow(10, -quantityPrecision), + minQty > 0 ? minQty : Math.Pow(10, -quantityPrecision), + maxQty, + minNotional > 0 ? minNotional : 5, + pricePrecision, + quantityPrecision); + } +} + +/// +/// Mark price and funding for one symbol. +/// +/// On perpetual futures the funding rate is paid every eight hours from one side of the +/// book to the other. A delta-neutral pair is short one leg and long the other, so it +/// collects funding on one and pays it on the other: the net of the two is a +/// yield the strategy earns simply for holding the position, and it is large enough to +/// be worth tilting the size towards. +/// +/// +public sealed record FundingInfo( + string Symbol, + double MarkPrice, + double IndexPrice, + double LastFundingRate, + DateTime NextFundingUtc) +{ + public static readonly FundingInfo None = new(string.Empty, 0, 0, 0, DateTime.MinValue); + + /// The rate annualised, assuming the current rate persists across all three daily settlements. + public double AnnualisedRate => LastFundingRate * 3 * 365; + + public TimeSpan TimeToNextFunding => + NextFundingUtc == DateTime.MinValue ? TimeSpan.MaxValue : NextFundingUtc - DateTime.UtcNow; + + public static FundingInfo Parse(JsonElement e) => new( + e.StringOrEmpty("symbol"), + e.Double("markPrice"), + e.Double("indexPrice"), + e.Double("lastFundingRate"), + e.Timestamp("nextFundingTime")); +} + +/// An order the bot wants to send. +public readonly record struct NewFuturesOrder +{ + public required string Symbol { get; init; } + + public required Side Side { get; init; } + + public required double Quantity { get; init; } + + public OrderType Type { get; init; } + + /// Only read for . + public double LimitPrice { get; init; } + + /// + /// Close-only. Set on every exit leg so a race with a concurrent entry can never + /// flip the position through zero and open the opposite side by accident. + /// + public bool ReduceOnly { get; init; } + + public string? ClientOrderId { get; init; } + + /// + /// Time in force for a limit order. GTX is post-only: the order is cancelled + /// rather than filled if it would cross, which guarantees the maker fee. + /// + public string TimeInForce { get; init; } +} + +/// Account and order events arriving on the user-data stream. +public sealed record TradeUpdate( + string Event, + string Symbol, + Side Side, + OrderStatus Status, + long OrderId, + string ClientOrderId, + double LastFilledQuantity, + double FilledQuantity, + double LastFilledPrice, + double AverageFillPrice, + double RealizedPnl, + double Commission, + string CommissionAsset, + DateTime TimestampUtc) +{ + /// True when this event carries an actual fill rather than a status change. + public bool IsExecution => LastFilledQuantity > 0 && LastFilledPrice > 0; + + /// True once the order can no longer fill, so its in-flight latch may be released. + public bool IsTerminal => Status is OrderStatus.Filled or OrderStatus.Canceled + or OrderStatus.Rejected or OrderStatus.Expired; +} diff --git a/Encelado/src/Encelado.Binance/Streaming/MarketDataStream.cs b/Encelado/src/Encelado.Binance/Streaming/MarketDataStream.cs new file mode 100644 index 0000000..ea30650 --- /dev/null +++ b/Encelado/src/Encelado.Binance/Streaming/MarketDataStream.cs @@ -0,0 +1,439 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Encelado.Core.Market; + +namespace Encelado.Binance.Streaming; + +/// +/// Raised once per closed bar. A named delegate rather than an +/// so the payload can be passed by in: these fire on the receive thread for every +/// symbol, and copying a nine-field struct per subscriber is exactly the kind of cost +/// that only shows up once the basket is large. +/// +public delegate void BarHandler(int symbolId, string symbol, in Bar bar); + +/// Raised on every top-of-book change. See for the `in`. +public delegate void QuoteHandler(int symbolId, string symbol, in Quote quote); + +/// +/// The single market-data socket: closed klines, top of book and the funding rate, for +/// every traded symbol at once. +/// +/// Binance multiplexes subscriptions into one /stream?streams=… connection, and +/// the subscription list lives in the URL, so there is no handshake to get wrong and no +/// authentication at all — public data needs no key. One socket also means one +/// reconnect path and one place where "are we still receiving?" is answered. +/// +/// +/// The kline stream is what makes this venue a better fit than the previous one. Binance +/// sends a kline event with x: true at the exact moment a bar closes, so the +/// engine no longer has to fold minute bars into buckets and hope the process happens to +/// be connected when the last one arrives. A closed bar is an event, not an inference. +/// +/// +public sealed class MarketDataStream : WebSocketChannel +{ + private readonly SymbolTable _symbols; + + public MarketDataStream(BinanceOptions options, IReadOnlyList symbols, TimeFrame timeFrame) + : base(BuildUri(options, symbols, timeFrame), "market-data") + { + _symbols = new SymbolTable(symbols); + TimeFrame = timeFrame; + } + + public SymbolTable Symbols => _symbols; + + public TimeFrame TimeFrame { get; } + + /// Raised once per closed bar. Runs on the receive thread. + public BarHandler? OnBar { get; set; } + + /// Raised on every top-of-book change. + public QuoteHandler? OnQuote { get; set; } + + /// + /// Raised once a second per symbol with the mark price and the funding rate that + /// will be settled at nextFundingUtc. + /// + public Action? OnFunding { get; set; } + + /// + /// Builds the combined-stream URL. Three subscriptions per symbol: + /// the kline at the strategy timeframe, the book ticker, and the mark price (which + /// carries the funding rate, so the bot never has to poll for it). + /// + private static Uri BuildUri(BinanceOptions options, IReadOnlyList symbols, TimeFrame timeFrame) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(symbols); + + if (symbols.Count == 0) + { + throw new ArgumentException("At least one symbol is required.", nameof(symbols)); + } + + StringBuilder streams = new(symbols.Count * 64); + string interval = timeFrame.ToBinance(); + + foreach (string raw in symbols) + { + string s = raw.Trim().ToLowerInvariant(); + if (s.Length == 0) + { + continue; + } + + if (streams.Length > 0) + { + streams.Append('/'); + } + + streams.Append(s).Append("@kline_").Append(interval); + streams.Append('/').Append(s).Append("@bookTicker"); + streams.Append('/').Append(s).Append("@markPrice@1s"); + } + + return options.CombinedStreamUri(streams.ToString()); + } + + /// + /// Nothing to send: the subscription is in the URL and public data needs no key. + /// The channel is live the moment the socket opens. + /// + protected override ValueTask OnOpenAsync(CancellationToken ct) + { + SetState(ChannelState.Live); + Log($"[{Name}] subscribed to {_symbols.Count} symbol(s) at {TimeFrame.ToBinance()}"); + return ValueTask.CompletedTask; + } + + protected override void ConfigureSocket(ClientWebSocketOptions socketOptions) + { + ArgumentNullException.ThrowIfNull(socketOptions); + + // Binance pings every three minutes and closes the socket if a pong does not come + // back within ten. The framework answers pings automatically; this just keeps the + // connection warm through an idle stretch. + socketOptions.KeepAliveInterval = TimeSpan.FromSeconds(30); + } + + /// + /// Decodes one frame. Runs on the receive thread for every book update on every + /// symbol, so it reads straight out of the receive buffer with + /// rather than materialising a document per message. + /// + protected override void OnMessage(ReadOnlySpan payload, bool isText) + { + if (!isText || payload.IsEmpty) + { + return; + } + + // Combined-stream frames wrap the event: {"stream":"…","data":{…}}. Descend to + // "data" and decode from there; a bare event (single-stream socket) decodes as is. + Utf8JsonReader reader = new(payload, new JsonReaderOptions { AllowTrailingCommas = true }); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + return; + } + + while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName) + { + if (reader.ValueTextEquals("data"u8)) + { + reader.Read(); + DecodeEvent(ref reader); + return; + } + + if (reader.ValueTextEquals("e"u8)) + { + // A bare event: rewind by decoding the whole payload as one object. + Utf8JsonReader fresh = new(payload, new JsonReaderOptions { AllowTrailingCommas = true }); + fresh.Read(); + DecodeEvent(ref fresh); + return; + } + + reader.Read(); + reader.Skip(); + } + } + + /// Dispatches one event object, positioned on its StartObject. + private void DecodeEvent(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + return; + } + + // The event type is the first property Binance emits, so this rarely scans far — + // but the decoder must not depend on ordering, so it copies the reader and looks. + Utf8JsonReader probe = reader; + EventKind kind = PeekKind(ref probe); + + switch (kind) + { + case EventKind.Kline: + DecodeKline(ref reader); + break; + case EventKind.BookTicker: + DecodeBookTicker(ref reader); + break; + case EventKind.MarkPrice: + DecodeMarkPrice(ref reader); + break; + default: + break; + } + } + + private enum EventKind : byte + { + Unknown = 0, + Kline, + BookTicker, + MarkPrice, + } + + private static EventKind PeekKind(ref Utf8JsonReader reader) + { + int depth = 0; + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + case JsonTokenType.StartArray: + depth++; + continue; + case JsonTokenType.EndObject: + case JsonTokenType.EndArray: + if (depth == 0) + { + return EventKind.Unknown; + } + + depth--; + continue; + case JsonTokenType.PropertyName when depth == 0 && reader.ValueTextEquals("e"u8): + reader.Read(); + return reader.ValueTextEquals("kline"u8) ? EventKind.Kline + : reader.ValueTextEquals("bookTicker"u8) ? EventKind.BookTicker + : reader.ValueTextEquals("markPriceUpdate"u8) ? EventKind.MarkPrice + : EventKind.Unknown; + default: + continue; + } + } + + return EventKind.Unknown; + } + + /// + /// Decodes a kline event and raises only when the bar has closed. + /// + /// Binance re-sends the forming bar several times a second. Acting on any of those is + /// deciding on a partial close — the strategy would then decide again on the real one, + /// which is how a single bar becomes two trades. + /// + /// + private void DecodeKline(ref Utf8JsonReader reader) + { + int symbolId = -1; + long openTime = 0; + double open = 0, high = 0, low = 0, close = 0, volume = 0, quoteVolume = 0, takerBuy = 0; + int trades = 0; + bool closed = false; + int depth = 0; + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + depth++; + continue; + } + + if (reader.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray) + { + if (depth == 0) + { + break; + } + + depth--; + continue; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + // Symbol at the top level, everything else inside the "k" object. Both are + // matched by name so ordering never matters. + if (depth == 0 && reader.ValueTextEquals("s"u8)) + { + reader.Read(); + symbolId = _symbols.Resolve(reader.ValueSpan); + continue; + } + + if (depth != 1) + { + reader.Read(); + reader.Skip(); + continue; + } + + if (reader.ValueTextEquals("t"u8)) { reader.Read(); openTime = reader.GetInt64(); } + else if (reader.ValueTextEquals("o"u8)) { reader.Read(); open = Number(ref reader); } + else if (reader.ValueTextEquals("h"u8)) { reader.Read(); high = Number(ref reader); } + else if (reader.ValueTextEquals("l"u8)) { reader.Read(); low = Number(ref reader); } + else if (reader.ValueTextEquals("c"u8)) { reader.Read(); close = Number(ref reader); } + else if (reader.ValueTextEquals("v"u8)) { reader.Read(); volume = Number(ref reader); } + else if (reader.ValueTextEquals("q"u8)) { reader.Read(); quoteVolume = Number(ref reader); } + else if (reader.ValueTextEquals("V"u8)) { reader.Read(); takerBuy = Number(ref reader); } + else if (reader.ValueTextEquals("n"u8)) { reader.Read(); trades = reader.GetInt32(); } + else if (reader.ValueTextEquals("x"u8)) { reader.Read(); closed = reader.TokenType == JsonTokenType.True; } + else { reader.Read(); reader.Skip(); } + } + + if (!closed || symbolId < 0 || close <= 0) + { + return; + } + + Bar bar = new( + DateTimeOffset.FromUnixTimeMilliseconds(openTime).UtcDateTime, + open, high, low, close, volume, + volume > 0 ? quoteVolume / volume : 0, + trades, + takerBuy); + + OnBar?.Invoke(symbolId, _symbols.Name(symbolId), bar); + } + + private void DecodeBookTicker(ref Utf8JsonReader reader) + { + int symbolId = -1; + double bid = 0, bidSize = 0, ask = 0, askSize = 0; + long eventMs = 0; + int depth = 0; + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + depth++; + continue; + } + + if (reader.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray) + { + if (depth == 0) + { + break; + } + + depth--; + continue; + } + + if (reader.TokenType != JsonTokenType.PropertyName || depth != 0) + { + continue; + } + + if (reader.ValueTextEquals("s"u8)) { reader.Read(); symbolId = _symbols.Resolve(reader.ValueSpan); } + else if (reader.ValueTextEquals("b"u8)) { reader.Read(); bid = Number(ref reader); } + else if (reader.ValueTextEquals("B"u8)) { reader.Read(); bidSize = Number(ref reader); } + else if (reader.ValueTextEquals("a"u8)) { reader.Read(); ask = Number(ref reader); } + else if (reader.ValueTextEquals("A"u8)) { reader.Read(); askSize = Number(ref reader); } + else if (reader.ValueTextEquals("E"u8)) { reader.Read(); eventMs = reader.GetInt64(); } + else { reader.Read(); reader.Skip(); } + } + + if (symbolId < 0 || bid <= 0 || ask <= 0) + { + return; + } + + Quote quote = new( + eventMs > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(eventMs).UtcDateTime : DateTime.UtcNow, + bid, bidSize, ask, askSize); + + OnQuote?.Invoke(symbolId, _symbols.Name(symbolId), quote); + } + + private void DecodeMarkPrice(ref Utf8JsonReader reader) + { + int symbolId = -1; + double markPrice = 0, fundingRate = 0; + long nextFundingMs = 0; + int depth = 0; + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + depth++; + continue; + } + + if (reader.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray) + { + if (depth == 0) + { + break; + } + + depth--; + continue; + } + + if (reader.TokenType != JsonTokenType.PropertyName || depth != 0) + { + continue; + } + + if (reader.ValueTextEquals("s"u8)) { reader.Read(); symbolId = _symbols.Resolve(reader.ValueSpan); } + else if (reader.ValueTextEquals("p"u8)) { reader.Read(); markPrice = Number(ref reader); } + else if (reader.ValueTextEquals("r"u8)) { reader.Read(); fundingRate = Number(ref reader); } + else if (reader.ValueTextEquals("T"u8)) { reader.Read(); nextFundingMs = reader.GetInt64(); } + else { reader.Read(); reader.Skip(); } + } + + if (symbolId < 0 || markPrice <= 0) + { + return; + } + + OnFunding?.Invoke( + symbolId, + _symbols.Name(symbolId), + markPrice, + fundingRate, + nextFundingMs > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(nextFundingMs).UtcDateTime : DateTime.MinValue); + } + + /// + /// Reads a number that Binance may have encoded either way. Prices and quantities + /// arrive as strings, counters as numbers, and the mark-price stream mixes both. + /// + internal static double Number(ref Utf8JsonReader reader) => reader.TokenType switch + { + JsonTokenType.Number => reader.GetDouble(), + JsonTokenType.String => Utf8Parser(reader.ValueSpan), + _ => 0, + }; + + private static double Utf8Parser(ReadOnlySpan utf8) => + System.Buffers.Text.Utf8Parser.TryParse(utf8, out double value, out _) ? value : 0; +} diff --git a/Encelado/src/Encelado.Alpaca/Streaming/SymbolTable.cs b/Encelado/src/Encelado.Binance/Streaming/SymbolTable.cs similarity index 94% rename from Encelado/src/Encelado.Alpaca/Streaming/SymbolTable.cs rename to Encelado/src/Encelado.Binance/Streaming/SymbolTable.cs index 89b0b2b..8357788 100644 --- a/Encelado/src/Encelado.Alpaca/Streaming/SymbolTable.cs +++ b/Encelado/src/Encelado.Binance/Streaming/SymbolTable.cs @@ -1,4 +1,4 @@ -namespace Encelado.Alpaca.Streaming; +namespace Encelado.Binance.Streaming; /// /// Maps a symbol's UTF-8 bytes to a stable integer id and a single interned string @@ -34,7 +34,7 @@ public sealed class SymbolTable /// Resolves a symbol from raw UTF-8. Returns -1 when it is not subscribed. public int Resolve(ReadOnlySpan utf8) { - // Symbols are short ASCII (crypto pairs like BTC/USD included), so a stack + // Symbols are short ASCII (BTCUSDT and the like), so a stack // buffer covers every real case without touching the heap. if (utf8.Length is 0 or > 32) { diff --git a/Encelado/src/Encelado.Binance/Streaming/UserDataStream.cs b/Encelado/src/Encelado.Binance/Streaming/UserDataStream.cs new file mode 100644 index 0000000..fe45202 --- /dev/null +++ b/Encelado/src/Encelado.Binance/Streaming/UserDataStream.cs @@ -0,0 +1,232 @@ +using System.Text.Json; +using Encelado.Binance.Internal; +using Encelado.Binance.Rest; +using Encelado.Core.Market; + +namespace Encelado.Binance.Streaming; + +/// +/// The account's own event feed: order fills, cancellations and position changes, as +/// they happen. +/// +/// Binance authenticates this socket with a listen key minted over REST and +/// carried in the URL. The key expires after sixty minutes of not being renewed, and +/// when it does the socket simply closes — no error, no message. That is how a bot ends +/// up placing orders and never learning whether they filled, so the keepalive here is +/// not housekeeping: it is the thing that keeps the position book truthful. +/// +/// +public sealed class UserDataStream : WebSocketChannel +{ + private readonly BinanceFuturesClient _client; + private readonly BinanceOptions _options; + + private Timer? _keepAlive; + private string _listenKey = string.Empty; + + public UserDataStream(BinanceFuturesClient client, BinanceOptions options) + : base(options.UserStreamUri("pending"), "user-data") + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(options); + _client = client; + _options = options; + } + + /// Raised for every order event. Runs on the receive thread. + public Action? OnTradeUpdate { get; set; } + + /// + /// Raised when Binance reports the account's own view of a position, which is + /// authoritative and overrides anything the bot computed for itself. + /// + public Action? OnPositionUpdate { get; set; } + + /// Raised when the wallet balance changes, with the new balance in USDT. + public Action? OnBalanceUpdate { get; set; } + + /// + /// Mints a fresh listen key for each connection attempt. + /// + /// Reusing the key across a reconnect looks tidier and is wrong: the most common + /// reason the socket dropped in the first place is that the key expired, so + /// reconnecting with it fails immediately and forever. + /// + /// + protected override async ValueTask ResolveUriAsync(CancellationToken ct) + { + string key = await _client.CreateListenKeyAsync(ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(key)) + { + throw new BinanceApiException("Binance returned an empty listen key for the user-data stream."); + } + + _listenKey = key; + return _options.UserStreamUri(key); + } + + protected override ValueTask OnOpenAsync(CancellationToken ct) + { + SetState(ChannelState.Live); + StartKeepAlive(); + Log($"[{Name}] order stream live"); + return ValueTask.CompletedTask; + } + + /// + /// Renews the listen key every thirty minutes — half its lifetime, so one missed + /// renewal is survivable rather than fatal. + /// + private void StartKeepAlive() + { + _keepAlive?.Dispose(); + + _keepAlive = new Timer( + static state => _ = ((UserDataStream)state!).RenewAsync(), + this, + TimeSpan.FromMinutes(30), + TimeSpan.FromMinutes(30)); + } + + private async Task RenewAsync() + { + if (_listenKey.Length == 0) + { + return; + } + + try + { + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(15)); + await _client.KeepListenKeyAliveAsync(cts.Token).ConfigureAwait(false); + Log($"[{Name}] listen key renewed"); + } + catch (Exception ex) when (ex is BinanceApiException or OperationCanceledException or HttpRequestException) + { + // Not fatal on its own: the key has another thirty minutes of life, and the + // channel reconnects with a brand new one if it does expire. + Log($"[{Name}] listen key renewal failed: {ex.Message}"); + } + } + + protected override void OnMessage(ReadOnlySpan payload, bool isText) + { + if (!isText || payload.IsEmpty) + { + return; + } + + // Order events are rare — a handful per trade — so clarity wins over the + // allocation-free decoding the market-data path needs. + JsonDocument document; + try + { + document = JsonDocument.Parse(payload.ToArray()); + } + catch (JsonException ex) + { + Log($"[{Name}] undecodable frame: {ex.Message}"); + return; + } + + using (document) + { + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return; + } + + switch (root.StringOrNull("e")) + { + case "ORDER_TRADE_UPDATE": + DecodeOrderUpdate(root); + break; + case "ACCOUNT_UPDATE": + DecodeAccountUpdate(root); + break; + case "listenKeyExpired": + // Said out loud rather than left as a silent close: this is the one + // failure that makes the bot blind to its own fills. + Log($"[{Name}] listen key expired — reconnecting with a new one"); + Reject("listen key expired"); + break; + case "MARGIN_CALL": + Log($"[{Name}] MARGIN CALL from Binance — positions are close to liquidation"); + break; + default: + break; + } + } + } + + private void DecodeOrderUpdate(JsonElement root) + { + if (!root.TryGetProperty("o", out JsonElement o) || o.ValueKind != JsonValueKind.Object) + { + return; + } + + TradeUpdate update = new( + "ORDER_TRADE_UPDATE", + o.StringOrEmpty("s"), + BinanceOrder.ParseSide(o.StringOrNull("S")), + BinanceOrder.ParseStatus(o.StringOrNull("X")), + o.Int64("i"), + o.StringOrEmpty("c"), + o.Double("l"), + o.Double("z"), + o.Double("L"), + o.Double("ap"), + o.Double("rp"), + o.Double("n"), + o.StringOrEmpty("N"), + root.Timestamp("E")); + + OnTradeUpdate?.Invoke(update); + } + + private void DecodeAccountUpdate(JsonElement root) + { + if (!root.TryGetProperty("a", out JsonElement a) || a.ValueKind != JsonValueKind.Object) + { + return; + } + + if (a.TryGetProperty("P", out JsonElement positions) && positions.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement p in positions.EnumerateArray()) + { + string symbol = p.StringOrEmpty("s"); + if (symbol.Length > 0) + { + OnPositionUpdate?.Invoke(symbol, p.Double("pa"), p.Double("ep")); + } + } + } + + if (a.TryGetProperty("B", out JsonElement balances) && balances.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement b in balances.EnumerateArray()) + { + if (b.StringOrEmpty("a").Equals("USDT", StringComparison.OrdinalIgnoreCase)) + { + OnBalanceUpdate?.Invoke(b.Double("wb")); + break; + } + } + } + } + + public override async ValueTask DisposeAsync() + { + if (_keepAlive is not null) + { + await _keepAlive.DisposeAsync().ConfigureAwait(false); + _keepAlive = null; + } + + await base.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/Encelado/src/Encelado.Alpaca/Streaming/WebSocketChannel.cs b/Encelado/src/Encelado.Binance/Streaming/WebSocketChannel.cs similarity index 84% rename from Encelado/src/Encelado.Alpaca/Streaming/WebSocketChannel.cs rename to Encelado/src/Encelado.Binance/Streaming/WebSocketChannel.cs index b92ad2f..1628407 100644 --- a/Encelado/src/Encelado.Alpaca/Streaming/WebSocketChannel.cs +++ b/Encelado/src/Encelado.Binance/Streaming/WebSocketChannel.cs @@ -1,7 +1,7 @@ using System.Buffers; using System.Net.WebSockets; -namespace Encelado.Alpaca.Streaming; +namespace Encelado.Binance.Streaming; public enum ChannelState : byte { @@ -32,7 +32,11 @@ public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable public string Name { get; } = name; - public Uri Uri { get; } = uri; + /// + /// Where the channel connects. Settable by because the + /// user-data socket's address contains a listen key that expires and is reissued. + /// + public Uri Uri { get; protected set; } = uri; public ChannelState State { get; private set; } = ChannelState.Disconnected; @@ -59,10 +63,10 @@ public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable /// Records a server-side refusal that reconnecting cannot fix on its own, and tears /// the socket down now rather than waiting for the server to time it out. /// - /// The timing matters more than it looks. Alpaca permits one market-data connection - /// per account and closes an unauthenticated socket after ten seconds; a client that + /// The timing matters more than it looks. A server that rate-limits connections keeps + /// the refused socket alive for a few seconds after refusing it; a client that /// reconnects on a three-second backoff therefore opens the next socket while the - /// refused one is still occupying the only slot, and refuses itself forever. Closing + /// refused one is still counted against it, and refuses itself forever. Closing /// immediately, and backing off past the server's own timeout, is what breaks that. /// /// @@ -135,12 +139,14 @@ public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable { SetState(ChannelState.Connecting); + Uri = await ResolveUriAsync(ct).ConfigureAwait(false); + _socket = new ClientWebSocket(); _socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(20); ConfigureSocket(_socket.Options); await _socket.ConnectAsync(Uri, ct).ConfigureAwait(false); - OnLog?.Invoke($"[{Name}] socket open -> {Uri}", null); + OnLog?.Invoke($"[{Name}] socket open -> {Redact(Uri)}", null); SetState(ChannelState.Authenticating); await OnOpenAsync(ct).ConfigureAwait(false); @@ -295,6 +301,25 @@ public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable /// Sends the auth (and subscribe) handshake right after the socket opens. protected abstract ValueTask OnOpenAsync(CancellationToken ct); + /// + /// Resolves the address to connect to, immediately before each attempt. The default + /// returns the fixed ; the user-data channel overrides it to mint a + /// fresh listen key, because a reconnect after an expiry has to use a new one. + /// + protected virtual ValueTask ResolveUriAsync(CancellationToken ct) => new(Uri); + + /// + /// Strips a credential out of a URL before it reaches the log. A listen key is a + /// bearer token for the account's order stream: it does not belong in a file the + /// operator may reasonably send to someone else. + /// + private static string Redact(Uri uri) + { + string text = uri.ToString(); + int ws = text.LastIndexOf("/ws/", StringComparison.Ordinal); + return ws < 0 ? text : string.Concat(text.AsSpan(0, ws + 4), "…"); + } + /// Decodes one complete frame. Runs on the receive thread — keep it allocation free. protected abstract void OnMessage(ReadOnlySpan payload, bool isText); @@ -329,19 +354,18 @@ public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable protected void Log(string message, Exception? ex = null) => OnLog?.Invoke(message, ex); /// - /// Server-side timeout for an unauthenticated socket. Any backoff shorter than this - /// risks opening the next connection while the previous one still holds the - /// account's single market-data slot. + /// How long the server keeps a refused socket counted against the connection limit. + /// Any backoff shorter than this risks opening the next connection while the + /// previous one is still being held against us. /// private static readonly TimeSpan ServerAuthTimeout = TimeSpan.FromSeconds(10); /// - /// 1s, 2s, 4s … capped at 60s, with jitter. Alpaca allows a single market-data - /// connection per account, so hammering reconnects just earns a 406. + /// 1s, 2s, 4s … capped at 60s, with jitter. Binance caps new websocket connections + /// at 300 per five minutes per IP, so hammering reconnects earns an outright ban. /// - /// Once the server has actually refused us, the floor rises above its own ten-second - /// timeout. Otherwise the client competes with its own dying socket for the one slot - /// available and can never win. + /// Once the server has actually refused us, the floor rises above its own timeout. + /// Otherwise the client competes with its own dying socket and can never win. /// /// private TimeSpan BackoffDelay(int failures) @@ -367,7 +391,7 @@ public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable socket?.Dispose(); } - public async ValueTask DisposeAsync() + public virtual async ValueTask DisposeAsync() { await StopAsync().ConfigureAwait(false); _cts?.Dispose(); diff --git a/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs b/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs index 5df518f..a988208 100644 --- a/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs +++ b/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs @@ -1,11 +1,11 @@ -using Encelado.Alpaca; +using Encelado.Binance; using Encelado.Core.Market; using Encelado.Core.Risk; using Encelado.Core.Strategies; namespace Encelado.Bot.Configuration; -/// Where the Alpaca credentials in use actually came from. +/// Where the Binance credentials in use actually came from. public enum CredentialSource { None = 0, @@ -17,10 +17,10 @@ public enum CredentialSource public sealed class BotConfig { - public AlpacaOptions Alpaca { get; set; } = new(); + public BinanceOptions Binance { get; set; } = new(); /// - /// Provenance of . Set by the loader and by the + /// Provenance of . Set by the loader and by the /// login flow so startup can report it without ever echoing the secret. /// public CredentialSource CredentialOrigin { get; set; } = CredentialSource.None; @@ -31,51 +31,61 @@ public sealed class BotConfig public LoggingOptions Logging { get; set; } = new(); - public UiOptions Ui { get; set; } = new(); + public List Pairs { get; set; } = []; - public List Symbols { get; set; } = []; + public IEnumerable EnabledPairs => Pairs.Where(static p => p.Enabled); - public IEnumerable EnabledSymbols => Symbols.Where(s => s.Enabled); + /// + /// Every distinct symbol the enabled pairs touch. One symbol can legitimately appear + /// in two pairs — BTCUSDT is the natural benchmark leg — so this is deduplicated + /// before it reaches the market-data subscription. + /// + public IReadOnlyList TradedSymbols + { + get + { + List symbols = []; + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + + foreach (PairConfig pair in EnabledPairs) + { + foreach (string symbol in new[] { pair.SymbolA, pair.SymbolB }) + { + string clean = PairConfig.Normalize(symbol); + if (clean.Length > 0 && seen.Add(clean)) + { + symbols.Add(clean); + } + } + } + + return symbols; + } + } public BotConfig Validate() { - Alpaca.Validate(); + Binance.Validate(); Risk.Validate(); Engine.Validate(); - Ui.Validate(); Logging.Validate(); - List enabled = [.. EnabledSymbols]; + List enabled = [.. EnabledPairs]; if (enabled.Count == 0) { - throw new InvalidOperationException("No enabled symbols in the configuration."); + throw new InvalidOperationException( + "Nessuna coppia attiva nella configurazione. Il bot opera su coppie cointegrate: " + + "serve almeno una coppia con 'enabled': true."); } HashSet seen = new(StringComparer.OrdinalIgnoreCase); - foreach (SymbolConfig s in enabled) + foreach (PairConfig pair in enabled) { - if (string.IsNullOrWhiteSpace(s.Symbol)) - { - throw new InvalidOperationException("A symbol entry has an empty 'symbol'."); - } + pair.Validate(); - if (!seen.Add(s.Symbol)) + if (!seen.Add(pair.Name)) { - throw new InvalidOperationException($"Symbol '{s.Symbol}' is configured more than once."); - } - - // Il messaggio dice anche come uscirne. Questo caso capita quando un - // aggiornamento toglie una strategia e l'installazione conserva — a - // ragione — l'encelado.json dell'utente: senza l'indicazione, l'unica via - // d'uscita apparente è modificare il file a mano. - if (!StrategyFactory.IsKnown(s.Strategy)) - { - throw new InvalidOperationException( - $"La strategia '{s.Strategy}' configurata su {s.Symbol} non esiste più.\n\n" + - $"Disponibili: {string.Join(", ", StrategyFactory.Available)}.\n\n" + - "Aprila da Impostazioni → Strategia e scegline una dall'elenco: " + - "capita dopo un aggiornamento, perché l'installazione non sovrascrive " + - "la tua configurazione."); + throw new InvalidOperationException($"La coppia '{pair.Name}' è configurata più di una volta."); } } @@ -83,120 +93,197 @@ public sealed class BotConfig } } +/// +/// One tradeable pair: two Binance futures symbols and the parameters the statistical +/// model uses on them. +/// +public sealed class PairConfig +{ + /// The leg the spread is measured on: ln(A) − β·ln(B). + public string SymbolA { get; set; } = string.Empty; + + /// The hedge leg. + public string SymbolB { get; set; } = string.Empty; + + public bool Enabled { get; set; } = true; + + public Dictionary Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// Stable identity, used as a dictionary key and shown in the UI. + public string Name => $"{Normalize(SymbolA)}/{Normalize(SymbolB)}"; + + public StrategyParameters ToStrategyParameters() => new(Parameters); + + /// + /// Binance futures symbols are uppercase and unpunctuated. Accepting + /// eth/usdt and ETH-USDT and normalising here costs nothing and saves + /// a support conversation about a symbol the exchange simply does not have. + /// + public static string Normalize(string? symbol) => + symbol is null + ? string.Empty + : symbol.Trim().ToUpperInvariant().Replace("/", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Replace(":", string.Empty, StringComparison.Ordinal); + + public void Validate() + { + string a = Normalize(SymbolA); + string b = Normalize(SymbolB); + + if (a.Length == 0 || b.Length == 0) + { + throw new InvalidOperationException("Una coppia ha un simbolo vuoto: servono sia symbolA sia symbolB."); + } + + if (a.Equals(b, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"La coppia '{a}/{b}' ha le due gambe uguali: lo spread sarebbe costante a zero."); + } + + // Built and thrown away just to run the strategy's own parameter validation now + // rather than at the first bar, when the window is already open and the operator + // has moved on. + _ = new Core.Strategies.Pairs.StatArbStrategy(ToStrategyParameters()); + } +} + public sealed class EngineOptions { - /// us_equity or crypto. Crypto trades 24/7 and requires fractional sizes. - public string AssetClass { get; set; } = "us_equity"; + /// + /// Decision timeframe. Binance closes the bar for us and says so on the stream, so + /// this is the interval subscribed to rather than a bucket size to fold into. + /// + /// 5m and 15m are the range the strategy is meant for: long enough that a domestic + /// connection's latency is irrelevant, short enough that a spread reverts several + /// times a day. + /// + /// + public string TimeFrame { get; set; } = "5m"; - /// Decision timeframe. Bars are consumed straight from the stream at 1Min. - public string TimeFrame { get; set; } = "1Min"; + /// Historical bars pulled at startup to fill the z-score window. + public int WarmupBars { get; set; } = 500; - /// Historical bars pulled at startup to warm the indicators. - public int WarmupBars { get; set; } = 300; + /// + /// How many bars the cointegration fit runs on. More is a more confident β and a + /// staler one; 500 five-minute bars is about forty hours, which is long enough for + /// the test to mean something and short enough to still describe today's market. + /// + public int CalibrationBars { get; set; } = 500; - /// Refuse new entries outside 09:30–16:00 ET. - public bool TradeOnlyRegularHours { get; set; } = true; - - /// Flatten everything this many minutes before the close. 0 disables. - public int FlattenBeforeCloseMinutes { get; set; } = 10; - - public bool AllowFractionalShares { get; set; } - - /// Attach take-profit/stop-loss legs server-side so exits survive a bot crash. - public bool UseBracketOrders { get; set; } = true; + /// + /// How often the whole basket is refitted and retested for cointegration. The + /// strategy note says daily; anything much shorter refits on noise and keeps + /// resetting the z-score window. + /// + public double RecalibrateHours { get; set; } = 24; /// market or limit. A marketable limit caps slippage. public string EntryOrderType { get; set; } = "limit"; /// How far through the touch a marketable limit is priced, in basis points. - public double LimitOffsetBps { get; set; } = 5; + public double LimitOffsetBps { get; set; } = 2; + + /// + /// Post the entry as maker-only (GTX) instead of crossing the book. + /// + /// Tempting and usually wrong here. The maker rebate is worth a basis point or two, + /// and a post-only order that does not fill leaves one leg of a delta-neutral pair + /// naked — which costs far more, far faster, than the fee it saved. Off by default; + /// on only if you have measured your own fill rate. + /// + /// + public bool PostOnlyEntries { get; set; } /// Log decisions but never send an order. The safest way to observe a new config. public bool DryRun { get; set; } - public int ReconcileSeconds { get; set; } = 30; + /// How often the bot re-checks the account, positions and orders against Binance. + public int ReconcileSeconds { get; set; } = 20; public int StatusSeconds { get; set; } = 60; /// - /// How often the engine re-reads what each strategy would do at the current price and + /// How often the engine re-reads what each pair would do at the current price and /// writes it to the log when it has changed. This is the heartbeat that makes a /// patient bot distinguishable from a stuck one. /// public int ExplainSeconds { get; set; } = 5; /// Reject entries when top-of-book is older than this. 0 disables the check. - public int MaxQuoteAgeSeconds { get; set; } = 30; + public int MaxQuoteAgeSeconds { get; set; } = 15; - /// Liquidate everything when the bot shuts down. + /// Close every open pair when the bot shuts down. public bool CloseOnShutdown { get; set; } - public AssetClass ResolvedAssetClass => - AssetClass.Trim().ToLowerInvariant() is "crypto" or "us_crypto" - ? Core.Market.AssetClass.Crypto - : Core.Market.AssetClass.UsEquity; + /// Write a line for every closed bar, per symbol, even when nothing happens. + public bool LogEveryBar { get; set; } = true; public TimeFrame ResolvedTimeFrame => - TimeFrameExtensions.TryParse(TimeFrame, out TimeFrame tf) ? tf : Core.Market.TimeFrame.OneMinute; + TimeFrameExtensions.TryParse(TimeFrame, out TimeFrame tf) ? tf : Core.Market.TimeFrame.FiveMinutes; public bool UseLimitEntries => EntryOrderType.Trim().Equals("limit", StringComparison.OrdinalIgnoreCase); + public TimeSpan RecalibrateEvery => TimeSpan.FromHours(Math.Max(0.25, RecalibrateHours)); + public void Validate() { - if (!TimeFrameExtensions.TryParse(TimeFrame, out _)) + if (!TimeFrameExtensions.TryParse(TimeFrame, out TimeFrame tf)) { - throw new InvalidOperationException($"engine.timeFrame '{TimeFrame}' is not supported."); + throw new InvalidOperationException($"engine.timeFrame '{TimeFrame}' non è supportato."); + } + + if (tf == Core.Market.TimeFrame.OneMinute) + { + throw new InvalidOperationException( + "engine.timeFrame '1m' non è utilizzabile: a un minuto il costo di quattro " + + "attraversamenti dello spread per giro supera il rientro medio, e la latenza " + + "di una connessione domestica smette di essere irrilevante. Usa 5m o 15m."); } if (EntryOrderType.Trim() is not ("limit" or "market")) { - throw new InvalidOperationException("engine.entryOrderType must be 'limit' or 'market'."); + throw new InvalidOperationException("engine.entryOrderType deve essere 'limit' oppure 'market'."); } - if (WarmupBars is < 0 or > 10_000) + if (WarmupBars is < 50 or > 1500) { - throw new InvalidOperationException("engine.warmupBars must be between 0 and 10000."); + throw new InvalidOperationException("engine.warmupBars deve essere fra 50 e 1500."); } - if (LimitOffsetBps is < 0 or > 500) + if (CalibrationBars is < 100 or > 1500) { - throw new InvalidOperationException("engine.limitOffsetBps must be between 0 and 500."); + throw new InvalidOperationException( + "engine.calibrationBars deve essere fra 100 e 1500. Sotto le 100 barre il test " + + "di cointegrazione non ha potere statistico; sopra le 1500 Binance non le serve " + + "in una richiesta sola."); + } + + if (RecalibrateHours is < 0.25 or > 168) + { + throw new InvalidOperationException("engine.recalibrateHours deve essere fra 0.25 e 168."); + } + + if (LimitOffsetBps is < 0 or > 200) + { + throw new InvalidOperationException("engine.limitOffsetBps deve essere fra 0 e 200."); } if (ReconcileSeconds < 5) { - throw new InvalidOperationException("engine.reconcileSeconds must be at least 5."); + throw new InvalidOperationException("engine.reconcileSeconds deve essere almeno 5."); } - if (ResolvedAssetClass == Core.Market.AssetClass.Crypto && !AllowFractionalShares) + if (StatusSeconds < 10) { - throw new InvalidOperationException( - "engine.allowFractionalShares must be true when engine.assetClass is 'crypto'."); + throw new InvalidOperationException("engine.statusSeconds deve essere almeno 10."); } - } -} -/// Settings for the web dashboard served by the ui command. -public sealed class UiOptions -{ - /// - /// Where the dashboard listens. Use http://0.0.0.0:5088 to reach it from - /// another machine — there is no authentication, so only do that on a trusted LAN. - /// - public string Url { get; set; } = "http://localhost:5088"; - - /// Begin trading as soon as the dashboard starts, without pressing START. - public bool AutoStartBot { get; set; } - - public bool OpenBrowser { get; set; } = true; - - public void Validate() - { - if (!Uri.TryCreate(Url, UriKind.Absolute, out Uri? parsed) || - (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) + if (ExplainSeconds < 1) { - throw new InvalidOperationException($"ui.url '{Url}' is not a valid http(s) URL."); + throw new InvalidOperationException("engine.explainSeconds deve essere almeno 1."); } } } @@ -205,8 +292,9 @@ public sealed class LoggingOptions { /// /// Verbosity: trace, debug, info, warn, error or - /// none. debug adds every rejected signal and risk refusal; - /// trace adds per-quote detail and is very noisy. + /// none. Every refusal that stops an order is written at info or above, + /// so debug is for the market-data path rather than for finding out why the + /// bot did not trade. /// public string Level { get; set; } = "info"; @@ -226,15 +314,15 @@ public sealed class LoggingOptions public string TradeJournal { get; set; } = "trades.jsonl"; /// - /// One CSV row per evaluated bar, per symbol, with the full market state, every - /// indicator the strategy exposes, the position, and the resulting signal. This is - /// the dataset to analyse when tuning the model. Empty disables it. + /// One CSV row per evaluated bar, per pair, with the spread, the z-score, the + /// calibration and the resulting signal. This is the dataset to analyse when tuning + /// the thresholds. Empty disables it. /// public string DecisionLog { get; set; } = "decisions.csv"; /// - /// One CSV row per signal that reached the order path, with the risk verdict and - /// the order outcome. Joins to on decisionId. + /// One CSV row per signal that reached the order path, with the risk verdict and the + /// order outcome. Joins to on decisionId. /// public string ExecutionLog { get; set; } = "executions.csv"; @@ -245,29 +333,17 @@ public sealed class LoggingOptions public int MaxFiles { get; set; } = 10; /// - /// Log every quote and trade tick. Produces enormous files and is only useful when - /// diagnosing the market-data path itself. + /// Log every quote. Produces enormous files and is only useful when diagnosing the + /// market-data path itself. /// public bool LogMarketData { get; set; } - /// - /// Lines kept in the activity strip on the status page. Small on purpose: that panel - /// is glanced at, not read, and every line held there is a live WPF visual. - /// - /// - /// Log every incoming bar from the stream, not only the ones that close a strategy - /// bucket. On a daily timeframe this is the difference between a log that shows the - /// market moving and one that shows nothing for twenty-four hours. - /// - public bool LogEveryBar { get; set; } = true; - + /// Lines kept in the activity strip on the status page. public int StatusLines { get; set; } = 200; /// - /// Lines kept by the log page. This is the memory ceiling for the in-app log: at the - /// default it is a few megabytes. It is deliberately not unbounded — a bot left - /// running for a week at debug would otherwise grow without limit. The file - /// on disk stays complete regardless, and the page can open it. + /// Lines kept by the log page. This is the memory ceiling for the in-app log. The + /// file on disk stays complete regardless, and the page can open it. /// public int BufferedLines { get; set; } = 5_000; @@ -292,44 +368,31 @@ public sealed class LoggingOptions { if (MaxFileSizeMb is < 0 or > 4096) { - throw new InvalidOperationException("logging.maxFileSizeMb must be between 0 and 4096."); + throw new InvalidOperationException("logging.maxFileSizeMb deve essere fra 0 e 4096."); } if (MaxFiles is < 1 or > 500) { - throw new InvalidOperationException("logging.maxFiles must be between 1 and 500."); + throw new InvalidOperationException("logging.maxFiles deve essere fra 1 e 500."); } if (StatusLines is < 20 or > 5_000) { - throw new InvalidOperationException("logging.statusLines must be between 20 and 5000."); + throw new InvalidOperationException("logging.statusLines deve essere fra 20 e 5000."); } // The ceiling is a memory guard, not a preference: each buffered line is a live // object plus, once scrolled into view, a WPF visual. if (BufferedLines is < 100 or > 200_000) { - throw new InvalidOperationException("logging.bufferedLines must be between 100 and 200000."); + throw new InvalidOperationException("logging.bufferedLines deve essere fra 100 e 200000."); } if (BufferedLines < StatusLines) { throw new InvalidOperationException( - "logging.bufferedLines must be >= logging.statusLines: the log page cannot hold " + - "less history than the status strip."); + "logging.bufferedLines deve essere >= logging.statusLines: la pagina Log non può " + + "tenere meno storia della striscia di stato."); } } } - -public sealed class SymbolConfig -{ - public string Symbol { get; set; } = string.Empty; - - public string Strategy { get; set; } = "ema-cross"; - - public bool Enabled { get; set; } = true; - - public Dictionary Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); - - public StrategyParameters ToStrategyParameters() => new(Parameters); -} diff --git a/Encelado/src/Encelado.Bot/Configuration/ConfigDefaults.cs b/Encelado/src/Encelado.Bot/Configuration/ConfigDefaults.cs new file mode 100644 index 0000000..b902004 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Configuration/ConfigDefaults.cs @@ -0,0 +1,322 @@ +using System.Globalization; + +namespace Encelado.Bot.Configuration; + +/// +/// The factory configuration, and the ability to go back to it. +/// +/// The default lives here as text rather than as a set of property assignments, and the +/// shipped config/encelado.json is a copy of this string. That is deliberate: the +/// file is more than its values — the _-prefixed lines explain what every number +/// is for and why it has the value it has, and a "restore defaults" that rebuilt the file +/// from object defaults would silently throw all of that away and hand back a file nobody +/// could reason about. Restoring means restoring the document, not just the numbers. +/// +/// +/// A test asserts that this string and the shipped file are identical, so the two cannot +/// drift apart unnoticed. +/// +/// +public static class ConfigDefaults +{ + /// Extension given to the copy taken before a restore. + public const string BackupSuffix = ".bak"; + + /// + /// Rewrites with the factory configuration, after + /// moving whatever was there to a timestamped backup beside it. + /// + /// The backup is not optional and not configurable. Restoring defaults throws away + /// every tuned number, every disabled pair and every note the operator wrote in the + /// file, and that is a decision people make by accident. A copy costs nothing. + /// + /// + /// The path of the backup, or null when there was no file to back up. + public static string? Restore(string configPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configPath); + + string? backup = null; + + if (File.Exists(configPath)) + { + backup = string.Create(CultureInfo.InvariantCulture, + $"{configPath}.{DateTime.Now:yyyyMMdd-HHmmss}{BackupSuffix}"); + + File.Copy(configPath, backup, overwrite: true); + } + + string? directory = Path.GetDirectoryName(Path.GetFullPath(configPath)); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // Written to a temporary file and moved into place, so an interrupted write + // cannot leave a half-file the application then refuses to start from. + string temporary = configPath + ".tmp"; + File.WriteAllText(temporary, Json); + File.Move(temporary, configPath, overwrite: true); + + return backup; + } + + /// + /// Loads the factory values into in memory, without + /// touching the disk. Used when no configuration file exists at all. + /// + public static void ApplyTo(BotConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + BotConfig factory = Parse(); + + config.Engine = factory.Engine; + config.Risk = factory.Risk; + config.Logging = factory.Logging; + config.Pairs = factory.Pairs; + + // Credentials are never part of a default: they belong to the operator, not to + // the shipped configuration, and clearing them here would log the user out every + // time the file went missing. + config.Binance.Testnet = factory.Binance.Testnet; + config.Binance.Leverage = factory.Binance.Leverage; + config.Binance.MarginType = factory.Binance.MarginType; + config.Binance.RecvWindowMs = factory.Binance.RecvWindowMs; + config.Binance.RequestsPerMinute = factory.Binance.RequestsPerMinute; + config.Binance.HttpTimeout = factory.Binance.HttpTimeout; + config.Binance.MaxRetries = factory.Binance.MaxRetries; + } + + /// The factory configuration as a parsed object. Reparsed on each call. + public static BotConfig Parse() + { + string temporary = Path.Combine(Path.GetTempPath(), $"encelado-default-{Guid.NewGuid():N}.json"); + try + { + File.WriteAllText(temporary, Json); + return ConfigLoader.Load(temporary, out _); + } + finally + { + try + { + File.Delete(temporary); + } + catch (IOException) + { + // A leftover in the temp folder is not worth failing over. + } + } + } + + /// The factory encelado.json, verbatim. + public const string Json = """ +{ + "_comment": "Encelado — arbitraggio statistico su coppie cointegrate, Binance Futures USDⓈ-M. Il bot non prende posizione sulla direzione del mercato: è long una gamba e short l'altra nel rapporto che il test di cointegrazione produce, e scommette solo sul fatto che la distanza fra le due si richiuda.", + + "_misura": "IMPORTANTE — che cosa dice il backtest, e perché dryRun parte ATTIVO. Misurato su 6,6 anni di barre da un minuto (2020-2026) di ETHUSDT, BTCUSDT, SOLUSDT e AVAXUSDT, ripiegate su 5m, 15m, 30m, 1h e 4h, con commissione taker 4 bps più 1 bp di slippage per lato. Il risultato: su 5m nessuna combinazione di soglie supera nemmeno i filtri di taratura; su 15m e 1h la ricerca a griglia trova combinazioni che rendono in taratura e in verifica, ma NESSUNA delle prime dieci resta positiva sulla terza fetta, quella che non entra mai nella scelta. Con i valori qui sotto, sulle quattro coppie misurabili, la conferma dà tre risultati negativi e uno positivo del +0,40% su una sola operazione. La ragione di fondo è che il test di cointegrazione passa solo l'8-13% del tempo, quindi in sei anni una coppia produce qualche decina di operazioni: troppo poche per distinguere un margine reale da una serie fortunata. Questi valori sono i meglio supportati fra quelli provati, non una strategia dimostrata. Falla girare in dry-run finché non hai visto coi tuoi occhi come si comporta sul tuo conto. Lo strumento è in tools/Encelado.Backtest: 'backtest basket --data ' rifa la scelta da capo sui tuoi dati.", + + "binance": { + "_testnet": "true = testnet (denaro finto, stesse API). Metterlo a false opera con denaro reale. Le chiavi dei due ambienti sono diverse e vengono salvate separatamente.", + "testnet": true, + + "_leverage": "Leva applicata a ogni simbolo all'avvio. La guida indica 2x-3x: un libro delta-neutral si liquida comunque se una sola gamba fa un salto isolato abbastanza grande. Alzarla non aumenta il margine della strategia, moltiplica soltanto guadagno e perdita.", + "leverage": 2, + + "_marginType": "CROSSED mette le due gambe nello stesso pool di margine, così si compensano invece di avere ciascuna il proprio prezzo di liquidazione.", + "marginType": "CROSSED", + + "recvWindowMs": 5000, + "requestsPerMinute": 1200, + "httpTimeoutSeconds": 10, + "maxRetries": 3 + }, + + "engine": { + "_timeFrame": "15m. La guida indica 5m o 15m; il backtest ha bocciato 5m senza appello — nessuna combinazione di soglie supera nemmeno i filtri di taratura, perché a cinque minuti la commissione vale circa una deviazione standard dello spread. A 15m il numero di operazioni è almeno misurabile. A 1h e 4h le operazioni scendono a poche decine in sei anni: troppo poche per dire alcunché. '1m' è rifiutato dal validatore.", + "timeFrame": "15m", + + "_warmupBars": "Barre storiche scaricate all'avvio per riempire la finestra dello z-score prima della prima decisione. Deve coprire zWindow.", + "warmupBars": 1500, + + "_calibrationBars": "Su quante barre gira la regressione di cointegrazione che produce beta e p-value. 500 è il valore della guida ed è anche il migliore misurato: a 1000 lo stesso paniere peggiora nettamente su tutte e tre le fette. Questa finestra è indipendente da zWindow, che è molto più lunga: la prima stabilisce il rapporto di copertura, la seconda dice quanto è insolito lo scostamento di adesso.", + "calibrationBars": 500, + + "_recalibrateHours": "Ogni quanto l'intero paniere viene rifittato e ritestato. La guida dice 24 ore; provate anche una settimana, senza differenze sostanziali.", + "recalibrateHours": 24, + + "_entryOrderType": "'limit' invia un limite marcabile, prezzato oltre il touch di limitOffsetBps: si comporta come un ordine a mercato ma non può eseguire a un prezzo assurdo. 'market' attraversa e basta.", + "entryOrderType": "limit", + "limitOffsetBps": 2, + + "_postOnly": "Solo maker (GTX). Fa risparmiare la commissione ma un ordine che non esegue lascia una gamba scoperta, che costa molto di più. Lasciare false se non hai misurato il tuo tasso di riempimento.", + "postOnlyEntries": false, + + "_dryRun": "ATTIVO DI FABBRICA, e la ragione è nella nota _misura qui sopra. Calcola e registra tutto, non invia nessun ordine. Toglilo solo dopo aver visto in Stato che le tue coppie passano davvero il test di cointegrazione.", + "dryRun": true, + + "_reconcile": "Ogni quanto il bot ricontrolla conto, posizioni e ordini contro Binance. È anche quando recupera le barre che lo stream non ha consegnato e chiude le coppie rimaste con una gamba sola.", + "reconcileSeconds": 20, + "statusSeconds": 60, + + "_explain": "Ogni quanto il bot rilegge cosa farebbe adesso e lo scrive nel log, se è cambiato. È la riga che distingue un bot che aspetta da uno bloccato.", + "explainSeconds": 5, + + "_maxQuoteAge": "Rifiuta un ingresso se il book di una gamba è più vecchio di così. 0 disattiva il controllo.", + "maxQuoteAgeSeconds": 15, + + "closeOnShutdown": false, + "logEveryBar": true + }, + + "risk": { + "_stake": "Frazione dell'equity impegnata come MARGINE su una coppia. Con leva 2 il controvalore effettivamente al lavoro è il doppio, diviso fra le due gambe secondo β. Con 4 coppie al 15% il margine totale impegnato arriva al 60% dell'equity.", + "stakePct": 0.15, + "stakeAmount": 0, + + "_funding": "Una coppia delta-neutral incassa il funding su una gamba e lo paga sull'altra. Quando il netto è a favore la posizione rende per il solo fatto di esistere. 0.12 è il centro dell'intervallo 10-15% indicato dalla guida. 0 disattiva.", + "fundingTiltPct": 0.12, + "fundingTiltThreshold": 0.0001, + + "_exposure": "Controvalore lordo totale come multiplo dell'equity. Con leva 2 e 4 coppie al 15% si arriva a 1.2×: 2.0 lascia margine senza permettere il raddoppio.", + "maxGrossExposurePct": 2.0, + "maxOpenPairs": 4, + + "_frequency": "0 = nessun limite. Sono reti contro un difetto, non contro la strategia: un ciclo che riapre la stessa coppia cento volte costa cento volte le commissioni.", + "maxTradesPerDay": 40, + "maxTradesPerPairPerDay": 8, + "minSecondsBetweenEntries": 60, + + "_dailyLoss": "Kill switch giornaliero, non disattivabile. Su futures con leva è l'ultima fermata prima di una liquidazione.", + "maxDailyLossPct": 0.06, + "maxDailyProfitPct": 0, + + "_spread": "Il book più largo che una gamba può mostrare ed essere comunque entrata. Conta molto più che su un modello direzionale: un giro completo attraversa lo spread QUATTRO volte, quindi un book da 10 bps costa 40 bps contro un rientro che spesso vale meno.", + "maxRelativeSpread": 0.0006, + + "_notional": "Controvalore minimo e massimo per gamba, in USDT. Binance ha anche i suoi minimi per simbolo, più stringenti su BTCUSDT.", + "minOrderNotional": 25, + "maxOrderNotional": 0, + + "_margin": "Rapporto massimo fra margine di mantenimento ed equity oltre il quale non si aprono nuove coppie.", + "maxMarginRatio": 0.5, + + "_hedge": "β fuori da [1/5, 5] viene rifiutato: non è una copertura, è una scommessa sulla seconda gamba travestita da copertura.", + "maxHedgeRatio": 5.0 + }, + + "logging": { + "_level": "trace | debug | info | warn | error | none. Ogni rifiuto che ferma un ordine viene scritto a 'info' o sopra, quindi 'debug' serve per il flusso dati, non per capire perché il bot non ha operato.", + "level": "info", + + "_directory": "Dove salvare tutti gli output. Relativa all'eseguibile, oppure un percorso assoluto. Si cambia anche da Impostazioni.", + "directory": "logs", + + "console": false, + "file": "encelado.log", + "maxFileSizeMb": 32, + "maxFiles": 10, + + "_analysis": "decisions.csv ha una riga per ogni barra valutata con spread, z-score e calibrazione; executions.csv una riga per ogni segnale arrivato agli ordini, con il verdetto del risk engine. Si uniscono su decisionId.", + "tradeJournal": "trades.jsonl", + "decisionLog": "decisions.csv", + "executionLog": "executions.csv", + + "logMarketData": false, + "statusLines": 200, + "bufferedLines": 5000 + }, + + "_pairs": "Il paniere della guida. Ogni coppia si legge come ln(A) − β·ln(B): A è la gamba su cui si misura lo spread, B quella di copertura. Il test di cointegrazione decide da solo, a ogni ricalibrazione, se una coppia è operabile: quelle che non passano restano in elenco e non vengono aperte. Sui dati disponibili passano il test solo l'8-17% del tempo, quindi il bot resta fermo a lungo — è il comportamento previsto.", + + "_parametri": "I valori qui sotto vengono dalla ricerca a griglia (tools/Encelado.Backtest, comando 'basket'), non dalla guida. Le differenze rispetto alla guida sono tre e sono tutte misurate: zWindow 1500 invece di 100, entryZ 2.5 invece di 2.0, stopZ 6.0 invece di 3.5. La prima è la più importante: con una finestra vicina all'emivita del rientro (30-40 barre) la media mobile insegue lo scostamento e lo assorbe, e il margine sparisce prima ancora delle commissioni.", + + "pairs": [ + { + "_note": "Benchmark. È l'unica coppia del paniere risultata positiva sull'intero periodo, ma con sole 35-50 operazioni in 6,6 anni: troppe poche perché il risultato significhi qualcosa.", + "symbolA": "ETHUSDT", + "symbolB": "BTCUSDT", + "enabled": true, + "parameters": { + "_zWindow": "Finestra mobile su cui si normalizza lo spread. 1500 barre da 15 minuti sono circa 15 giorni. Deve essere molte volte l'emivita del rientro (qui 30-40 barre), altrimenti la media insegue lo scostamento e lo cancella.", + "zWindow": 1500, + + "_entryZ": "Quante deviazioni standard di scostamento servono per aprire. 2.5-3.0 ha battuto costantemente 2.0.", + "entryZ": 2.5, + + "_exitZ": "Sotto questo valore lo spread è rientrato: si chiude in guadagno.", + "exitZ": 0.5, + + "_stopZ": "Stop statistico: non è uno stop di prezzo, è l'affermazione che la relazione ha smesso di valere. 6.0 ha battuto 3.5 e 4.0 — uno stop stretto su uno spread che rientra lentamente realizza perdite che sarebbero rientrate.", + "stopZ": 6.0, + + "_maxPValue": "Soglia di cointegrazione. È il filtro che tiene in piedi tutto: senza, ogni combinazione provata passa da leggermente positiva a −73%/−87%.", + "maxPValue": 0.05, + + "_halfLife": "Emivita del rientro, in barre. Sotto il minimo lo spread rientra troppo in fretta per ripagare le commissioni; sopra il massimo il capitale resta impegnato più a lungo di quanto duri la relazione.", + "minHalfLife": 3, + "maxHalfLife": 200, + + "_maxBarsInTrade": "Stop temporale in barre. 0 disattiva: nei test non ha migliorato nulla, ma è la rete contro il caso peggiore — uno spread che si ferma a metà strada e ci resta per giorni.", + "maxBarsInTrade": 0, + + "requireCointegration": 1 + } + }, + { + "_note": "Layer-1. Verificata sul backtest: negativa sull'intero periodo con questi parametri.", + "symbolA": "SOLUSDT", + "symbolB": "AVAXUSDT", + "enabled": true, + "parameters": { + "zWindow": 1500, + "entryZ": 2.5, + "exitZ": 0.5, + "stopZ": 6.0, + "maxPValue": 0.05, + "minHalfLife": 3, + "maxHalfLife": 200, + "maxBarsInTrade": 0, + "requireCointegration": 1 + } + }, + { + "_note": "Layer-2 su Ethereum. NON verificata: non avevo dati storici di OP e ARB. È la coppia con le premesse migliori — stesso ecosistema, stessa età, stessi flussi — ma finché non la misuri resta un'ipotesi.", + "symbolA": "OPUSDT", + "symbolB": "ARBUSDT", + "enabled": true, + "parameters": { + "zWindow": 1500, + "entryZ": 2.5, + "exitZ": 0.5, + "stopZ": 6.0, + "maxPValue": 0.05, + "minHalfLife": 3, + "maxHalfLife": 200, + "maxBarsInTrade": 0, + "requireCointegration": 1 + } + }, + { + "_note": "DeFi blue-chip. NON verificata: non avevo dati storici di LINK e UNI.", + "symbolA": "LINKUSDT", + "symbolB": "UNIUSDT", + "enabled": true, + "parameters": { + "zWindow": 1500, + "entryZ": 2.5, + "exitZ": 0.5, + "stopZ": 6.0, + "maxPValue": 0.05, + "minHalfLife": 3, + "maxHalfLife": 200, + "maxBarsInTrade": 0, + "requireCointegration": 1 + } + } + ] +} + +"""; +} diff --git a/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs b/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs index 67736d4..e56e4cc 100644 --- a/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs +++ b/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs @@ -8,8 +8,8 @@ namespace Encelado.Bot.Configuration; /// binder means no trimming surprises and no silent type coercion — an unknown key is /// reported instead of ignored. /// -/// Precedence: file < environment variables. Credentials should live in the -/// environment (or a gitignored local file), never in the committed config. +/// Precedence: file < local overlay < environment variables. Credentials should +/// live in the environment (or a gitignored local file), never in the committed config. /// /// public static class ConfigLoader @@ -33,7 +33,8 @@ public static class ConfigLoader } else { - warnings.Add($"config file '{path}' not found; using defaults plus environment variables"); + warnings.Add($"configurazione '{path}' non trovata; uso i valori di fabbrica e le variabili d'ambiente"); + ConfigDefaults.ApplyTo(config); } // A sibling *.local.json overlays secrets and machine-specific overrides. @@ -53,7 +54,7 @@ public static class ConfigLoader { if (root.ValueKind != JsonValueKind.Object) { - throw new InvalidOperationException("The configuration root must be a JSON object."); + throw new InvalidOperationException("La radice della configurazione deve essere un oggetto JSON."); } foreach (JsonProperty section in root.EnumerateObject()) @@ -65,8 +66,8 @@ public static class ConfigLoader switch (section.Name.ToLowerInvariant()) { - case "alpaca": - ReadAlpaca(config, section.Value, warnings); + case "binance": + ReadBinance(config, section.Value, warnings); break; case "engine": ReadEngine(config, section.Value, warnings); @@ -77,38 +78,49 @@ public static class ConfigLoader case "logging": ReadLogging(config, section.Value, warnings); break; - case "ui": - ReadUi(config, section.Value, warnings); - break; - case "symbols": - ReadSymbols(config, section.Value, warnings); + case "pairs": + ReadPairs(config, section.Value, warnings); break; case "$schema": case "_comment": break; + + // Named explicitly rather than falling into the generic warning: an + // installation that keeps the user's file across an update will still + // carry these, and "unknown section" reads like a typo rather than like + // the upgrade it actually is. + case "alpaca": + case "symbols": + case "ui": + warnings.Add( + $"la sezione '{section.Name}' appartiene alla versione Alpaca ed è stata ignorata. " + + "Da Impostazioni → Ripristina valori predefiniti riscrivi il file nel formato Binance."); + break; default: - warnings.Add($"unknown config section '{section.Name}'"); + warnings.Add($"sezione sconosciuta '{section.Name}'"); break; } } } - private static void ReadAlpaca(BotConfig config, JsonElement e, List warnings) + private static void ReadBinance(BotConfig config, JsonElement e, List warnings) { - foreach (JsonProperty p in Properties(e, "alpaca", warnings)) + foreach (JsonProperty p in Properties(e, "binance", warnings)) { switch (p.Name.ToLowerInvariant()) { - case "keyid": config.Alpaca.KeyId = Str(p); break; - case "secretkey": config.Alpaca.SecretKey = Str(p); break; - case "paper": config.Alpaca.Paper = Bool(p); break; - case "datafeed": config.Alpaca.DataFeed = Str(p); break; - case "tradingbaseurl": config.Alpaca.TradingBaseUrlOverride = Str(p); break; - case "databaseurl": config.Alpaca.DataBaseUrlOverride = Str(p); break; - case "requestsperminute": config.Alpaca.RequestsPerMinute = Int(p); break; - case "httptimeoutseconds": config.Alpaca.HttpTimeout = TimeSpan.FromSeconds(Num(p)); break; - case "maxretries": config.Alpaca.MaxRetries = Int(p); break; - default: warnings.Add($"unknown key 'alpaca.{p.Name}'"); break; + case "apikey": config.Binance.ApiKey = Str(p); break; + case "apisecret": config.Binance.ApiSecret = Str(p); break; + case "testnet": config.Binance.Testnet = Bool(p); break; + case "restbaseurl": config.Binance.RestBaseUrlOverride = Str(p); break; + case "streambaseurl": config.Binance.StreamBaseUrlOverride = Str(p); break; + case "recvwindowms": config.Binance.RecvWindowMs = Int(p); break; + case "requestsperminute": config.Binance.RequestsPerMinute = Int(p); break; + case "httptimeoutseconds": config.Binance.HttpTimeout = TimeSpan.FromSeconds(Num(p)); break; + case "maxretries": config.Binance.MaxRetries = Int(p); break; + case "leverage": config.Binance.Leverage = Int(p); break; + case "margintype": config.Binance.MarginType = Str(p); break; + default: warnings.Add($"chiave sconosciuta 'binance.{p.Name}'"); break; } } } @@ -120,23 +132,21 @@ public static class ConfigLoader { switch (p.Name.ToLowerInvariant()) { - case "assetclass": o.AssetClass = Str(p); break; case "timeframe": o.TimeFrame = Str(p); break; case "warmupbars": o.WarmupBars = Int(p); break; - case "tradeonlyregularhours": o.TradeOnlyRegularHours = Bool(p); break; - case "flattenbeforeclosminutes": - case "flattenbeforecloseminutes": o.FlattenBeforeCloseMinutes = Int(p); break; - case "allowfractionalshares": o.AllowFractionalShares = Bool(p); break; - case "usebracketorders": o.UseBracketOrders = Bool(p); break; + case "calibrationbars": o.CalibrationBars = Int(p); break; + case "recalibratehours": o.RecalibrateHours = Num(p); break; case "entryordertype": o.EntryOrderType = Str(p); break; case "limitoffsetbps": o.LimitOffsetBps = Num(p); break; + case "postonlyentries": o.PostOnlyEntries = Bool(p); break; case "dryrun": o.DryRun = Bool(p); break; case "reconcileseconds": o.ReconcileSeconds = Int(p); break; case "statusseconds": o.StatusSeconds = Int(p); break; case "explainseconds": o.ExplainSeconds = Int(p); break; case "maxquoteageseconds": o.MaxQuoteAgeSeconds = Int(p); break; case "closeonshutdown": o.CloseOnShutdown = Bool(p); break; - default: warnings.Add($"unknown key 'engine.{p.Name}'"); break; + case "logeverybar": o.LogEveryBar = Bool(p); break; + default: warnings.Add($"chiave sconosciuta 'engine.{p.Name}'"); break; } } } @@ -148,26 +158,23 @@ public static class ConfigLoader { switch (p.Name.ToLowerInvariant()) { - case "maxriskpertradepct": r.MaxRiskPerTradePct = Num(p); break; case "stakepct": r.StakePct = Num(p); break; case "stakeamount": r.StakeAmount = Num(p); break; - case "maxpositionnotionalpct": r.MaxPositionNotionalPct = Num(p); break; + case "fundingtiltpct": r.FundingTiltPct = Num(p); break; + case "fundingtiltthreshold": r.FundingTiltThreshold = Num(p); break; case "maxgrossexposurepct": r.MaxGrossExposurePct = Num(p); break; - case "maxopenpositions": r.MaxOpenPositions = Int(p); break; + case "maxopenpairs": r.MaxOpenPairs = Int(p); break; case "maxtradesperday": r.MaxTradesPerDay = Int(p); break; - case "maxtradespersymbolperday": r.MaxTradesPerSymbolPerDay = Int(p); break; + case "maxtradesperpairperday": r.MaxTradesPerPairPerDay = Int(p); break; + case "minsecondsbetweenentries": r.MinSecondsBetweenEntries = Int(p); break; case "maxdailylosspct": r.MaxDailyLossPct = Num(p); break; case "maxdailyprofitpct": r.MaxDailyProfitPct = Num(p); break; - case "minsecondsbetweenentries": r.MinSecondsBetweenEntries = Int(p); break; case "maxrelativespread": r.MaxRelativeSpread = Num(p); break; - case "minprice": r.MinPrice = Num(p); break; - case "maxprice": r.MaxPrice = Num(p); break; case "minordernotional": r.MinOrderNotional = Num(p); break; case "maxordernotional": r.MaxOrderNotional = Num(p); break; - case "allowshorting": r.AllowShorting = Bool(p); break; - case "defaultstoppct": r.DefaultStopPct = Num(p); break; - case "maxstopdistancepct": r.MaxStopDistancePct = Num(p); break; - default: warnings.Add($"unknown key 'risk.{p.Name}'"); break; + case "maxmarginratio": r.MaxMarginRatio = Num(p); break; + case "maxhedgeratio": r.MaxHedgeRatio = Num(p); break; + default: warnings.Add($"chiave sconosciuta 'risk.{p.Name}'"); break; } } } @@ -189,52 +196,30 @@ public static class ConfigLoader case "maxfilesizemb": o.MaxFileSizeMb = Int(p); break; case "maxfiles": o.MaxFiles = Int(p); break; case "logmarketdata": o.LogMarketData = Bool(p); break; - case "logeverybar": o.LogEveryBar = Bool(p); break; case "statuslines": o.StatusLines = Int(p); break; case "bufferedlines": o.BufferedLines = Int(p); break; - default: warnings.Add($"unknown key 'logging.{p.Name}'"); break; + default: warnings.Add($"chiave sconosciuta 'logging.{p.Name}'"); break; } } } - private static void ReadUi(BotConfig config, JsonElement e, List warnings) - { - UiOptions o = config.Ui; - foreach (JsonProperty p in Properties(e, "ui", warnings)) - { - switch (p.Name.ToLowerInvariant()) - { - case "url": o.Url = Str(p); break; - case "autostartbot": o.AutoStartBot = Bool(p); break; - case "openbrowser": o.OpenBrowser = Bool(p); break; - default: warnings.Add($"unknown key 'ui.{p.Name}'"); break; - } - } - } - - private static void ReadSymbols(BotConfig config, JsonElement e, List warnings) + private static void ReadPairs(BotConfig config, JsonElement e, List warnings) { if (e.ValueKind != JsonValueKind.Array) { - throw new InvalidOperationException("'symbols' must be an array."); + throw new InvalidOperationException("'pairs' deve essere un array."); } - config.Symbols.Clear(); + config.Pairs.Clear(); foreach (JsonElement item in e.EnumerateArray()) { - if (item.ValueKind == JsonValueKind.String) - { - config.Symbols.Add(new SymbolConfig { Symbol = item.GetString() ?? string.Empty }); - continue; - } - if (item.ValueKind != JsonValueKind.Object) { - warnings.Add("ignoring a non-object entry in 'symbols'"); + warnings.Add("ignoro una voce non-oggetto in 'pairs'"); continue; } - SymbolConfig sc = new(); + PairConfig pair = new(); foreach (JsonProperty p in item.EnumerateObject()) { if (p.Name.StartsWith('_')) @@ -244,71 +229,77 @@ public static class ConfigLoader switch (p.Name.ToLowerInvariant()) { - case "symbol": sc.Symbol = Str(p); break; - case "strategy": sc.Strategy = Str(p); break; - case "enabled": sc.Enabled = Bool(p); break; + case "symbola" or "a": pair.SymbolA = Str(p); break; + case "symbolb" or "b": pair.SymbolB = Str(p); break; + case "enabled": pair.Enabled = Bool(p); break; case "parameters" or "params": - if (p.Value.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty kv in p.Value.EnumerateObject()) - { - if (kv.Name.StartsWith('_')) - { - continue; - } - - sc.Parameters[kv.Name] = kv.Value.ValueKind switch - { - JsonValueKind.Number => kv.Value.GetDouble(), - JsonValueKind.True => 1, - JsonValueKind.False => 0, - JsonValueKind.String when double.TryParse( - kv.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, - out double parsed) => parsed, - _ => 0, - }; - } - } - + ReadParameters(pair, p.Value); break; - default: warnings.Add($"unknown key 'symbols[].{p.Name}'"); break; + default: warnings.Add($"chiave sconosciuta 'pairs[].{p.Name}'"); break; } } - config.Symbols.Add(sc); + config.Pairs.Add(pair); + } + } + + private static void ReadParameters(PairConfig pair, JsonElement value) + { + if (value.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (JsonProperty kv in value.EnumerateObject()) + { + if (kv.Name.StartsWith('_')) + { + continue; + } + + pair.Parameters[kv.Name] = kv.Value.ValueKind switch + { + JsonValueKind.Number => kv.Value.GetDouble(), + JsonValueKind.True => 1, + JsonValueKind.False => 0, + JsonValueKind.String when double.TryParse( + kv.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, + out double parsed) => parsed, + _ => 0, + }; } } private static void ApplyEnvironment(BotConfig config) { - // Alpaca's own variable names, so existing tooling keeps working. bool fromEnvironment = false; - string? key = Environment.GetEnvironmentVariable("APCA_API_KEY_ID"); + // Binance's own conventional variable names, so existing tooling keeps working. + string? key = Environment.GetEnvironmentVariable("BINANCE_API_KEY"); if (!string.IsNullOrWhiteSpace(key)) { - config.Alpaca.KeyId = key.Trim(); + config.Binance.ApiKey = key.Trim(); fromEnvironment = true; } - string? secret = Environment.GetEnvironmentVariable("APCA_API_SECRET_KEY"); + string? secret = Environment.GetEnvironmentVariable("BINANCE_API_SECRET"); if (!string.IsNullOrWhiteSpace(secret)) { - config.Alpaca.SecretKey = secret.Trim(); + config.Binance.ApiSecret = secret.Trim(); fromEnvironment = true; } bool haveBoth = - !string.IsNullOrWhiteSpace(config.Alpaca.KeyId) && - !string.IsNullOrWhiteSpace(config.Alpaca.SecretKey); + !string.IsNullOrWhiteSpace(config.Binance.ApiKey) && + !string.IsNullOrWhiteSpace(config.Binance.ApiSecret); config.CredentialOrigin = haveBoth ? fromEnvironment ? CredentialSource.Environment : CredentialSource.ConfigFile : CredentialSource.None; - if (TryEnvBool("ENCELADO_PAPER", out bool paper)) + if (TryEnvBool("ENCELADO_TESTNET", out bool testnet)) { - config.Alpaca.Paper = paper; + config.Binance.Testnet = testnet; } if (TryEnvBool("ENCELADO_DRY_RUN", out bool dryRun)) @@ -316,12 +307,6 @@ public static class ConfigLoader config.Engine.DryRun = dryRun; } - string? feed = Environment.GetEnvironmentVariable("ENCELADO_DATA_FEED"); - if (!string.IsNullOrWhiteSpace(feed)) - { - config.Alpaca.DataFeed = feed.Trim(); - } - string? level = Environment.GetEnvironmentVariable("ENCELADO_LOG_LEVEL"); if (!string.IsNullOrWhiteSpace(level)) { @@ -347,7 +332,7 @@ public static class ConfigLoader { if (e.ValueKind != JsonValueKind.Object) { - warnings.Add($"'{section}' must be an object; ignored"); + warnings.Add($"'{section}' deve essere un oggetto; ignorata"); yield break; } @@ -376,7 +361,7 @@ public static class ConfigLoader p.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) => d, JsonValueKind.True => 1, JsonValueKind.False => 0, - _ => throw new InvalidOperationException($"'{p.Name}' must be a number."), + _ => throw new InvalidOperationException($"'{p.Name}' deve essere un numero."), }; private static int Int(JsonProperty p) => (int)Math.Round(Num(p)); @@ -387,6 +372,6 @@ public static class ConfigLoader JsonValueKind.False => false, JsonValueKind.Number => p.Value.GetDouble() != 0, JsonValueKind.String => bool.TryParse(p.Value.GetString(), out bool b) && b, - _ => throw new InvalidOperationException($"'{p.Name}' must be a boolean."), + _ => throw new InvalidOperationException($"'{p.Name}' deve essere un booleano."), }; } diff --git a/Encelado/src/Encelado.Bot/Configuration/CredentialResolver.cs b/Encelado/src/Encelado.Bot/Configuration/CredentialResolver.cs index 436bd98..c17d0d2 100644 --- a/Encelado/src/Encelado.Bot/Configuration/CredentialResolver.cs +++ b/Encelado/src/Encelado.Bot/Configuration/CredentialResolver.cs @@ -1,9 +1,9 @@ -using Encelado.Alpaca; -using Encelado.Alpaca.Rest; +using Encelado.Binance; +using Encelado.Binance.Rest; namespace Encelado.Bot.Configuration; -/// Outcome of trying to find usable Alpaca credentials. +/// Outcome of trying to find usable Binance credentials. public readonly record struct CredentialLookup(bool Found, CredentialSource Source, string MaskedKey) { public string Describe() => Source switch @@ -17,8 +17,8 @@ public readonly record struct CredentialLookup(bool Found, CredentialSource Sour } /// -/// Decides which credentials the app should use, and verifies candidates against the -/// broker before they are trusted. Deliberately UI-free: the window owns the dialog, +/// Decides which credentials the app should use, and verifies candidates against +/// Binance before they are trusted. Deliberately UI-free: the window owns the dialog, /// this owns the policy. /// public static class CredentialResolver @@ -35,41 +35,47 @@ public static class CredentialResolver if (config.CredentialOrigin == CredentialSource.Environment) { return new CredentialLookup(true, CredentialSource.Environment, - CredentialStore.Mask(config.Alpaca.KeyId)); + CredentialStore.Mask(config.Binance.ApiKey)); } - if (CredentialStore.Load(config.Alpaca.Paper) is { } saved) + if (CredentialStore.Load(config.Binance.Testnet) is { } saved) { - config.Alpaca.KeyId = saved.KeyId; - config.Alpaca.SecretKey = saved.SecretKey; + config.Binance.ApiKey = saved.ApiKey; + config.Binance.ApiSecret = saved.ApiSecret; config.CredentialOrigin = CredentialSource.SavedStore; - return new CredentialLookup(true, CredentialSource.SavedStore, CredentialStore.Mask(saved.KeyId)); + return new CredentialLookup(true, CredentialSource.SavedStore, CredentialStore.Mask(saved.ApiKey)); } if (config.CredentialOrigin == CredentialSource.ConfigFile) { return new CredentialLookup(true, CredentialSource.ConfigFile, - CredentialStore.Mask(config.Alpaca.KeyId)); + CredentialStore.Mask(config.Binance.ApiKey)); } return new CredentialLookup(false, CredentialSource.None, string.Empty); } /// - /// Confirms a key pair actually works by asking Alpaca for the account. Returns the - /// account on success so the caller can show who just logged in. + /// Confirms a key pair actually works by asking Binance for the futures account. + /// + /// The account endpoint rather than a public one on purpose: it exercises the whole + /// signed path — the key, the secret, the HMAC and the clock — and it is the only + /// thing that proves the key has futures permission enabled, which is a + /// separate checkbox on Binance and the single most common reason a key that looks + /// correct cannot trade. + /// /// - public static async Task<(bool Ok, string Message, AlpacaAccount? Account)> VerifyAsync( - string keyId, - string secretKey, - bool paper, + public static async Task<(bool Ok, string Message, FuturesAccount? Account)> VerifyAsync( + string apiKey, + string apiSecret, + bool testnet, CancellationToken ct) { - AlpacaOptions probe = new() + BinanceOptions probe = new() { - KeyId = keyId, - SecretKey = secretKey, - Paper = paper, + ApiKey = apiKey, + ApiSecret = apiSecret, + Testnet = testnet, HttpTimeout = TimeSpan.FromSeconds(20), MaxRetries = 1, }; @@ -85,38 +91,83 @@ public static class CredentialResolver try { - using AlpacaTradingClient client = new(probe); - AlpacaAccount account = await client.GetAccountAsync(ct).ConfigureAwait(false); - return (true, $"Conto {account.AccountNumber} — {account.Status}", account); - } - catch (AlpacaApiException ex) when (ex.StatusCode is 401 or 403) - { - string hint = paper && keyId.StartsWith("AK", StringComparison.OrdinalIgnoreCase) - ? " Sembra una chiave LIVE ma l'app è impostata su paper." - : !paper && keyId.StartsWith("PK", StringComparison.OrdinalIgnoreCase) - ? " Sembra una chiave PAPER ma l'app è impostata su live." - : string.Empty; + using BinanceFuturesClient client = new(probe); + await client.WarmupAsync(ct).ConfigureAwait(false); - return (false, $"Alpaca ha rifiutato le credenziali (HTTP {ex.StatusCode}).{hint}", null); + FuturesAccount account = await client.GetAccountAsync(ct).ConfigureAwait(false); + + if (!account.CanTrade) + { + return (false, + "Le chiavi sono valide ma Binance dice che il conto non può operare. " + + "Controlla che la chiave abbia il permesso 'Enable Futures' e che non ci sia " + + "una restrizione per indirizzo IP.", account); + } + + return (true, + $"Conto futures verificato — saldo {account.WalletBalance:N2} USDT, " + + $"fee tier {account.FeeTier}", account); + } + catch (BinanceApiException ex) + { + return (false, Explain(ex, testnet), null); } catch (Exception ex) when (ex is not OperationCanceledException) { - return (false, $"Impossibile contattare Alpaca: {ex.Message}", null); + return (false, $"Impossibile contattare Binance: {ex.Message}", null); } } + /// + /// Turns a Binance refusal into something an operator can act on. + /// + /// Binance's own messages are accurate and useless — "Invalid API-key, IP, or + /// permissions for action" covers four unrelated problems with four unrelated fixes. + /// Naming the likely one is the difference between a two-minute fix and an evening. + /// + /// + private static string Explain(BinanceApiException ex, bool testnet) => ex.ErrorCode switch + { + -2015 => + "Binance ha rifiutato la chiave (−2015). Le cause sono tre, in ordine di frequenza: " + + "la chiave non ha il permesso 'Enable Futures'; c'è una restrizione per IP e questo " + + "computer non è nell'elenco; oppure " + + (testnet + ? "stai usando una chiave del conto reale su testnet." + : "stai usando una chiave di testnet sul conto reale."), + + -2014 => + "Il formato della chiave non è valido (−2014). Ricopiala dalla pagina di gestione " + + "API di Binance: spesso è stata troncata o ha raccolto uno spazio invisibile.", + + -1022 or -1021 => + "La firma è stata rifiutata (−1022/−1021). Di solito è il secret sbagliato, oppure " + + "l'orologio del computer è fuori sincronia di più di qualche secondo — Encelado " + + "corregge lo scarto da solo, quindi se l'errore resta è il secret.", + + -4056 or -4055 => + "Il conto futures non risulta attivo su questo profilo Binance. Va aperto una volta " + + "dalla piattaforma prima che le API possano usarlo.", + + _ when ex.StatusCode is 401 or 403 => + $"Binance ha rifiutato le credenziali (HTTP {ex.StatusCode}). Verifica la chiave, il " + + $"secret e l'ambiente selezionato ({(testnet ? "testnet" : "reale")}).", + + _ => $"Binance ha risposto con un errore: {ex.Message}", + }; + /// Stores a verified key pair and points the config at it. - public static void Apply(BotConfig config, string keyId, string secretKey, bool save) + public static void Apply(BotConfig config, string apiKey, string apiSecret, bool save) { ArgumentNullException.ThrowIfNull(config); - config.Alpaca.KeyId = keyId; - config.Alpaca.SecretKey = secretKey; + config.Binance.ApiKey = apiKey; + config.Binance.ApiSecret = apiSecret; config.CredentialOrigin = CredentialSource.Interactive; if (save) { - CredentialStore.Save(config.Alpaca.Paper, keyId, secretKey); + CredentialStore.Save(config.Binance.Testnet, apiKey, apiSecret); config.CredentialOrigin = CredentialSource.SavedStore; } } diff --git a/Encelado/src/Encelado.Bot/Configuration/CredentialStore.cs b/Encelado/src/Encelado.Bot/Configuration/CredentialStore.cs index b423ff3..2d14a95 100644 --- a/Encelado/src/Encelado.Bot/Configuration/CredentialStore.cs +++ b/Encelado/src/Encelado.Bot/Configuration/CredentialStore.cs @@ -4,12 +4,12 @@ using System.Text.Json; namespace Encelado.Bot.Configuration; -/// Credentials plus a human-readable note about where they came from. -public readonly record struct StoredCredentials(string KeyId, string SecretKey); +/// An API key pair as it was saved. +public readonly record struct StoredCredentials(string ApiKey, string ApiSecret); /// -/// Persists Alpaca API keys outside the repository, per user and per environment -/// (paper keys and live keys are different keys, so they are stored separately). +/// Persists Binance API keys outside the repository, per user and per environment +/// (testnet keys and live keys are different keys, so they are stored separately). /// /// On Windows the file is encrypted with DPAPI bound to the current user account: /// another user on the same machine cannot read it, and it needs no passphrase, which @@ -20,7 +20,7 @@ public readonly record struct StoredCredentials(string KeyId, string SecretKey); /// public static class CredentialStore { - private const string PaperKey = "paper"; + private const string TestnetKey = "testnet"; private const string LiveKey = "live"; /// True when the file at rest is encrypted rather than merely permission-restricted. @@ -43,27 +43,27 @@ public static class CredentialStore public static bool Exists => File.Exists(FilePath); /// Reads the credentials saved for the given environment, or null when there are none. - public static StoredCredentials? Load(bool paper) + public static StoredCredentials? Load(bool testnet) { Dictionary all = LoadAll(); - return all.TryGetValue(paper ? PaperKey : LiveKey, out StoredCredentials found) ? found : null; + return all.TryGetValue(testnet ? TestnetKey : LiveKey, out StoredCredentials found) ? found : null; } - public static void Save(bool paper, string keyId, string secretKey) + public static void Save(bool testnet, string apiKey, string apiSecret) { - ArgumentException.ThrowIfNullOrWhiteSpace(keyId); - ArgumentException.ThrowIfNullOrWhiteSpace(secretKey); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + ArgumentException.ThrowIfNullOrWhiteSpace(apiSecret); Dictionary all = LoadAll(); - all[paper ? PaperKey : LiveKey] = new StoredCredentials(keyId.Trim(), secretKey.Trim()); + all[testnet ? TestnetKey : LiveKey] = new StoredCredentials(apiKey.Trim(), apiSecret.Trim()); Write(all); } /// Removes the credentials for one environment. Returns whether anything was removed. - public static bool Clear(bool paper) + public static bool Clear(bool testnet) { Dictionary all = LoadAll(); - if (!all.Remove(paper ? PaperKey : LiveKey)) + if (!all.Remove(testnet ? TestnetKey : LiveKey)) { return false; } @@ -120,23 +120,24 @@ public static class CredentialStore } /// - /// Masks a key for display. Only the first four characters survive — enough to tell - /// a paper key (PK…) from a live one (AK…) and to recognise which key - /// is loaded, without putting anything reusable into a log file that may be shared. + /// Masks a key for display. Only the first six characters survive — enough to + /// recognise which key is loaded when several exist, without putting anything + /// reusable into a log file that may be shared. Binance keys carry no environment + /// prefix, so six is the shortest prefix that reliably distinguishes two of them. /// public static string Mask(string? value) { if (string.IsNullOrEmpty(value)) { - return "(empty)"; + return "(vuota)"; } - if (value.Length <= 4) + if (value.Length <= 6) { return new string('*', value.Length); } - return value[..4] + new string('*', Math.Min(12, value.Length - 4)); + return value[..6] + new string('*', Math.Min(12, value.Length - 6)); } private static Dictionary LoadAll() @@ -178,12 +179,12 @@ public static class CredentialStore using JsonDocument doc = JsonDocument.Parse(plaintext); foreach (JsonProperty entry in doc.RootElement.EnumerateObject()) { - string? keyId = entry.Value.TryGetProperty("keyId", out JsonElement k) ? k.GetString() : null; - string? secret = entry.Value.TryGetProperty("secretKey", out JsonElement s) ? s.GetString() : null; + string? apiKey = entry.Value.TryGetProperty("apiKey", out JsonElement k) ? k.GetString() : null; + string? secret = entry.Value.TryGetProperty("apiSecret", out JsonElement s) ? s.GetString() : null; - if (!string.IsNullOrWhiteSpace(keyId) && !string.IsNullOrWhiteSpace(secret)) + if (!string.IsNullOrWhiteSpace(apiKey) && !string.IsNullOrWhiteSpace(secret)) { - result[entry.Name] = new StoredCredentials(keyId, secret); + result[entry.Name] = new StoredCredentials(apiKey, secret); } } } @@ -208,8 +209,8 @@ public static class CredentialStore foreach ((string environment, StoredCredentials credentials) in all) { w.WriteStartObject(environment); - w.WriteString("keyId", credentials.KeyId); - w.WriteString("secretKey", credentials.SecretKey); + w.WriteString("apiKey", credentials.ApiKey); + w.WriteString("apiSecret", credentials.ApiSecret); w.WriteEndObject(); } diff --git a/Encelado/src/Encelado.Bot/Diagnostics/AnalyticsLog.cs b/Encelado/src/Encelado.Bot/Diagnostics/AnalyticsLog.cs index 97e3cae..21b4de8 100644 --- a/Encelado/src/Encelado.Bot/Diagnostics/AnalyticsLog.cs +++ b/Encelado/src/Encelado.Bot/Diagnostics/AnalyticsLog.cs @@ -3,8 +3,7 @@ using System.Text; using Encelado.Bot.Configuration; using Encelado.Bot.Logging; using Encelado.Core.Market; -using Encelado.Core.Portfolio; -using Encelado.Core.Strategies; +using Encelado.Core.Strategies.Pairs; namespace Encelado.Bot.Diagnostics; @@ -14,13 +13,12 @@ namespace Encelado.Bot.Diagnostics; /// Two CSV files, joined on decisionId: /// /// -/// decisions — one row per evaluated bar per symbol: the bar itself -/// including the aggressor breakdown, every indicator the strategy publishes, the -/// position at the time, and the signal that came out. This is the dataset to load -/// into pandas when asking "why did it do that" or "would a different threshold have -/// helped". +/// decisions — one row per aligned bar per pair: both closes, the spread, +/// the z-score, the calibration that produced them, the position at the time, and the +/// signal that came out. This is the dataset to load into pandas when asking "why did it +/// do that" or "would a different entry threshold have helped". /// executions — one row per signal that reached the order path: the risk -/// verdict, the size that survived it, and the broker's answer. +/// verdict, the size that survived it, and the exchange's answer for each leg. /// /// /// CSV rather than JSON on purpose: it opens in Excel, loads in one line of pandas, and @@ -30,13 +28,25 @@ namespace Encelado.Bot.Diagnostics; /// public sealed class AnalyticsLog : IDisposable { + private const string DecisionHeader = + "barTimeUtc,decisionId,pair,symbolA,symbolB,ready," + + "closeA,closeB,volumeA,volumeB,deltaA,deltaB," + + "spread,zScore,beta,alpha,pValue,adf,critical5,halfLife,cointegrated," + + "qtyA,qtyB,entryZ,barsHeld,signal,reason," + + "bidA,askA,spreadPctA,bidB,askB,spreadPctB,quoteAgeSec," + + "fundingA,fundingB,equity,halted"; + + private const string ExecutionHeader = + "timestampUtc,decisionId,pair,phase,approved,reason,detail," + + "sideA,notionalA,quantityA,priceA,sideB,notionalB,quantityB,priceB," + + "netFundingRate,equity,availableBalance,grossExposure,openPairs," + + "orderIdA,orderIdB,error,latencyMs"; + private readonly StreamWriter? _decisions; private readonly StreamWriter? _executions; private readonly Lock _gate = new(); - private readonly StringBuilder _row = new(512); + private readonly StringBuilder _row = new(768); - private string[] _metricNames = []; - private bool _decisionHeaderWritten; private long _nextId; private DateTime _lastFlush = DateTime.UtcNow; @@ -47,108 +57,95 @@ public sealed class AnalyticsLog : IDisposable _decisions = Open(options.ResolvePath(options.DecisionLog)); _executions = Open(options.ResolvePath(options.ExecutionLog)); + // Headers are fixed, so they can be written up front rather than deferred until + // the first row — which is what forced the old file to guess at its own columns. + if (_decisions is not null && _decisions.BaseStream.Length == 0) + { + _decisions.WriteLine(DecisionHeader); + } + if (_executions is not null && _executions.BaseStream.Length == 0) { - _executions.WriteLine( - "timestampUtc,decisionId,symbol,side,phase,approved,riskReason,riskDetail," + - "quantity,referencePrice,stopPrice,targetPrice,notional,equity,buyingPower," + - "grossExposure,openPositions,orderId,error,latencyMs"); + _executions.WriteLine(ExecutionHeader); } } public bool IsEnabled => _decisions is not null || _executions is not null; - public string? DecisionPath { get; private init; } - - /// Allocates the id that ties a decision row to its execution row. + /// Allocates the id that ties a decision row to its execution rows. public long NextDecisionId() => Interlocked.Increment(ref _nextId); - /// - /// Records one bar evaluation. Called on the market-data thread once per closed - /// bar per symbol — a handful of times a day on this configuration, so the cost is - /// irrelevant, but it stays buffered anyway. - /// + /// Records one aligned-bar evaluation. Called on the market-data thread. public void Decision( long decisionId, - string symbol, - in Bar bar, - IStrategy strategy, - in PositionView position, - in Signal signal, - in Quote quote, + string pair, + in Bar barA, + in Bar barB, + LegSnapshot legA, + LegSnapshot legB, + StatArbStrategy strategy, + in PairPositionView position, + in PairSignal signal, double quoteAgeSeconds, double equity, - bool sessionOpen, bool halted) { + ArgumentNullException.ThrowIfNull(strategy); + if (_decisions is null) { return; } - IReadOnlyList metrics = strategy.Diagnostics; + PairCalibration c = strategy.Calibration; lock (_gate) { - if (!_decisionHeaderWritten) - { - WriteDecisionHeader(metrics); - } - _row.Clear(); - Add(bar.TimeUtc.ToString("O", CultureInfo.InvariantCulture)); + Add(barA.TimeUtc.ToString("O", CultureInfo.InvariantCulture)); Add(decisionId); - Add(symbol); - Add(strategy.Name); + Add(pair); + Add(legA.Symbol); + Add(legB.Symbol); Add(strategy.IsReady ? 1 : 0); - Add(bar.Open); - Add(bar.High); - Add(bar.Low); - Add(bar.Close); - Add(bar.Volume); - Add(bar.TakerBuyVolume); - Add(bar.Delta); - Add(bar.TradeCount); + Add(barA.Close); + Add(barB.Close); + Add(barA.Volume); + Add(barB.Volume); + Add(barA.Delta); + Add(barB.Delta); - // Indicator values, in the same order the header declared. - foreach (string name in _metricNames) - { - double value = 0; - foreach (StrategyMetric m in metrics) - { - if (m.Name == name) - { - value = double.IsFinite(m.Value) ? m.Value : 0; - break; - } - } + Add(strategy.Spread); + Add(strategy.ZScore); + Add(c.Beta); + Add(c.Alpha); + Add(c.PValue); + Add(c.AdfStatistic); + Add(c.CriticalValue5); + Add(c.HalfLifeBars); + Add(c.IsCointegrated ? 1 : 0); - Add(value); - } - - Add(position.Quantity); - Add(position.AverageEntryPrice); - Add(position.UnrealizedPnl); + Add(position.QuantityA); + Add(position.QuantityB); + Add(position.EntryZScore); Add(position.BarsHeld); - Add(position.StopPrice); - Add(position.TargetPrice); - Add(signal.Kind.ToString()); - Add(signal.Strength); - Add(signal.StopPrice); - Add(signal.TargetPrice); + Add(signal.Reason); - Add(quote.IsValid ? quote.BidPrice : 0); - Add(quote.IsValid ? quote.AskPrice : 0); - Add(quote.IsValid ? quote.RelativeSpread : 0); + Add(legA.Bid); + Add(legA.Ask); + Add(legA.SpreadPct); + Add(legB.Bid); + Add(legB.Ask); + Add(legB.SpreadPct); Add(quoteAgeSeconds); + Add(legA.FundingRate); + Add(legB.FundingRate); Add(equity); - Add(sessionOpen ? 1 : 0); - Add(halted ? 1 : 0); - Add(signal.Reason, last: true); + Add(halted ? 1 : 0, last: true); _decisions.WriteLine(_row.ToString()); MaybeFlush(); @@ -156,25 +153,7 @@ public sealed class AnalyticsLog : IDisposable } /// Records what the order path did with a signal. - public void Execution( - long decisionId, - string symbol, - Side side, - string phase, - bool approved, - string riskReason, - string riskDetail, - double quantity, - double referencePrice, - double stopPrice, - double targetPrice, - double equity, - double buyingPower, - double grossExposure, - int openPositions, - string? orderId, - string? error, - double latencyMs) + public void Execution(in ExecutionRecord record) { if (_executions is null) { @@ -186,64 +165,43 @@ public sealed class AnalyticsLog : IDisposable _row.Clear(); Add(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); - Add(decisionId); - Add(symbol); - Add(side.ToString()); - Add(phase); - Add(approved ? 1 : 0); - Add(riskReason); - Add(riskDetail); - Add(quantity); - Add(referencePrice); - Add(stopPrice); - Add(targetPrice); - Add(quantity * referencePrice); - Add(equity); - Add(buyingPower); - Add(grossExposure); - Add(openPositions); - Add(orderId ?? string.Empty); - Add(error ?? string.Empty); - Add(latencyMs, last: true); + Add(record.DecisionId); + Add(record.Pair); + Add(record.Phase); + Add(record.Approved ? 1 : 0); + Add(record.Reason); + Add(record.Detail); + + Add(Describe(record.SideA)); + Add(record.NotionalA); + Add(record.QuantityA); + Add(record.PriceA); + Add(Describe(record.SideB)); + Add(record.NotionalB); + Add(record.QuantityB); + Add(record.PriceB); + + Add(record.NetFundingRate); + Add(record.Equity); + Add(record.AvailableBalance); + Add(record.GrossExposure); + Add(record.OpenPairs); + Add(record.OrderIdA); + Add(record.OrderIdB); + Add(record.Error); + Add(record.LatencyMs, last: true); _executions.WriteLine(_row.ToString()); MaybeFlush(); } } - private void WriteDecisionHeader(IReadOnlyList metrics) + private static string Describe(Side side) => side switch { - string[] names = new string[metrics.Count]; - for (int i = 0; i < metrics.Count; i++) - { - names[i] = metrics[i].Name; - } - - _metricNames = names; - _decisionHeaderWritten = true; - - if (_decisions!.BaseStream.Length > 0) - { - // Appending to an existing file: keep its header rather than writing a - // second one in the middle. - return; - } - - StringBuilder header = new(400); - header.Append("barTimeUtc,decisionId,symbol,strategy,ready,") - .Append("open,high,low,close,volume,takerBuyVolume,delta,trades,"); - - foreach (string name in names) - { - header.Append(name).Append(','); - } - - header.Append("positionQty,positionEntry,positionPnl,barsHeld,positionStop,positionTarget,") - .Append("signal,signalStrength,signalStop,signalTarget,") - .Append("bid,ask,spreadPct,quoteAgeSec,equity,sessionOpen,halted,reason"); - - _decisions.WriteLine(header.ToString()); - } + Side.Buy => "BUY", + Side.Sell => "SELL", + _ => string.Empty, + }; private void Add(double value, bool last = false) { @@ -310,7 +268,7 @@ public sealed class AnalyticsLog : IDisposable } catch (IOException ex) { - Log.Warn($"analytics flush failed: {ex.Message}"); + Log.Warn($"scrittura analytics fallita: {ex.Message}"); } } } @@ -332,7 +290,7 @@ public sealed class AnalyticsLog : IDisposable } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - Log.Warn($"cannot open analytics file {path}: {ex.Message}"); + Log.Warn($"impossibile aprire il file di analisi {path}: {ex.Message}"); return null; } } @@ -356,3 +314,66 @@ public sealed class AnalyticsLog : IDisposable } } } + +/// One leg's market state at the moment of a decision, for the decision log. +public readonly record struct LegSnapshot( + string Symbol, + double Bid, + double Ask, + double SpreadPct, + double FundingRate); + +/// +/// One row of the execution log. A record rather than twenty positional parameters: +/// the previous signature had eighteen, and a call site that swaps two of them compiles +/// silently and corrupts the dataset the strategy is tuned on. +/// +public readonly record struct ExecutionRecord +{ + public required long DecisionId { get; init; } + + public required string Pair { get; init; } + + /// suppressed, risk, order or exit. + public required string Phase { get; init; } + + public required bool Approved { get; init; } + + public required string Reason { get; init; } + + public required string Detail { get; init; } + + public Side SideA { get; init; } + + public Side SideB { get; init; } + + public double NotionalA { get; init; } + + public double NotionalB { get; init; } + + public double QuantityA { get; init; } + + public double QuantityB { get; init; } + + public double PriceA { get; init; } + + public double PriceB { get; init; } + + public double NetFundingRate { get; init; } + + public double Equity { get; init; } + + public double AvailableBalance { get; init; } + + public double GrossExposure { get; init; } + + public int OpenPairs { get; init; } + + public string OrderIdA { get; init; } + + public string OrderIdB { get; init; } + + public string Error { get; init; } + + public double LatencyMs { get; init; } +} diff --git a/Encelado/src/Encelado.Bot/Encelado.Bot.csproj b/Encelado/src/Encelado.Bot/Encelado.Bot.csproj index 5dc8bc9..614d792 100644 --- a/Encelado/src/Encelado.Bot/Encelado.Bot.csproj +++ b/Encelado/src/Encelado.Bot/Encelado.Bot.csproj @@ -34,7 +34,7 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Encelado/src/Encelado.Bot/Ui/Pages/AccountPage.xaml.cs b/Encelado/src/Encelado.Bot/Ui/Pages/AccountPage.xaml.cs deleted file mode 100644 index 49cf6e9..0000000 --- a/Encelado/src/Encelado.Bot/Ui/Pages/AccountPage.xaml.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Windows.Controls; -using Encelado.Core.Risk; - -namespace Encelado.Bot.Ui.Pages; - -/// -/// The broker's view of the account, plus the limits the bot imposes on top of it. -/// Both are shown because they answer different questions: the broker says what is -/// possible, the configuration says what is permitted. -/// -public partial class AccountPage : UserControl -{ - public AccountPage() => InitializeComponent(); - - /// - /// Set once by the shell. The sizing rule is described from the configuration - /// rather than bound, because it is one sentence assembled from three fields. - /// - public void Describe(RiskLimits limits) - { - ArgumentNullException.ThrowIfNull(limits); - SizingText.Text = limits.DescribeSizing(); - } -} diff --git a/Encelado/src/Encelado.Bot/Ui/Pages/ChartsPage.xaml b/Encelado/src/Encelado.Bot/Ui/Pages/ChartsPage.xaml deleted file mode 100644 index faf81cc..0000000 --- a/Encelado/src/Encelado.Bot/Ui/Pages/ChartsPage.xaml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -