Passa a Binance Futures con arbitraggio statistico su coppie cointegrate
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Encelado.Alpaca/Encelado.Alpaca.csproj" />
|
||||
<Project Path="src/Encelado.Binance/Encelado.Binance.csproj" />
|
||||
<Project Path="src/Encelado.Bot/Encelado.Bot.csproj" />
|
||||
<Project Path="src/Encelado.Core/Encelado.Core.csproj" />
|
||||
</Folder>
|
||||
|
||||
+246
-2
@@ -1,2 +1,246 @@
|
||||
Altre cose da fare:
|
||||
-
|
||||
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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+23
-10
@@ -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 @@
|
||||
|
||||
<!--
|
||||
Il corrispettivo del backtest sui dossier di AutoBidder. Qui non si rigiocano
|
||||
aste ma serie storiche di prezzi, con lo strumento in tools/Encelado.Backtest.
|
||||
aste ma coppie di serie storiche di prezzi, con lo strumento in
|
||||
tools/Encelado.Backtest.
|
||||
|
||||
I parametri li legge da config/encelado.json, non li ridichiara: il banco e
|
||||
il bot non possono divergere senza che qualcuno se ne accorga.
|
||||
── Perché una CARTELLA e non un file ─────────────────────────────────────
|
||||
Una coppia ha due gambe, quindi servono due file. Lo strumento li cerca come
|
||||
<SIMBOLO>.csv dentro la cartella indicata da -p:Dati=, così aggiungere una
|
||||
coppia non richiede di cambiare la riga di comando.
|
||||
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="C:\dati\Binance"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=basket
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=run -p:Extra="--a ETHUSDT --b BTCUSDT"
|
||||
|
||||
Comandi: run, pairs, explore, sweep, confirm, basket.
|
||||
'basket' è quello che sceglie i valori di fabbrica: griglia su tutte le
|
||||
coppie disponibili, classifica sulla fetta di verifica e riporta la fetta di
|
||||
conferma, che non entra mai nella scelta.
|
||||
-->
|
||||
<Target Name="Backtest">
|
||||
<PropertyGroup>
|
||||
<Comando Condition="'$(Comando)' == ''">split</Comando>
|
||||
<Barre Condition="'$(Barre)' == ''">1d</Barre>
|
||||
<Comando Condition="'$(Comando)' == ''">pairs</Comando>
|
||||
<Barre Condition="'$(Barre)' == ''">15m</Barre>
|
||||
<BacktestExe>$(Radice)\tools\Encelado.Backtest\bin\Release\net10.0\backtest.exe</BacktestExe>
|
||||
</PropertyGroup>
|
||||
|
||||
<Error Condition="'$(Dati)' == ''"
|
||||
Text="Serve un file di dati: -p:Dati="C:\percorso\btcusd.csv".%0AComandi disponibili in -p:Comando= : split, sweep, walk, frequency, costs, explore." />
|
||||
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." />
|
||||
|
||||
<Error Condition="!Exists('$(Dati)')" Text="File di dati non trovato: $(Dati)" />
|
||||
<Error Condition="!Exists('$(Dati)')" Text="Cartella dati non trovata: $(Dati)" />
|
||||
|
||||
<Message Importance="High" Text="== Rigiocata sui prezzi ==" />
|
||||
<Message Importance="High" Text="== Rigiocata sulle coppie ==" />
|
||||
<Message Importance="High" Text=" dati : $(Dati)" />
|
||||
<Message Importance="High" Text=" comando : $(Comando) su barre da $(Barre)" />
|
||||
|
||||
@@ -443,7 +456,7 @@
|
||||
Command="dotnet build "$(BacktestProj)" -c Release --nologo -v q" />
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)"
|
||||
Command=""$(BacktestExe)" $(Comando) --file "$(Dati)" --tf $(Barre) $(Extra)" />
|
||||
Command=""$(BacktestExe)" $(Comando) --data "$(Dati)" --tf $(Barre) $(Extra)" />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Eseguibile ═════════════════════ -->
|
||||
|
||||
+141
-86
@@ -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 <cartella>' 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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Alpaca;
|
||||
|
||||
/// <summary>Connection settings for every Alpaca endpoint the bot talks to.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Paper trading is the default. Flipping this to <see langword="false"/> risks real money.</summary>
|
||||
public bool Paper { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Equity data feed: <c>iex</c> (free), <c>sip</c> (full tape, paid),
|
||||
/// <c>delayed_sip</c>, or <c>test</c> (Alpaca's synthetic FAKEPACA stream).
|
||||
/// </summary>
|
||||
public string DataFeed { get; set; } = "iex";
|
||||
|
||||
/// <summary>Overrides the trading REST base URL. Leave empty to derive it from <see cref="Paper"/>.</summary>
|
||||
public string TradingBaseUrlOverride { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Overrides the market-data REST base URL.</summary>
|
||||
public string DataBaseUrlOverride { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Client-side throttle. Alpaca allows 200 requests/minute per account on the basic plan.</summary>
|
||||
public int RequestsPerMinute { get; set; } = 180;
|
||||
|
||||
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Number of retries for transient failures (429 / 5xx / socket errors).</summary>
|
||||
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('/');
|
||||
|
||||
/// <summary>Order/position event stream. Lives on the trading host, not the data host.</summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised when Alpaca answers with a non-success status or an unusable payload.</summary>
|
||||
public sealed class AlpacaApiException(string message, int statusCode = 0, string? body = null)
|
||||
: Exception(message)
|
||||
{
|
||||
public int StatusCode { get; } = statusCode;
|
||||
|
||||
public string? Body { get; } = body;
|
||||
|
||||
/// <summary>Transient conditions worth retrying.</summary>
|
||||
public bool IsTransient => StatusCode is 429 or >= 500;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Alpaca.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Reading helpers for Alpaca's REST payloads. Alpaca encodes most numeric fields as
|
||||
/// JSON <i>strings</i> (<c>"qty": "10"</c>), and omits or nulls fields liberally, so
|
||||
/// every accessor tolerates both shapes and a missing property.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Alpaca.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-rolled RFC3339 parser for the shape Alpaca actually emits
|
||||
/// (<c>2024-05-17T13:04:56.334262119Z</c>). 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 <see cref="DateTime.TryParse(ReadOnlySpan{char}, IFormatProvider, DateTimeStyles, out DateTime)"/>
|
||||
/// for anything unusual (offsets, missing fractions, non-UTC).
|
||||
/// </summary>
|
||||
public static class Rfc3339
|
||||
{
|
||||
/// <summary>Parses a UTC timestamp from UTF-8 bytes. Returns <see cref="DateTime.MinValue"/> on failure.</summary>
|
||||
public static DateTime ParseUtc(ReadOnlySpan<byte> 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<byte> utf8)
|
||||
{
|
||||
Span<char> 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<byte> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Historical and latest-snapshot market data. Used to warm indicators up before the
|
||||
/// live stream takes over, and by the replay/backtest mode.
|
||||
/// </summary>
|
||||
public sealed class AlpacaDataClient(AlpacaOptions options) : IDisposable
|
||||
{
|
||||
private readonly AlpacaHttp _http = new(options.Validate(), options.DataBaseUrl);
|
||||
private readonly string _feed = options.DataFeed;
|
||||
|
||||
/// <summary>Alpaca caps a single bars page at 10 000 rows.</summary>
|
||||
private const int PageLimit = 10_000;
|
||||
|
||||
public Task WarmupAsync(CancellationToken ct) =>
|
||||
_http.WarmupAsync("v2/stocks/bars?symbols=SPY&timeframe=1Day&limit=1", ct);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches historical bars for one or more symbols in chronological order,
|
||||
/// following pagination across the whole requested range.
|
||||
/// <para>
|
||||
/// <paramref name="maxBarsPerSymbol"/> keeps the <b>most recent</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<Dictionary<string, List<Bar>>> GetBarsAsync(
|
||||
IReadOnlyList<string> symbols,
|
||||
TimeFrame timeFrame,
|
||||
DateTime startUtc,
|
||||
DateTime? endUtc,
|
||||
AssetClass assetClass,
|
||||
int maxBarsPerSymbol,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
Dictionary<string, List<Bar>> 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<Bar>? list))
|
||||
{
|
||||
list = new List<Bar>(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<Bar> bars = result[key];
|
||||
if (bars.Count > maxBarsPerSymbol)
|
||||
{
|
||||
result[key] = bars.GetRange(bars.Count - maxBarsPerSymbol, maxBarsPerSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, Quote>> GetLatestQuotesAsync(
|
||||
IReadOnlyList<string> symbols,
|
||||
AssetClass assetClass,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, Quote> 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<Dictionary<string, Tick>> GetLatestTradesAsync(
|
||||
IReadOnlyList<string> symbols,
|
||||
AssetClass assetClass,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, Tick> 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();
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Alpaca.Rest;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the TLS connection ahead of the first real request so the first order
|
||||
/// does not pay for the handshake.
|
||||
/// </summary>
|
||||
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<JsonDocument> GetAsync(string path, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Get, path, null, ct);
|
||||
|
||||
public Task<JsonDocument> PostAsync(string path, ReadOnlyMemory<byte> json, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Post, path, json, ct);
|
||||
|
||||
public Task<JsonDocument> PatchAsync(string path, ReadOnlyMemory<byte> json, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Patch, path, json, ct);
|
||||
|
||||
public Task<JsonDocument> DeleteAsync(string path, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Delete, path, null, ct);
|
||||
|
||||
/// <summary>Like <see cref="GetAsync"/> but maps HTTP 404 to <see langword="null"/>.</summary>
|
||||
public async Task<JsonDocument?> 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<JsonDocument> SendAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
ReadOnlyMemory<byte>? 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/// <summary>True once the order can no longer change state.</summary>
|
||||
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)
|
||||
{
|
||||
/// <summary>True when the broker will refuse new orders for any reason.</summary>
|
||||
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<AlpacaOrder> 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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Account equity over time, straight from Alpaca. <see cref="BaseValue"/> is the
|
||||
/// equity at the start of the requested window, so lifetime P&L is
|
||||
/// <c>Equity[^1] - BaseValue</c> — with the usual caveat that deposits and withdrawals
|
||||
/// move equity without being profit.
|
||||
/// </summary>
|
||||
public sealed record AlpacaPortfolioHistory(
|
||||
IReadOnlyList<long> TimestampsUnix,
|
||||
IReadOnlyList<double> Equity,
|
||||
IReadOnlyList<double> 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;
|
||||
|
||||
/// <summary>Change over the whole window in absolute terms.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An order about to be submitted. Built by the execution router, never by strategies.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Idempotency key. Alpaca rejects duplicates, which is exactly what we want on a retry.</summary>
|
||||
public string? ClientOrderId { get; init; }
|
||||
|
||||
public bool ExtendedHours { get; init; }
|
||||
|
||||
/// <summary>Attached protective stop. Turns the order into a bracket/OTO order.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Alpaca's <c>order_class</c> implied by the attached legs.</summary>
|
||||
public string OrderClass =>
|
||||
!double.IsNaN(TakeProfitLimitPrice) && !double.IsNaN(StopLossStopPrice) ? "bracket"
|
||||
: !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice) ? "oto"
|
||||
: "simple";
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Alpaca.Rest;
|
||||
|
||||
/// <summary>
|
||||
/// Typed wrapper over Alpaca's trading REST API (<c>/v2/account</c>, <c>/v2/orders</c>,
|
||||
/// <c>/v2/positions</c>, …). Request bodies are written straight to UTF-8 with
|
||||
/// <see cref="Utf8JsonWriter"/> — no serializer, no reflection, no per-order allocation
|
||||
/// beyond a pooled buffer.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Opens the TLS/HTTP2 connection before the session starts.</summary>
|
||||
public Task WarmupAsync(CancellationToken ct) => _http.WarmupAsync("v2/clock", ct);
|
||||
|
||||
public async Task<AlpacaAccount> GetAccountAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument doc = await _http.GetAsync("v2/account", ct).ConfigureAwait(false);
|
||||
return AlpacaAccount.FromJson(doc.RootElement);
|
||||
}
|
||||
|
||||
public async Task<AlpacaClock> GetClockAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument doc = await _http.GetAsync("v2/clock", ct).ConfigureAwait(false);
|
||||
return AlpacaClock.FromJson(doc.RootElement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equity curve for the account. <paramref name="period"/> uses Alpaca's notation
|
||||
/// (<c>1D</c>, <c>1M</c>, <c>1A</c>, <c>all</c>) and <paramref name="timeframe"/> the
|
||||
/// bucket size (<c>1Min</c>, <c>15Min</c>, <c>1H</c>, <c>1D</c>).
|
||||
/// </summary>
|
||||
public async Task<AlpacaPortfolioHistory> 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<AlpacaAsset?> 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<List<AlpacaPosition>> ListPositionsAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument doc = await _http.GetAsync("v2/positions", ct).ConfigureAwait(false);
|
||||
List<AlpacaPosition> positions = [];
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement e in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
positions.Add(AlpacaPosition.FromJson(e));
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
public async Task<AlpacaPosition?> 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);
|
||||
}
|
||||
|
||||
/// <summary>Liquidates a position at market. Alpaca cancels the open legs for us.</summary>
|
||||
public async Task<AlpacaOrder?> 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<List<AlpacaOrder>> 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<AlpacaOrder> orders = [];
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement e in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
orders.Add(AlpacaOrder.FromJson(e));
|
||||
}
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
public Task<List<AlpacaOrder>> ListOpenOrdersAsync(CancellationToken ct) =>
|
||||
ListOrdersAsync("open", 500, null, ct);
|
||||
|
||||
public async Task<AlpacaOrder> 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);
|
||||
}
|
||||
|
||||
/// <summary>Moves an open order's stop/limit — used to trail protective stops.</summary>
|
||||
public async Task<AlpacaOrder> ReplaceOrderAsync(
|
||||
string orderId,
|
||||
double? quantity,
|
||||
double? limitPrice,
|
||||
double? stopPrice,
|
||||
string? clientOrderId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>Serialises an order to Alpaca's wire format. Public so it can be asserted on in tests.</summary>
|
||||
public static byte[] WriteOrderJson(NewOrder order)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alpaca rejects prices that are not a valid sub-penny increment: two decimals at
|
||||
/// or above $1, four decimals below it.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Whole shares stay integral; fractional sizes get at most 9 decimals.</summary>
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Which side crossed the spread on a print. Unknown when the feed omits it.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Alpaca's real-time market data socket. Frames are decoded straight out of the
|
||||
/// receive buffer with <see cref="Utf8JsonReader"/> and symbols are resolved through
|
||||
/// a <see cref="SymbolTable"/>, so a live tape produces no garbage per tick.
|
||||
/// </summary>
|
||||
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<string> 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;
|
||||
|
||||
/// <summary>Fired for every print on the tape.</summary>
|
||||
public TickHandler? OnTrade { get; set; }
|
||||
|
||||
/// <summary>Fired on every top-of-book change.</summary>
|
||||
public QuoteHandler? OnQuote { get; set; }
|
||||
|
||||
/// <summary>Fired when a minute bar closes — the engine's main decision trigger.</summary>
|
||||
public BarHandler? OnBar { get; set; }
|
||||
|
||||
/// <summary>Fired for Alpaca's rolling daily bar.</summary>
|
||||
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<byte> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns an Alpaca stream error into either a refusal or a note.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<byte> 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<byte> 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<string> symbols, bool trades, bool quotes, bool bars)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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<string> values)
|
||||
{
|
||||
w.WriteStartArray(name);
|
||||
foreach (string v in values)
|
||||
{
|
||||
w.WriteStringValue(v);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>One order lifecycle event pushed by Alpaca.</summary>
|
||||
public sealed record TradeUpdate(
|
||||
string Event,
|
||||
DateTime TimestampUtc,
|
||||
string Symbol,
|
||||
Side Side,
|
||||
double Price,
|
||||
double Quantity,
|
||||
double PositionQuantity,
|
||||
AlpacaOrder Order)
|
||||
{
|
||||
/// <summary>True when shares actually changed hands.</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Raised for every order lifecycle event. Runs on the receive thread.</summary>
|
||||
public Action<TradeUpdate>? 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<byte> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>{"action":"authenticate","data":{"key_id":…,"secret_key":…}}</c></summary>
|
||||
private static byte[] BuildEnvelopeAuth(string key, string secret)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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();
|
||||
}
|
||||
|
||||
/// <summary><c>{"action":"auth","key":…,"secret":…}</c></summary>
|
||||
private static byte[] BuildFlatAuth(string key, string secret)
|
||||
{
|
||||
ArrayBufferWriter<byte> 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<byte> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
namespace Encelado.Binance;
|
||||
|
||||
/// <summary>
|
||||
/// Connection settings for the Binance USDⓈ-M futures endpoints the bot talks to.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Testnet is the default, and the analogue of the old paper flag: same API, same
|
||||
/// order types, fake money. Flipping it to <see langword="false"/> risks real funds.
|
||||
/// </summary>
|
||||
public bool Testnet { get; set; } = true;
|
||||
|
||||
/// <summary>Overrides the REST base URL. Empty derives it from <see cref="Testnet"/>.</summary>
|
||||
public string RestBaseUrlOverride { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Overrides the websocket base URL.</summary>
|
||||
public string StreamBaseUrlOverride { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int RecvWindowMs { get; set; } = 5_000;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int RequestsPerMinute { get; set; } = 1_200;
|
||||
|
||||
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Retries for transient failures (429 / 418 / 5xx / socket errors).</summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int Leverage { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// <c>CROSSED</c> or <c>ISOLATED</c>. Crossed is what a hedged pair wants: the two
|
||||
/// legs offset inside one margin pool instead of each carrying its own liquidation
|
||||
/// price.
|
||||
/// </summary>
|
||||
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('/');
|
||||
|
||||
/// <summary>The combined-stream endpoint, which multiplexes every subscription onto one socket.</summary>
|
||||
public Uri CombinedStreamUri(string streams) =>
|
||||
new($"{StreamBaseUrl}/stream?streams={streams}");
|
||||
|
||||
/// <summary>The single-stream endpoint used by the user-data (listen key) socket.</summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised when Binance answers with a non-success status or an unusable payload.</summary>
|
||||
public sealed class BinanceApiException(string message, int statusCode = 0, int errorCode = 0, string? body = null)
|
||||
: Exception(message)
|
||||
{
|
||||
public int StatusCode { get; } = statusCode;
|
||||
|
||||
/// <summary>Binance's own error code, e.g. <c>-2019</c> for insufficient margin. 0 when absent.</summary>
|
||||
public int ErrorCode { get; } = errorCode;
|
||||
|
||||
public string? Body { get; } = body;
|
||||
|
||||
/// <summary>Transient conditions worth retrying. 418 is an IP ban after repeated 429s.</summary>
|
||||
public bool IsTransient => StatusCode is 429 or 418 or >= 500;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsClockSkew => ErrorCode is -1021 or -1022;
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Encelado.Alpaca</RootNamespace>
|
||||
<AssemblyName>Encelado.Alpaca</AssemblyName>
|
||||
<RootNamespace>Encelado.Binance</RootNamespace>
|
||||
<AssemblyName>Encelado.Binance</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Binance.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Reading helpers for Binance's REST and websocket payloads. Binance encodes every
|
||||
/// price and quantity as a JSON <i>string</i> (<c>"price": "42123.50"</c>) but counters
|
||||
/// and timestamps as numbers, and omits fields liberally, so every accessor tolerates
|
||||
/// both shapes and a missing property.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Binance timestamps are milliseconds since the Unix epoch, always UTC.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Parses a Binance numeric string (never culture dependent).</summary>
|
||||
public static double ParseDouble(string? text) =>
|
||||
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double d) ? d : 0;
|
||||
|
||||
/// <summary>Reads element <paramref name="index"/> of a kline array as a double.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the bot asks of Binance USDⓈ-M futures over REST: the account, positions,
|
||||
/// klines, funding, symbol rules, and the orders themselves.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BinanceFuturesClient : IDisposable
|
||||
{
|
||||
private readonly BinanceHttp _http;
|
||||
private readonly Dictionary<string, SymbolFilters> _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<FuturesAccount> 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<List<FuturesPosition>> ListPositionsAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument doc = await _http.GetSignedAsync("fapi/v2/positionRisk", string.Empty, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
List<FuturesPosition> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets cross or isolated margin for one symbol.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Historical klines for one symbol, oldest first.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<List<Bar>> 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<Bar> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one kline array. Binance's layout is positional:
|
||||
/// <c>[openTime, open, high, low, close, volume, closeTime, quoteVolume, trades,
|
||||
/// takerBuyBase, takerBuyQuote, ignore]</c>.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Mark price and funding for one symbol.</summary>
|
||||
public async Task<FundingInfo> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exchange rules for one symbol. Falls back to conservative defaults when
|
||||
/// <see cref="LoadSymbolRulesAsync"/> 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.
|
||||
/// </summary>
|
||||
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<BinanceOrder> 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<List<BinanceOrder>> 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);
|
||||
}
|
||||
|
||||
/// <summary>Recent orders for one symbol, newest last as Binance returns them.</summary>
|
||||
public async Task<List<BinanceOrder>> 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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes one symbol's position at market.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<BinanceOrder?> ClosePositionAsync(string symbol, CancellationToken ct)
|
||||
{
|
||||
List<FuturesPosition> 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<string> CreateListenKeyAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument doc = await _http.PostKeyedAsync("fapi/v1/listenKey", ct).ConfigureAwait(false);
|
||||
return doc.RootElement.StringOrEmpty("listenKey");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task KeepListenKeyAliveAsync(CancellationToken ct)
|
||||
{
|
||||
using JsonDocument _ = await _http.PutKeyedAsync("fapi/v1/listenKey", ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static List<BinanceOrder> ParseOrders(JsonElement root)
|
||||
{
|
||||
List<BinanceOrder> 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// Signing is the whole reason this exists rather than a bare <see cref="HttpClient"/>.
|
||||
/// Binance authenticates by hashing the <i>exact</i> 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 <see cref="Uri"/> re-encode it, produces a
|
||||
/// signature that is silently wrong and a rejection that says nothing useful.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Exchange clock minus local clock, in milliseconds. Applied to every signed request.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Exchange clock minus local clock, in milliseconds, as last measured.</summary>
|
||||
public long ClockOffsetMs => Interlocked.Read(ref _clockOffsetMs);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures the difference between the exchange clock and ours.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<JsonDocument> GetAsync(string path, string query, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Get, Combine(path, query), null, signed: false, ct);
|
||||
|
||||
public Task<JsonDocument> GetSignedAsync(string path, string query, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Get, path, query, signed: true, ct);
|
||||
|
||||
public Task<JsonDocument> PostSignedAsync(string path, string query, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Post, path, query, signed: true, ct);
|
||||
|
||||
public Task<JsonDocument> DeleteSignedAsync(string path, string query, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Delete, path, query, signed: true, ct);
|
||||
|
||||
/// <summary>Key-authenticated but unsigned. Only the listen-key endpoints work this way.</summary>
|
||||
public Task<JsonDocument> PostKeyedAsync(string path, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Post, path, null, signed: false, ct);
|
||||
|
||||
public Task<JsonDocument> PutKeyedAsync(string path, CancellationToken ct) =>
|
||||
SendAsync(HttpMethod.Put, path, null, signed: false, ct);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Transport
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async Task<JsonDocument> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<byte> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The futures wallet, as <c>/fapi/v2/account</c> reports it.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>What the risk engine sizes against.</summary>
|
||||
public decimal Equity => MarginBalance;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>One symbol's open exposure, as <c>/fapi/v2/positionRisk</c> reports it.</summary>
|
||||
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"));
|
||||
}
|
||||
|
||||
/// <summary>An order as Binance reports it.</summary>
|
||||
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)
|
||||
{
|
||||
/// <summary>True while the order can still fill, i.e. it holds margin and blocks a re-entry.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// These are not optional niceties. A hedge ratio produces a quantity like
|
||||
/// <c>0.0473819…</c>, and sending that to a symbol whose step is <c>0.001</c> is
|
||||
/// rejected outright. Rounding to the step is what makes a computed size an order.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Rounds a quantity <b>down</b> onto the exchange's lot step.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Rounds a price onto the exchange's tick.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Whether a rounded quantity is actually sendable at this price.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark price and funding for one symbol.
|
||||
/// <para>
|
||||
/// 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 <i>net</i> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>The rate annualised, assuming the current rate persists across all three daily settlements.</summary>
|
||||
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"));
|
||||
}
|
||||
|
||||
/// <summary>An order the bot wants to send.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Only read for <see cref="OrderType.Limit"/>.</summary>
|
||||
public double LimitPrice { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool ReduceOnly { get; init; }
|
||||
|
||||
public string? ClientOrderId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time in force for a limit order. <c>GTX</c> is post-only: the order is cancelled
|
||||
/// rather than filled if it would cross, which guarantees the maker fee.
|
||||
/// </summary>
|
||||
public string TimeInForce { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Account and order events arriving on the user-data stream.</summary>
|
||||
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)
|
||||
{
|
||||
/// <summary>True when this event carries an actual fill rather than a status change.</summary>
|
||||
public bool IsExecution => LastFilledQuantity > 0 && LastFilledPrice > 0;
|
||||
|
||||
/// <summary>True once the order can no longer fill, so its in-flight latch may be released.</summary>
|
||||
public bool IsTerminal => Status is OrderStatus.Filled or OrderStatus.Canceled
|
||||
or OrderStatus.Rejected or OrderStatus.Expired;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Binance.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Raised once per closed bar. A named delegate rather than an <see cref="Action{T1,T2,T3}"/>
|
||||
/// so the payload can be passed by <c>in</c>: 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.
|
||||
/// </summary>
|
||||
public delegate void BarHandler(int symbolId, string symbol, in Bar bar);
|
||||
|
||||
/// <summary>Raised on every top-of-book change. See <see cref="BarHandler"/> for the `in`.</summary>
|
||||
public delegate void QuoteHandler(int symbolId, string symbol, in Quote quote);
|
||||
|
||||
/// <summary>
|
||||
/// The single market-data socket: closed klines, top of book and the funding rate, for
|
||||
/// every traded symbol at once.
|
||||
/// <para>
|
||||
/// Binance multiplexes subscriptions into one <c>/stream?streams=…</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The kline stream is what makes this venue a better fit than the previous one. Binance
|
||||
/// sends a kline event with <c>x: true</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MarketDataStream : WebSocketChannel
|
||||
{
|
||||
private readonly SymbolTable _symbols;
|
||||
|
||||
public MarketDataStream(BinanceOptions options, IReadOnlyList<string> symbols, TimeFrame timeFrame)
|
||||
: base(BuildUri(options, symbols, timeFrame), "market-data")
|
||||
{
|
||||
_symbols = new SymbolTable(symbols);
|
||||
TimeFrame = timeFrame;
|
||||
}
|
||||
|
||||
public SymbolTable Symbols => _symbols;
|
||||
|
||||
public TimeFrame TimeFrame { get; }
|
||||
|
||||
/// <summary>Raised once per <b>closed</b> bar. Runs on the receive thread.</summary>
|
||||
public BarHandler? OnBar { get; set; }
|
||||
|
||||
/// <summary>Raised on every top-of-book change.</summary>
|
||||
public QuoteHandler? OnQuote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised once a second per symbol with the mark price and the funding rate that
|
||||
/// will be settled at <c>nextFundingUtc</c>.
|
||||
/// </summary>
|
||||
public Action<int, string, double, double, DateTime>? OnFunding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private static Uri BuildUri(BinanceOptions options, IReadOnlyList<string> 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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nothing to send: the subscription is in the URL and public data needs no key.
|
||||
/// The channel is live the moment the socket opens.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="Utf8JsonReader"/> rather than materialising a document per message.
|
||||
/// </summary>
|
||||
protected override void OnMessage(ReadOnlySpan<byte> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dispatches one event object, positioned on its <c>StartObject</c>.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a kline event and raises <see cref="OnBar"/> only when the bar has closed.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<byte> utf8) =>
|
||||
System.Buffers.Text.Utf8Parser.TryParse(utf8, out double value, out _) ? value : 0;
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
namespace Encelado.Alpaca.Streaming;
|
||||
namespace Encelado.Binance.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <summary>Resolves a symbol from raw UTF-8. Returns -1 when it is not subscribed.</summary>
|
||||
public int Resolve(ReadOnlySpan<byte> 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)
|
||||
{
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Text.Json;
|
||||
using Encelado.Binance.Internal;
|
||||
using Encelado.Binance.Rest;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Binance.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// The account's own event feed: order fills, cancellations and position changes, as
|
||||
/// they happen.
|
||||
/// <para>
|
||||
/// Binance authenticates this socket with a <b>listen key</b> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Raised for every order event. Runs on the receive thread.</summary>
|
||||
public Action<TradeUpdate>? OnTradeUpdate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when Binance reports the account's own view of a position, which is
|
||||
/// authoritative and overrides anything the bot computed for itself.
|
||||
/// </summary>
|
||||
public Action<string, double, double>? OnPositionUpdate { get; set; }
|
||||
|
||||
/// <summary>Raised when the wallet balance changes, with the new balance in USDT.</summary>
|
||||
public Action<double>? OnBalanceUpdate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Mints a fresh listen key for each connection attempt.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
protected override async ValueTask<Uri> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renews the listen key every thirty minutes — half its lifetime, so one missed
|
||||
/// renewal is survivable rather than fatal.
|
||||
/// </summary>
|
||||
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<byte> 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);
|
||||
}
|
||||
}
|
||||
+39
-15
@@ -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;
|
||||
/// <summary>
|
||||
/// Where the channel connects. Settable by <see cref="ResolveUriAsync"/> because the
|
||||
/// user-data socket's address contains a listen key that expires and is reissued.
|
||||
/// </summary>
|
||||
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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
@@ -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
|
||||
/// <summary>Sends the auth (and subscribe) handshake right after the socket opens.</summary>
|
||||
protected abstract ValueTask OnOpenAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the address to connect to, immediately before each attempt. The default
|
||||
/// returns the fixed <see cref="Uri"/>; the user-data channel overrides it to mint a
|
||||
/// fresh listen key, because a reconnect after an expiry has to use a new one.
|
||||
/// </summary>
|
||||
protected virtual ValueTask<Uri> ResolveUriAsync(CancellationToken ct) => new(Uri);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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), "…");
|
||||
}
|
||||
|
||||
/// <summary>Decodes one complete frame. Runs on the receive thread — keep it allocation free.</summary>
|
||||
protected abstract void OnMessage(ReadOnlySpan<byte> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan ServerAuthTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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();
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Where the Alpaca credentials in use actually came from.</summary>
|
||||
/// <summary>Where the Binance credentials in use actually came from.</summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Provenance of <see cref="AlpacaOptions.KeyId"/>. Set by the loader and by the
|
||||
/// Provenance of <see cref="BinanceOptions.ApiKey"/>. Set by the loader and by the
|
||||
/// login flow so startup can report it without ever echoing the secret.
|
||||
/// </summary>
|
||||
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<PairConfig> Pairs { get; set; } = [];
|
||||
|
||||
public List<SymbolConfig> Symbols { get; set; } = [];
|
||||
public IEnumerable<PairConfig> EnabledPairs => Pairs.Where(static p => p.Enabled);
|
||||
|
||||
public IEnumerable<SymbolConfig> EnabledSymbols => Symbols.Where(s => s.Enabled);
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> TradedSymbols
|
||||
{
|
||||
get
|
||||
{
|
||||
List<string> symbols = [];
|
||||
HashSet<string> 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<SymbolConfig> enabled = [.. EnabledSymbols];
|
||||
List<PairConfig> 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<string> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One tradeable pair: two Binance futures symbols and the parameters the statistical
|
||||
/// model uses on them.
|
||||
/// </summary>
|
||||
public sealed class PairConfig
|
||||
{
|
||||
/// <summary>The leg the spread is measured <i>on</i>: <c>ln(A) − β·ln(B)</c>.</summary>
|
||||
public string SymbolA { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The hedge leg.</summary>
|
||||
public string SymbolB { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public Dictionary<string, double> Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Stable identity, used as a dictionary key and shown in the UI.</summary>
|
||||
public string Name => $"{Normalize(SymbolA)}/{Normalize(SymbolB)}";
|
||||
|
||||
public StrategyParameters ToStrategyParameters() => new(Parameters);
|
||||
|
||||
/// <summary>
|
||||
/// Binance futures symbols are uppercase and unpunctuated. Accepting
|
||||
/// <c>eth/usdt</c> and <c>ETH-USDT</c> and normalising here costs nothing and saves
|
||||
/// a support conversation about a symbol the exchange simply does not have.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary><c>us_equity</c> or <c>crypto</c>. Crypto trades 24/7 and requires fractional sizes.</summary>
|
||||
public string AssetClass { get; set; } = "us_equity";
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string TimeFrame { get; set; } = "5m";
|
||||
|
||||
/// <summary>Decision timeframe. Bars are consumed straight from the stream at 1Min.</summary>
|
||||
public string TimeFrame { get; set; } = "1Min";
|
||||
/// <summary>Historical bars pulled at startup to fill the z-score window.</summary>
|
||||
public int WarmupBars { get; set; } = 500;
|
||||
|
||||
/// <summary>Historical bars pulled at startup to warm the indicators.</summary>
|
||||
public int WarmupBars { get; set; } = 300;
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int CalibrationBars { get; set; } = 500;
|
||||
|
||||
/// <summary>Refuse new entries outside 09:30–16:00 ET.</summary>
|
||||
public bool TradeOnlyRegularHours { get; set; } = true;
|
||||
|
||||
/// <summary>Flatten everything this many minutes before the close. 0 disables.</summary>
|
||||
public int FlattenBeforeCloseMinutes { get; set; } = 10;
|
||||
|
||||
public bool AllowFractionalShares { get; set; }
|
||||
|
||||
/// <summary>Attach take-profit/stop-loss legs server-side so exits survive a bot crash.</summary>
|
||||
public bool UseBracketOrders { get; set; } = true;
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public double RecalibrateHours { get; set; } = 24;
|
||||
|
||||
/// <summary><c>market</c> or <c>limit</c>. A marketable limit caps slippage.</summary>
|
||||
public string EntryOrderType { get; set; } = "limit";
|
||||
|
||||
/// <summary>How far through the touch a marketable limit is priced, in basis points.</summary>
|
||||
public double LimitOffsetBps { get; set; } = 5;
|
||||
public double LimitOffsetBps { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Post the entry as maker-only (<c>GTX</c>) instead of crossing the book.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool PostOnlyEntries { get; set; }
|
||||
|
||||
/// <summary>Log decisions but never send an order. The safest way to observe a new config.</summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
public int ReconcileSeconds { get; set; } = 30;
|
||||
/// <summary>How often the bot re-checks the account, positions and orders against Binance.</summary>
|
||||
public int ReconcileSeconds { get; set; } = 20;
|
||||
|
||||
public int StatusSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int ExplainSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Reject entries when top-of-book is older than this. 0 disables the check.</summary>
|
||||
public int MaxQuoteAgeSeconds { get; set; } = 30;
|
||||
public int MaxQuoteAgeSeconds { get; set; } = 15;
|
||||
|
||||
/// <summary>Liquidate everything when the bot shuts down.</summary>
|
||||
/// <summary>Close every open pair when the bot shuts down.</summary>
|
||||
public bool CloseOnShutdown { get; set; }
|
||||
|
||||
public AssetClass ResolvedAssetClass =>
|
||||
AssetClass.Trim().ToLowerInvariant() is "crypto" or "us_crypto"
|
||||
? Core.Market.AssetClass.Crypto
|
||||
: Core.Market.AssetClass.UsEquity;
|
||||
/// <summary>Write a line for every closed bar, per symbol, even when nothing happens.</summary>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>Settings for the web dashboard served by the <c>ui</c> command.</summary>
|
||||
public sealed class UiOptions
|
||||
if (ExplainSeconds < 1)
|
||||
{
|
||||
/// <summary>
|
||||
/// Where the dashboard listens. Use <c>http://0.0.0.0:5088</c> to reach it from
|
||||
/// another machine — there is no authentication, so only do that on a trusted LAN.
|
||||
/// </summary>
|
||||
public string Url { get; set; } = "http://localhost:5088";
|
||||
|
||||
/// <summary>Begin trading as soon as the dashboard starts, without pressing START.</summary>
|
||||
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))
|
||||
{
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Verbosity: <c>trace</c>, <c>debug</c>, <c>info</c>, <c>warn</c>, <c>error</c> or
|
||||
/// <c>none</c>. <c>debug</c> adds every rejected signal and risk refusal;
|
||||
/// <c>trace</c> adds per-quote detail and is very noisy.
|
||||
/// <c>none</c>. Every refusal that stops an order is written at <c>info</c> or above,
|
||||
/// so <c>debug</c> is for the market-data path rather than for finding out why the
|
||||
/// bot did not trade.
|
||||
/// </summary>
|
||||
public string Level { get; set; } = "info";
|
||||
|
||||
@@ -226,15 +314,15 @@ public sealed class LoggingOptions
|
||||
public string TradeJournal { get; set; } = "trades.jsonl";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string DecisionLog { get; set; } = "decisions.csv";
|
||||
|
||||
/// <summary>
|
||||
/// One CSV row per signal that reached the order path, with the risk verdict and
|
||||
/// the order outcome. Joins to <see cref="DecisionLog"/> on <c>decisionId</c>.
|
||||
/// One CSV row per signal that reached the order path, with the risk verdict and the
|
||||
/// order outcome. Joins to <see cref="DecisionLog"/> on <c>decisionId</c>.
|
||||
/// </summary>
|
||||
public string ExecutionLog { get; set; } = "executions.csv";
|
||||
|
||||
@@ -245,29 +333,17 @@ public sealed class LoggingOptions
|
||||
public int MaxFiles { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool LogMarketData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool LogEveryBar { get; set; } = true;
|
||||
|
||||
/// <summary>Lines kept in the activity strip on the status page.</summary>
|
||||
public int StatusLines { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>debug</c> 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.
|
||||
/// </summary>
|
||||
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<string, double> Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public StrategyParameters ToStrategyParameters() => new(Parameters);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The factory configuration, and the ability to go back to it.
|
||||
/// <para>
|
||||
/// The default lives here as text rather than as a set of property assignments, and the
|
||||
/// shipped <c>config/encelado.json</c> is a copy of this string. That is deliberate: the
|
||||
/// file is more than its values — the <c>_</c>-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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A test asserts that this string and the shipped file are identical, so the two cannot
|
||||
/// drift apart unnoticed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ConfigDefaults
|
||||
{
|
||||
/// <summary>Extension given to the copy taken before a restore.</summary>
|
||||
public const string BackupSuffix = ".bak";
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites <paramref name="configPath"/> with the factory configuration, after
|
||||
/// moving whatever was there to a timestamped backup beside it.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>The path of the backup, or null when there was no file to back up.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the factory values into <paramref name="config"/> in memory, without
|
||||
/// touching the disk. Used when no configuration file exists at all.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The factory configuration as a parsed object. Reparsed on each call.</summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The factory <c>encelado.json</c>, verbatim.</summary>
|
||||
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 <cartella>' 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
""";
|
||||
}
|
||||
@@ -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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<string> warnings)
|
||||
private static void ReadBinance(BotConfig config, JsonElement e, List<string> 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<string> 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<string> warnings)
|
||||
private static void ReadPairs(BotConfig config, JsonElement e, List<string> 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,20 +229,35 @@ 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)
|
||||
ReadParameters(pair, p.Value);
|
||||
break;
|
||||
default: warnings.Add($"chiave sconosciuta 'pairs[].{p.Name}'"); break;
|
||||
}
|
||||
}
|
||||
|
||||
config.Pairs.Add(pair);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadParameters(PairConfig pair, JsonElement value)
|
||||
{
|
||||
foreach (JsonProperty kv in p.Value.EnumerateObject())
|
||||
if (value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (JsonProperty kv in value.EnumerateObject())
|
||||
{
|
||||
if (kv.Name.StartsWith('_'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sc.Parameters[kv.Name] = kv.Value.ValueKind switch
|
||||
pair.Parameters[kv.Name] = kv.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => kv.Value.GetDouble(),
|
||||
JsonValueKind.True => 1,
|
||||
@@ -270,45 +270,36 @@ public static class ConfigLoader
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
default: warnings.Add($"unknown key 'symbols[].{p.Name}'"); break;
|
||||
}
|
||||
}
|
||||
|
||||
config.Symbols.Add(sc);
|
||||
}
|
||||
}
|
||||
|
||||
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."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Encelado.Alpaca;
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Binance;
|
||||
using Encelado.Binance.Rest;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>Outcome of trying to find usable Alpaca credentials.</summary>
|
||||
/// <summary>Outcome of trying to find usable Binance credentials.</summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// 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 <b>futures</b> permission enabled, which is a
|
||||
/// separate checkbox on Binance and the single most common reason a key that looks
|
||||
/// correct cannot trade.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a Binance refusal into something an operator can act on.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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}",
|
||||
};
|
||||
|
||||
/// <summary>Stores a verified key pair and points the config at it.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ using System.Text.Json;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>Credentials plus a human-readable note about where they came from.</summary>
|
||||
public readonly record struct StoredCredentials(string KeyId, string SecretKey);
|
||||
/// <summary>An API key pair as it was saved.</summary>
|
||||
public readonly record struct StoredCredentials(string ApiKey, string ApiSecret);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// <para>
|
||||
/// 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);
|
||||
/// </summary>
|
||||
public static class CredentialStore
|
||||
{
|
||||
private const string PaperKey = "paper";
|
||||
private const string TestnetKey = "testnet";
|
||||
private const string LiveKey = "live";
|
||||
|
||||
/// <summary>True when the file at rest is encrypted rather than merely permission-restricted.</summary>
|
||||
@@ -43,27 +43,27 @@ public static class CredentialStore
|
||||
public static bool Exists => File.Exists(FilePath);
|
||||
|
||||
/// <summary>Reads the credentials saved for the given environment, or null when there are none.</summary>
|
||||
public static StoredCredentials? Load(bool paper)
|
||||
public static StoredCredentials? Load(bool testnet)
|
||||
{
|
||||
Dictionary<string, StoredCredentials> 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<string, StoredCredentials> all = LoadAll();
|
||||
all[paper ? PaperKey : LiveKey] = new StoredCredentials(keyId.Trim(), secretKey.Trim());
|
||||
all[testnet ? TestnetKey : LiveKey] = new StoredCredentials(apiKey.Trim(), apiSecret.Trim());
|
||||
Write(all);
|
||||
}
|
||||
|
||||
/// <summary>Removes the credentials for one environment. Returns whether anything was removed.</summary>
|
||||
public static bool Clear(bool paper)
|
||||
public static bool Clear(bool testnet)
|
||||
{
|
||||
Dictionary<string, StoredCredentials> all = LoadAll();
|
||||
if (!all.Remove(paper ? PaperKey : LiveKey))
|
||||
if (!all.Remove(testnet ? TestnetKey : LiveKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -120,23 +120,24 @@ public static class CredentialStore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Masks a key for display. Only the first four characters survive — enough to tell
|
||||
/// a paper key (<c>PK…</c>) from a live one (<c>AK…</c>) 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 <i>which</i> 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.
|
||||
/// </summary>
|
||||
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<string, StoredCredentials> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <c>decisionId</c>:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>decisions</b> — 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".</item>
|
||||
/// <item><b>decisions</b> — 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".</item>
|
||||
/// <item><b>executions</b> — one row per signal that reached the order path: the risk
|
||||
/// verdict, the size that survived it, and the broker's answer.</item>
|
||||
/// verdict, the size that survived it, and the exchange's answer for each leg.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// 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;
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Allocates the id that ties a decision row to its execution row.</summary>
|
||||
/// <summary>Allocates the id that ties a decision row to its execution rows.</summary>
|
||||
public long NextDecisionId() => Interlocked.Increment(ref _nextId);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <summary>Records one aligned-bar evaluation. Called on the market-data thread.</summary>
|
||||
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<StrategyMetric> 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
|
||||
}
|
||||
|
||||
/// <summary>Records what the order path did with a signal.</summary>
|
||||
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<StrategyMetric> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One leg's market state at the moment of a decision, for the decision log.</summary>
|
||||
public readonly record struct LegSnapshot(
|
||||
string Symbol,
|
||||
double Bid,
|
||||
double Ask,
|
||||
double SpreadPct,
|
||||
double FundingRate);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public readonly record struct ExecutionRecord
|
||||
{
|
||||
public required long DecisionId { get; init; }
|
||||
|
||||
public required string Pair { get; init; }
|
||||
|
||||
/// <summary><c>suppressed</c>, <c>risk</c>, <c>order</c> or <c>exit</c>.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
|
||||
<ProjectReference Include="..\Encelado.Alpaca\Encelado.Alpaca.csproj" />
|
||||
<ProjectReference Include="..\Encelado.Binance\Encelado.Binance.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- DPAPI (System.Security.Cryptography.ProtectedData) ships inside the Windows
|
||||
|
||||
@@ -1,68 +1,84 @@
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Binance.Rest;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Latest known account snapshot, refreshed by the reconciler and read from the
|
||||
/// order path. Fields are written as a unit under a lock and read without one, which
|
||||
/// is fine: sizing only needs a recent value, not a transactionally consistent one.
|
||||
/// Latest known account snapshot, refreshed by the reconciler and read from the order
|
||||
/// path. Fields are written as a unit and read without a lock, which is fine: sizing
|
||||
/// only needs a recent value, not a transactionally consistent one.
|
||||
/// </summary>
|
||||
public sealed class AccountState
|
||||
{
|
||||
private double _equity;
|
||||
private double _buyingPower;
|
||||
private double _cash;
|
||||
private int _daytradeCount;
|
||||
private bool _patternDayTrader;
|
||||
private double _walletBalance;
|
||||
private double _availableBalance;
|
||||
private double _unrealizedPnl;
|
||||
private double _marginRatio;
|
||||
private bool _canTrade;
|
||||
private bool _shortingEnabled;
|
||||
|
||||
// Broker-side detail the account page shows verbatim. None of it is on the decision
|
||||
// path, so a reference swap under the same update is all the consistency needed.
|
||||
private AlpacaAccount? _raw;
|
||||
// The full payload the account panel shows verbatim. Not on the decision path, so a
|
||||
// reference swap under the same update is all the consistency needed.
|
||||
private FuturesAccount? _raw;
|
||||
|
||||
/// <summary>
|
||||
/// Margin balance: wallet plus unrealised P&L. This is what everything sizes
|
||||
/// against, because it is what a liquidation is measured against — the wallet alone
|
||||
/// ignores an open position that is currently under water, which is precisely when
|
||||
/// the difference matters.
|
||||
/// </summary>
|
||||
public double Equity => Volatile.Read(ref _equity);
|
||||
|
||||
public double BuyingPower => Volatile.Read(ref _buyingPower);
|
||||
public double WalletBalance => Volatile.Read(ref _walletBalance);
|
||||
|
||||
public double Cash => Volatile.Read(ref _cash);
|
||||
/// <summary>Free margin, i.e. what a new position can actually be opened against.</summary>
|
||||
public double AvailableBalance => Volatile.Read(ref _availableBalance);
|
||||
|
||||
public int DaytradeCount => Volatile.Read(ref _daytradeCount);
|
||||
public double UnrealizedPnl => Volatile.Read(ref _unrealizedPnl);
|
||||
|
||||
public bool PatternDayTrader => Volatile.Read(ref _patternDayTrader);
|
||||
/// <summary>Maintenance margin over margin balance. Approaching 1 is approaching liquidation.</summary>
|
||||
public double MarginRatio => Volatile.Read(ref _marginRatio);
|
||||
|
||||
public bool CanTrade => Volatile.Read(ref _canTrade);
|
||||
|
||||
public bool ShortingEnabled => Volatile.Read(ref _shortingEnabled);
|
||||
|
||||
public DateTime LastUpdateUtc { get; private set; }
|
||||
|
||||
public bool HasData => Equity > 0;
|
||||
public bool HasData => Equity > 0 || WalletBalance > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The last full account payload from the broker, or <see langword="null"/> before
|
||||
/// the first reconcile. Dashboard only — the trading path reads the fields above.
|
||||
/// </summary>
|
||||
public AlpacaAccount? Raw => Volatile.Read(ref _raw);
|
||||
/// <summary>The last full account payload, or null before the first reconcile.</summary>
|
||||
public FuturesAccount? Raw => Volatile.Read(ref _raw);
|
||||
|
||||
public void Update(AlpacaAccount account)
|
||||
public void Update(FuturesAccount account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
|
||||
Volatile.Write(ref _raw, account);
|
||||
Volatile.Write(ref _equity, (double)account.Equity);
|
||||
Volatile.Write(ref _buyingPower, (double)account.BuyingPower);
|
||||
Volatile.Write(ref _cash, (double)account.Cash);
|
||||
Volatile.Write(ref _daytradeCount, account.DaytradeCount);
|
||||
Volatile.Write(ref _patternDayTrader, account.PatternDayTrader);
|
||||
Volatile.Write(ref _walletBalance, (double)account.WalletBalance);
|
||||
Volatile.Write(ref _availableBalance, (double)account.AvailableBalance);
|
||||
Volatile.Write(ref _unrealizedPnl, (double)account.UnrealizedPnl);
|
||||
Volatile.Write(ref _marginRatio, account.MarginRatio);
|
||||
Volatile.Write(ref _canTrade, account.CanTrade);
|
||||
Volatile.Write(ref _shortingEnabled, account.ShortingEnabled);
|
||||
LastUpdateUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Under the PDT rule an account flagged as a pattern day trader with less than
|
||||
/// $25 000 in equity cannot open a new day trade.
|
||||
/// Applies a wallet balance pushed by the user-data stream between reconciles.
|
||||
/// <para>
|
||||
/// Only the wallet, and only when it actually moved: the stream reports a balance
|
||||
/// without the unrealised P&L or the maintenance margin that go with it, so
|
||||
/// deriving equity from it would produce a number that disagrees with the one the
|
||||
/// exchange would give — and sizing against that is worse than sizing against a
|
||||
/// number that is twenty seconds old.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsDayTradeBlocked => PatternDayTrader && Equity < 25_000;
|
||||
public void UpdateWalletBalance(double walletBalance)
|
||||
{
|
||||
if (walletBalance <= 0 || !double.IsFinite(walletBalance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _walletBalance, walletBalance);
|
||||
LastUpdateUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Folds Alpaca's one-minute stream bars into the strategy timeframe. A bucket is
|
||||
/// emitted as soon as the first bar of the next bucket arrives, which is the earliest
|
||||
/// moment the previous one is provably complete.
|
||||
/// </summary>
|
||||
public sealed class BarAggregator(int minutesPerBar)
|
||||
{
|
||||
private readonly long _bucketTicks = TimeSpan.TicksPerMinute * Math.Max(1, minutesPerBar);
|
||||
private readonly bool _passthrough = minutesPerBar <= 1;
|
||||
|
||||
private Bar _current;
|
||||
private long _bucket = -1;
|
||||
private bool _has;
|
||||
|
||||
public bool IsPassthrough => _passthrough;
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a one-minute bar. Returns <see langword="true"/> when a higher-timeframe
|
||||
/// bar closed, with the completed bar in <paramref name="closed"/>.
|
||||
/// </summary>
|
||||
public bool TryAdd(in Bar minuteBar, out Bar closed)
|
||||
{
|
||||
if (_passthrough)
|
||||
{
|
||||
closed = minuteBar;
|
||||
return true;
|
||||
}
|
||||
|
||||
long bucket = minuteBar.TimeUtc.Ticks / _bucketTicks;
|
||||
|
||||
if (!_has)
|
||||
{
|
||||
_current = minuteBar;
|
||||
_bucket = bucket;
|
||||
_has = true;
|
||||
closed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bucket != _bucket)
|
||||
{
|
||||
closed = _current;
|
||||
_current = minuteBar;
|
||||
_bucket = bucket;
|
||||
return true;
|
||||
}
|
||||
|
||||
_current = Merge(_current, minuteBar);
|
||||
closed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_has = false;
|
||||
_bucket = -1;
|
||||
_current = default;
|
||||
}
|
||||
|
||||
private static Bar Merge(in Bar acc, in Bar next)
|
||||
{
|
||||
double volume = acc.Volume + next.Volume;
|
||||
double vwap = volume > 0
|
||||
? ((acc.Vwap > 0 ? acc.Vwap : acc.TypicalPrice) * acc.Volume +
|
||||
(next.Vwap > 0 ? next.Vwap : next.TypicalPrice) * next.Volume) / volume
|
||||
: next.Close;
|
||||
|
||||
return new Bar(
|
||||
acc.TimeUtc,
|
||||
acc.Open,
|
||||
Math.Max(acc.High, next.High),
|
||||
Math.Min(acc.Low, next.Low),
|
||||
next.Close,
|
||||
volume,
|
||||
vwap,
|
||||
acc.TradeCount + next.TradeCount,
|
||||
acc.TakerBuyVolume + next.TakerBuyVolume);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
public enum BotState
|
||||
@@ -9,9 +11,88 @@ public enum BotState
|
||||
Faulted,
|
||||
}
|
||||
|
||||
/// <summary>One open position, as the positions grid shows it.</summary>
|
||||
/// <summary>
|
||||
/// One pair, as the status page shows it. This is the row that answers every question
|
||||
/// worth asking about the bot: what the relationship looks like right now, whether it is
|
||||
/// tradeable, whether we are in it, and — when we are not — exactly what is stopping us.
|
||||
/// </summary>
|
||||
public sealed record PairRow(
|
||||
string Name,
|
||||
string SymbolA,
|
||||
string SymbolB,
|
||||
bool Ready,
|
||||
double ZScore,
|
||||
double Beta,
|
||||
double PValue,
|
||||
double HalfLife,
|
||||
bool Cointegrated,
|
||||
double EntryZ,
|
||||
double ExitZ,
|
||||
double StopZ,
|
||||
double QuantityA,
|
||||
double QuantityB,
|
||||
double EntryZScore,
|
||||
int BarsHeld,
|
||||
double UnrealizedPnl,
|
||||
double PriceA,
|
||||
double PriceB,
|
||||
double NetFundingRate,
|
||||
string Intent,
|
||||
string? LastRefusal)
|
||||
{
|
||||
public bool IsOpen => Math.Abs(QuantityA) > 1e-12 || Math.Abs(QuantityB) > 1e-12;
|
||||
|
||||
public string Direction => !IsOpen
|
||||
? "—"
|
||||
: QuantityA > 0 ? "long spread" : "short spread";
|
||||
|
||||
/// <summary>
|
||||
/// Where the z-score sits between the exit band and the stop, as 0..1. Drives the
|
||||
/// bar in the pair list, which is the one thing on the page readable at a glance.
|
||||
/// </summary>
|
||||
public double ZProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!double.IsFinite(ZScore) || StopZ <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.Clamp(Math.Abs(ZScore) / StopZ, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True once the spread is far enough out that an entry is on the table.</summary>
|
||||
public bool AtThreshold => Ready && Cointegrated && Math.Abs(ZScore) >= EntryZ;
|
||||
|
||||
public string ZDisplay => double.IsFinite(ZScore)
|
||||
? ZScore.ToString("+0.00;-0.00;0.00", CultureInfo.CurrentCulture)
|
||||
: "—";
|
||||
|
||||
public string BetaDisplay => double.IsFinite(Beta) && Beta != 0
|
||||
? Beta.ToString("F4", CultureInfo.CurrentCulture)
|
||||
: "—";
|
||||
|
||||
public string PValueDisplay => double.IsFinite(PValue)
|
||||
? PValue.ToString("F3", CultureInfo.CurrentCulture)
|
||||
: "—";
|
||||
|
||||
public string HalfLifeDisplay => double.IsFinite(HalfLife)
|
||||
? HalfLife.ToString("F0", CultureInfo.CurrentCulture) + " barre"
|
||||
: "—";
|
||||
|
||||
public string StateLabel => !Ready ? "riscaldamento"
|
||||
: !Cointegrated ? "non cointegrata"
|
||||
: IsOpen ? Direction
|
||||
: AtThreshold ? "pronta"
|
||||
: "in attesa";
|
||||
}
|
||||
|
||||
/// <summary>One open leg, as the positions grid shows it.</summary>
|
||||
public sealed record PositionRow(
|
||||
string Symbol,
|
||||
string Pair,
|
||||
string Side,
|
||||
double Quantity,
|
||||
double EntryPrice,
|
||||
@@ -19,53 +100,16 @@ public sealed record PositionRow(
|
||||
double MarketValue,
|
||||
double UnrealizedPnl,
|
||||
double UnrealizedPnlPct,
|
||||
double StopPrice,
|
||||
double TargetPrice,
|
||||
int BarsHeld,
|
||||
DateTime OpenedAtUtc);
|
||||
|
||||
/// <summary>Per-symbol strategy state, including whatever the strategy chooses to expose.</summary>
|
||||
public sealed record SymbolRow(
|
||||
string Symbol,
|
||||
string Strategy,
|
||||
bool Ready,
|
||||
int BarsSeen,
|
||||
int WarmupBars,
|
||||
double LastPrice,
|
||||
double BidPrice,
|
||||
double AskPrice,
|
||||
double SpreadPct,
|
||||
double QuoteAgeSeconds,
|
||||
bool InPosition,
|
||||
IReadOnlyList<MetricRow> Metrics)
|
||||
{
|
||||
public double WarmupProgress => WarmupBars > 0 ? Math.Min(1, BarsSeen / (double)WarmupBars) : 1;
|
||||
|
||||
/// <summary>The blended conviction, when the strategy publishes one.</summary>
|
||||
public double? Score
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (MetricRow m in Metrics)
|
||||
{
|
||||
if (m.Name == "score")
|
||||
{
|
||||
return m.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MetricRow(string Name, double Value, string Format)
|
||||
{
|
||||
public string Display => Format switch
|
||||
{
|
||||
"P1" => (Value * 100).ToString("F1", System.Globalization.CultureInfo.CurrentCulture) + "%",
|
||||
"F0" => Value.ToString("F0", System.Globalization.CultureInfo.CurrentCulture),
|
||||
_ => Value.ToString("F2", System.Globalization.CultureInfo.CurrentCulture),
|
||||
"P1" => (Value * 100).ToString("F1", CultureInfo.CurrentCulture) + "%",
|
||||
"F0" => Value.ToString("F0", CultureInfo.CurrentCulture),
|
||||
"F4" => Value.ToString("F4", CultureInfo.CurrentCulture),
|
||||
_ => Value.ToString("F2", CultureInfo.CurrentCulture),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,50 +119,36 @@ public sealed record EquityPoint(DateTime TimeUtc, double Equity);
|
||||
public sealed record EventRow(string Time, string Level, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// The broker's own view of the account, shown verbatim on the account page. Kept as a
|
||||
/// separate record rather than folded into <see cref="BotSnapshot"/> so it can be
|
||||
/// absent — before the first reconcile there is nothing truthful to display, and
|
||||
/// showing zeros would look like a funded account that lost everything.
|
||||
/// The exchange's own view of the futures wallet. Kept separate so it can be absent —
|
||||
/// before the first reconcile there is nothing truthful to display, and showing zeros
|
||||
/// would look like a funded account that lost everything.
|
||||
/// </summary>
|
||||
public sealed record AccountRow(
|
||||
string AccountNumber,
|
||||
string Status,
|
||||
string Currency,
|
||||
double Equity,
|
||||
double LastEquity,
|
||||
double Cash,
|
||||
double PortfolioValue,
|
||||
double BuyingPower,
|
||||
double DaytradingBuyingPower,
|
||||
double Multiplier,
|
||||
int DaytradeCount,
|
||||
bool PatternDayTrader,
|
||||
bool TradingBlocked,
|
||||
bool AccountBlocked,
|
||||
bool TransfersBlocked,
|
||||
bool ShortingEnabled,
|
||||
double WalletBalance,
|
||||
double MarginBalance,
|
||||
double AvailableBalance,
|
||||
double UnrealizedPnl,
|
||||
double MaintenanceMargin,
|
||||
double MarginRatio,
|
||||
int FeeTier,
|
||||
bool CanTrade,
|
||||
bool MultiAssetsMargin,
|
||||
DateTime UpdatedUtc)
|
||||
{
|
||||
public double ChangeToday => Equity - LastEquity;
|
||||
|
||||
public double ChangeTodayPct => LastEquity > 0 ? (Equity - LastEquity) / LastEquity : 0;
|
||||
|
||||
/// <summary>Everything that would make the broker refuse an order, in one line.</summary>
|
||||
/// <summary>Everything that would make the exchange refuse an order, in one line.</summary>
|
||||
public string Restrictions
|
||||
{
|
||||
get
|
||||
{
|
||||
List<string> issues = [];
|
||||
if (TradingBlocked) { issues.Add("trading bloccato"); }
|
||||
if (AccountBlocked) { issues.Add("conto bloccato"); }
|
||||
if (TransfersBlocked) { issues.Add("trasferimenti bloccati"); }
|
||||
if (PatternDayTrader && Equity < 25_000) { issues.Add("PDT sotto i 25.000"); }
|
||||
if (!CanTrade) { issues.Add("operatività bloccata"); }
|
||||
if (MarginRatio > 0.8) { issues.Add("margine vicino alla liquidazione"); }
|
||||
return issues.Count == 0 ? "nessuna" : string.Join(", ", issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One row of the orders page.</summary>
|
||||
/// <summary>One row of the orders list.</summary>
|
||||
public sealed record OrderRow(
|
||||
string OrderId,
|
||||
string Symbol,
|
||||
@@ -129,8 +159,8 @@ public sealed record OrderRow(
|
||||
double FilledQuantity,
|
||||
double FilledAveragePrice,
|
||||
double LimitPrice,
|
||||
DateTime SubmittedUtc,
|
||||
DateTime? FilledUtc)
|
||||
bool ReduceOnly,
|
||||
DateTime SubmittedUtc)
|
||||
{
|
||||
public bool IsWorking { get; init; }
|
||||
|
||||
@@ -138,33 +168,14 @@ public sealed record OrderRow(
|
||||
? FilledQuantity * FilledAveragePrice
|
||||
: Quantity * (double.IsFinite(LimitPrice) && LimitPrice > 0 ? LimitPrice : 0);
|
||||
|
||||
public string SubmittedLocal => SubmittedUtc.ToLocalTime().ToString("dd/MM HH:mm:ss",
|
||||
System.Globalization.CultureInfo.CurrentCulture);
|
||||
}
|
||||
|
||||
/// <summary>Price memory for one charted symbol.</summary>
|
||||
public sealed record PriceSeriesRow(
|
||||
string Symbol,
|
||||
double LastPrice,
|
||||
double SessionOpen,
|
||||
double SessionHigh,
|
||||
double SessionLow,
|
||||
double SessionChangePct,
|
||||
IReadOnlyList<double> BarCloses,
|
||||
IReadOnlyList<double> BarHighs,
|
||||
IReadOnlyList<double> BarLows,
|
||||
IReadOnlyList<double> BarOpens,
|
||||
IReadOnlyList<double> LivePrices)
|
||||
{
|
||||
public bool HasBars => BarCloses.Count > 1;
|
||||
|
||||
public bool HasLive => LivePrices.Count > 1;
|
||||
public string SubmittedLocal =>
|
||||
SubmittedUtc.ToLocalTime().ToString("dd/MM HH:mm:ss", CultureInfo.CurrentCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything the UI renders, in one immutable object built without holding any engine
|
||||
/// lock. Handing off a value rather than exposing live state means repainting the
|
||||
/// window can never perturb or block the trading path.
|
||||
/// lock. Handing off a value rather than exposing live state means repainting the window
|
||||
/// can never perturb or block the trading path.
|
||||
/// </summary>
|
||||
public sealed record BotSnapshot
|
||||
{
|
||||
@@ -178,50 +189,38 @@ public sealed record BotSnapshot
|
||||
|
||||
public required string Mode { get; init; }
|
||||
|
||||
public bool Paper { get; init; }
|
||||
public bool Testnet { get; init; }
|
||||
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
public required string AssetClass { get; init; }
|
||||
|
||||
public required string TimeFrame { get; init; }
|
||||
|
||||
public required string Endpoint { get; init; }
|
||||
|
||||
public int Leverage { get; init; }
|
||||
|
||||
// ---- money -----------------------------------------------------------
|
||||
public double Equity { get; init; }
|
||||
|
||||
public double Cash { get; init; }
|
||||
public double WalletBalance { get; init; }
|
||||
|
||||
public double BuyingPower { get; init; }
|
||||
|
||||
public double PnlToday { get; init; }
|
||||
|
||||
public double PnlTodayPct { get; init; }
|
||||
|
||||
public double PnlSession { get; init; }
|
||||
|
||||
public double PnlSessionPct { get; init; }
|
||||
|
||||
public double PnlAllTime { get; init; }
|
||||
|
||||
public double PnlAllTimePct { get; init; }
|
||||
|
||||
public bool HasAllTime { get; init; }
|
||||
public double AvailableBalance { get; init; }
|
||||
|
||||
public double UnrealizedPnl { get; init; }
|
||||
|
||||
public double RealizedToday { get; init; }
|
||||
|
||||
public double PnlSession { get; init; }
|
||||
|
||||
public double PnlSessionPct { get; init; }
|
||||
|
||||
public double MarginRatio { get; init; }
|
||||
|
||||
public double GrossExposure { get; init; }
|
||||
|
||||
public double ExposurePct { get; init; }
|
||||
|
||||
// ---- session & risk ---------------------------------------------------
|
||||
public required string SessionStatus { get; init; }
|
||||
|
||||
public bool MarketOpen { get; init; }
|
||||
|
||||
// ---- risk -------------------------------------------------------------
|
||||
public bool Halted { get; init; }
|
||||
|
||||
public string? HaltReason { get; init; }
|
||||
@@ -230,30 +229,23 @@ public sealed record BotSnapshot
|
||||
|
||||
public int MaxTradesPerDay { get; init; }
|
||||
|
||||
public int OpenPositions { get; init; }
|
||||
public int OpenPairs { get; init; }
|
||||
|
||||
public int MaxOpenPositions { get; init; }
|
||||
|
||||
public double RiskPerTradePct { get; init; }
|
||||
public int MaxOpenPairs { get; init; }
|
||||
|
||||
public double MaxDailyLossPct { get; init; }
|
||||
|
||||
public required string Sizing { get; init; }
|
||||
|
||||
// ---- plumbing ---------------------------------------------------------
|
||||
public required string MarketDataState { get; init; }
|
||||
|
||||
public required string TradeStreamState { get; init; }
|
||||
public required string OrderStreamState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Why a stream is being refused by the broker, when one is. A reconnect loop is
|
||||
/// otherwise invisible from the window: the state just reads "disconnected" and the
|
||||
/// explanation sits in the log file, which is the last place anyone looks.
|
||||
/// </summary>
|
||||
public string? StreamRejection { get; init; }
|
||||
|
||||
public int Reconnects { get; init; }
|
||||
|
||||
public long Ticks { get; init; }
|
||||
|
||||
public long Quotes { get; init; }
|
||||
|
||||
public long Bars { get; init; }
|
||||
@@ -274,21 +266,18 @@ public sealed record BotSnapshot
|
||||
|
||||
public required string SignalToOrder { get; init; }
|
||||
|
||||
public IReadOnlyList<PositionRow> Positions { get; init; } = [];
|
||||
public IReadOnlyList<PairRow> Pairs { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<SymbolRow> Symbols { get; init; } = [];
|
||||
public IReadOnlyList<PositionRow> Positions { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<EquityPoint> EquityCurve { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<EventRow> Events { get; init; } = [];
|
||||
|
||||
/// <summary>Null until the first successful reconcile against the broker.</summary>
|
||||
/// <summary>Null until the first successful reconcile against the exchange.</summary>
|
||||
public AccountRow? Account { get; init; }
|
||||
|
||||
/// <summary>Named to stay clear of <see cref="Orders"/>, which counts submissions.</summary>
|
||||
public IReadOnlyList<OrderRow> OrderHistory { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<PriceSeriesRow> Prices { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Result of a start/stop/close request from the UI.</summary>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Binance.Rest;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Portfolio;
|
||||
using Encelado.Core.Strategies;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
@@ -11,10 +11,10 @@ namespace Encelado.Bot.Engine;
|
||||
/// Owns the engine's lifecycle so the window can start and stop trading without
|
||||
/// restarting the process, and assembles the snapshot the UI renders.
|
||||
/// <para>
|
||||
/// Each start creates a <b>fresh</b> <see cref="TradingEngine"/>. Reusing one would
|
||||
/// mean resurrecting websockets, warm-up state and risk counters that were built to
|
||||
/// live exactly as long as a session does; a new instance is simpler and cannot leak
|
||||
/// stale state into the next run.
|
||||
/// Each start creates a <b>fresh</b> <see cref="TradingEngine"/>. Reusing one would mean
|
||||
/// resurrecting websockets, calibrations and risk counters that were built to live
|
||||
/// exactly as long as a session does; a new instance is simpler and cannot leak stale
|
||||
/// state into the next run.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
@@ -56,19 +56,19 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
{
|
||||
if (_state is BotState.Running or BotState.Starting)
|
||||
{
|
||||
return new CommandResult(false, "the bot is already running");
|
||||
return new CommandResult(false, "il bot è già in esecuzione");
|
||||
}
|
||||
|
||||
if (_state == BotState.Stopping)
|
||||
{
|
||||
return new CommandResult(false, "the previous run is still shutting down");
|
||||
return new CommandResult(false, "l'esecuzione precedente si sta ancora fermando");
|
||||
}
|
||||
|
||||
_state = BotState.Starting;
|
||||
_error = null;
|
||||
}
|
||||
|
||||
Log.Info("── start requested ──");
|
||||
Log.Info("── avvio richiesto ──");
|
||||
|
||||
TradingEngine engine;
|
||||
try
|
||||
@@ -83,7 +83,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
_error = ex.Message;
|
||||
}
|
||||
|
||||
Log.Error("could not build the engine", ex);
|
||||
Log.Error("non sono riuscito a costruire il motore", ex);
|
||||
return new CommandResult(false, ex.Message);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("engine stopped with an error", ex);
|
||||
Log.Error("il motore si è fermato con un errore", ex);
|
||||
lock (_gate)
|
||||
{
|
||||
_state = BotState.Faulted;
|
||||
@@ -132,7 +132,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"shutdown reported: {ex.Message}");
|
||||
Log.Warn($"arresto: {ex.Message}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -142,15 +142,16 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
_engineTask = task;
|
||||
}
|
||||
|
||||
// Give startup a moment so an immediate failure (bad credentials, blocked
|
||||
// account) surfaces as a returned error instead of silently on the feed.
|
||||
await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(3))).ConfigureAwait(false);
|
||||
// Give startup a moment so an immediate failure (bad credentials, a symbol the
|
||||
// exchange does not list) surfaces as a returned error instead of silently on
|
||||
// the activity feed.
|
||||
await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == BotState.Faulted)
|
||||
{
|
||||
return new CommandResult(false, _error ?? "the engine failed to start");
|
||||
return new CommandResult(false, _error ?? "il motore non è riuscito a partire");
|
||||
}
|
||||
|
||||
if (_state == BotState.Starting)
|
||||
@@ -159,7 +160,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
return new CommandResult(true, "bot started");
|
||||
return new CommandResult(true, "bot avviato");
|
||||
}
|
||||
|
||||
public async Task<CommandResult> StopAsync()
|
||||
@@ -171,7 +172,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
{
|
||||
if (_state is BotState.Stopped or BotState.Stopping)
|
||||
{
|
||||
return new CommandResult(false, "the bot is not running");
|
||||
return new CommandResult(false, "il bot non è in esecuzione");
|
||||
}
|
||||
|
||||
_state = BotState.Stopping;
|
||||
@@ -179,7 +180,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
task = _engineTask;
|
||||
}
|
||||
|
||||
Log.Info("── stop requested ──");
|
||||
Log.Info("── arresto richiesto ──");
|
||||
|
||||
if (cts is not null)
|
||||
{
|
||||
@@ -194,7 +195,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Log.Warn("the engine did not stop within 45s");
|
||||
Log.Warn("il motore non si è fermato entro 45s");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,12 +215,12 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
|
||||
cts?.Dispose();
|
||||
Log.Info("bot stopped");
|
||||
return new CommandResult(true, "bot stopped");
|
||||
Log.Info("bot fermo");
|
||||
return new CommandResult(true, "bot fermo");
|
||||
}
|
||||
|
||||
/// <summary>Closes one position on demand from the positions grid.</summary>
|
||||
public async Task<CommandResult> ClosePositionAsync(string symbol, CancellationToken ct)
|
||||
/// <summary>Closes one pair on demand from the pairs list.</summary>
|
||||
public async Task<CommandResult> ClosePairAsync(string pairName, CancellationToken ct)
|
||||
{
|
||||
TradingEngine? engine;
|
||||
lock (_gate)
|
||||
@@ -229,23 +230,37 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
|
||||
if (engine is null)
|
||||
{
|
||||
return new CommandResult(false, "the bot is not running");
|
||||
return new CommandResult(false, "il bot non è in esecuzione");
|
||||
}
|
||||
|
||||
if (config.Engine.DryRun)
|
||||
{
|
||||
return new CommandResult(false, "dry-run mode: no order was sent");
|
||||
return new CommandResult(false, "modalità dry-run: nessun ordine è stato inviato");
|
||||
}
|
||||
|
||||
PairPipeline? pair = null;
|
||||
foreach (PairPipeline candidate in engine.Pairs)
|
||||
{
|
||||
if (candidate.Name.Equals(pairName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
pair = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pair is null)
|
||||
{
|
||||
return new CommandResult(false, $"coppia '{pairName}' non trovata");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await engine.Router.FlattenSymbolAsync(symbol, "closed manually from the app", ct)
|
||||
.ConfigureAwait(false);
|
||||
return new CommandResult(true, $"{symbol} close requested");
|
||||
await engine.Router.CloseAsync(pair, "chiusa a mano dall'applicazione", ct).ConfigureAwait(false);
|
||||
return new CommandResult(true, $"{pairName}: chiusura richiesta");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"manual close of {symbol} failed", ex);
|
||||
Log.Error($"chiusura manuale di {pairName} fallita", ex);
|
||||
return new CommandResult(false, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -277,61 +292,57 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
State = state,
|
||||
Error = error,
|
||||
Mode = DescribeMode(),
|
||||
Paper = config.Alpaca.Paper,
|
||||
Testnet = config.Binance.Testnet,
|
||||
DryRun = config.Engine.DryRun,
|
||||
AssetClass = config.Engine.AssetClass,
|
||||
TimeFrame = config.Engine.TimeFrame,
|
||||
Endpoint = config.Alpaca.TradingBaseUrl,
|
||||
SessionStatus = "engine stopped",
|
||||
MarketDataState = "disconnected",
|
||||
TradeStreamState = "disconnected",
|
||||
Endpoint = config.Binance.RestBaseUrl,
|
||||
Leverage = config.Binance.Leverage,
|
||||
MarketDataState = "disconnesso",
|
||||
OrderStreamState = "disconnesso",
|
||||
BarToSignal = "—",
|
||||
SignalToOrder = "—",
|
||||
MaxOpenPositions = config.Risk.MaxOpenPositions,
|
||||
MaxOpenPairs = config.Risk.MaxOpenPairs,
|
||||
MaxTradesPerDay = config.Risk.MaxTradesPerDay,
|
||||
RiskPerTradePct = config.Risk.MaxRiskPerTradePct,
|
||||
MaxDailyLossPct = config.Risk.MaxDailyLossPct,
|
||||
Symbols = IdleSymbols(),
|
||||
Sizing = config.Risk.DescribeSizing(),
|
||||
Pairs = IdlePairs(),
|
||||
EquityCurve = SnapshotEquityCurve(),
|
||||
Events = SnapshotEvents(),
|
||||
Prices = IdlePrices(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Empty price rows for the configured symbols while the engine is stopped. Without
|
||||
/// them the prices panel says "nessun asset configurato", which is false and reads
|
||||
/// as a configuration problem rather than as "the bot is not running".
|
||||
/// Placeholder rows for the configured pairs while the engine is stopped, so the
|
||||
/// page shows what it <i>would</i> trade rather than an empty list that reads as a
|
||||
/// configuration problem.
|
||||
/// </summary>
|
||||
private IReadOnlyList<PriceSeriesRow> IdlePrices()
|
||||
private IReadOnlyList<PairRow> IdlePairs()
|
||||
{
|
||||
List<PriceSeriesRow> rows = [];
|
||||
foreach (SymbolConfig sc in config.EnabledSymbols)
|
||||
{
|
||||
rows.Add(new PriceSeriesRow(sc.Symbol, 0, 0, 0, 0, 0, [], [], [], [], []));
|
||||
}
|
||||
List<PairRow> rows = [];
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private IReadOnlyList<SymbolRow> IdleSymbols()
|
||||
foreach (PairConfig pc in config.EnabledPairs)
|
||||
{
|
||||
List<SymbolRow> views = [];
|
||||
foreach (SymbolConfig sc in config.EnabledSymbols)
|
||||
{
|
||||
int warmup = 0;
|
||||
StatArbStrategy? strategy = null;
|
||||
try
|
||||
{
|
||||
warmup = StrategyFactory.Create(sc.Strategy, sc.ToStrategyParameters()).WarmupBars;
|
||||
strategy = new StatArbStrategy(pc.ToStrategyParameters());
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// A misconfigured strategy is reported by validation, not here.
|
||||
// A misconfigured pair is reported by validation, not here.
|
||||
}
|
||||
|
||||
views.Add(new SymbolRow(sc.Symbol, sc.Strategy, false, 0, warmup, 0, 0, 0, 0, 0, false, []));
|
||||
rows.Add(new PairRow(
|
||||
pc.Name,
|
||||
PairConfig.Normalize(pc.SymbolA),
|
||||
PairConfig.Normalize(pc.SymbolB),
|
||||
false, double.NaN, double.NaN, double.NaN, double.NaN, false,
|
||||
strategy?.EntryZ ?? 2, strategy?.ExitZ ?? 0.2, strategy?.StopZ ?? 3.5,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
"il bot è fermo",
|
||||
null));
|
||||
}
|
||||
|
||||
return views;
|
||||
return rows;
|
||||
}
|
||||
|
||||
private BotSnapshot LiveSnapshot(TradingEngine engine, BotState state, string? error)
|
||||
@@ -342,63 +353,64 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
SampleEquity(equity);
|
||||
|
||||
double sessionBase = engine.StartEquity;
|
||||
double todayBase = engine.PreviousCloseEquity;
|
||||
AlpacaPortfolioHistory history = engine.PortfolioHistory;
|
||||
|
||||
List<PairRow> pairs = [];
|
||||
List<PositionRow> positions = [];
|
||||
foreach (Position p in engine.Book.Positions)
|
||||
|
||||
IReadOnlyDictionary<string, string> refusals = engine.Router.LastRefusal;
|
||||
|
||||
foreach (PairPipeline pipe in engine.Pairs)
|
||||
{
|
||||
if (!p.IsOpen)
|
||||
PairPositionView view = pipe.PositionView(engine.Book);
|
||||
StatArbStrategy s = pipe.Strategy;
|
||||
PairCalibration c = s.Calibration;
|
||||
|
||||
PositionView legA = engine.Book.View(pipe.LegA.Symbol);
|
||||
PositionView legB = engine.Book.View(pipe.LegB.Symbol);
|
||||
|
||||
string intent = s.Explain(view);
|
||||
|
||||
string? refusal = null;
|
||||
lock (refusals)
|
||||
{
|
||||
continue;
|
||||
if (refusals.TryGetValue(pipe.Name, out string? found))
|
||||
{
|
||||
refusal = found;
|
||||
}
|
||||
}
|
||||
|
||||
positions.Add(new PositionRow(
|
||||
p.Symbol,
|
||||
p.Side == Core.Market.Side.Buy ? "long" : "short",
|
||||
p.Quantity,
|
||||
p.AverageEntryPrice,
|
||||
p.LastPrice,
|
||||
p.MarketValue,
|
||||
p.UnrealizedPnl,
|
||||
p.UnrealizedPnlPct,
|
||||
p.StopPrice,
|
||||
p.TargetPrice,
|
||||
p.BarsHeld,
|
||||
p.OpenedAtUtc));
|
||||
pairs.Add(new PairRow(
|
||||
pipe.Name,
|
||||
pipe.LegA.Symbol,
|
||||
pipe.LegB.Symbol,
|
||||
s.IsReady,
|
||||
s.ZScore,
|
||||
c.Beta,
|
||||
c.PValue,
|
||||
c.HalfLifeBars,
|
||||
c.PValue <= s.MaxPValue,
|
||||
s.EntryZ,
|
||||
s.ExitZ,
|
||||
s.StopZ,
|
||||
view.QuantityA,
|
||||
view.QuantityB,
|
||||
view.EntryZScore,
|
||||
view.BarsHeld,
|
||||
legA.UnrealizedPnl + legB.UnrealizedPnl,
|
||||
pipe.LegA.LastPrice,
|
||||
pipe.LegB.LastPrice,
|
||||
pipe.NetFundingRate(view.QuantityA < 0 ? Core.Market.Side.Sell : Core.Market.Side.Buy, c.Beta),
|
||||
intent,
|
||||
refusal));
|
||||
|
||||
AddPosition(positions, pipe, legA, pipe.LegA.Symbol);
|
||||
AddPosition(positions, pipe, legB, pipe.LegB.Symbol);
|
||||
}
|
||||
|
||||
positions.Sort(static (a, b) => Math.Abs(b.MarketValue).CompareTo(Math.Abs(a.MarketValue)));
|
||||
|
||||
List<SymbolRow> symbols = [];
|
||||
foreach (SymbolPipeline pipe in engine.Pipelines)
|
||||
{
|
||||
List<MetricRow> metrics = [];
|
||||
foreach (StrategyMetric m in pipe.Strategy.Diagnostics)
|
||||
{
|
||||
metrics.Add(new MetricRow(m.Name, double.IsFinite(m.Value) ? m.Value : 0, m.Format));
|
||||
}
|
||||
|
||||
Core.Market.Quote quote = pipe.LastQuote;
|
||||
double age = pipe.QuoteAge == TimeSpan.MaxValue ? -1 : pipe.QuoteAge.TotalSeconds;
|
||||
|
||||
symbols.Add(new SymbolRow(
|
||||
pipe.Symbol,
|
||||
pipe.Strategy.Name,
|
||||
pipe.Strategy.IsReady,
|
||||
pipe.BarsSeen,
|
||||
pipe.Strategy.WarmupBars,
|
||||
pipe.LastPrice,
|
||||
quote.IsValid ? quote.BidPrice : 0,
|
||||
quote.IsValid ? quote.AskPrice : 0,
|
||||
quote.IsValid ? quote.RelativeSpread : 0,
|
||||
age,
|
||||
!engine.Book.View(pipe.Symbol).IsFlat,
|
||||
metrics));
|
||||
}
|
||||
|
||||
double unrealized = engine.Book.TotalUnrealizedPnl;
|
||||
double exposure = engine.Book.GrossExposure;
|
||||
int openPairs = pairs.Count(static p => p.IsOpen);
|
||||
|
||||
return new BotSnapshot
|
||||
{
|
||||
@@ -410,43 +422,36 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
: DateTime.UtcNow - engine.StartedAtUtc,
|
||||
|
||||
Mode = DescribeMode(),
|
||||
Paper = config.Alpaca.Paper,
|
||||
Testnet = config.Binance.Testnet,
|
||||
DryRun = config.Engine.DryRun,
|
||||
AssetClass = config.Engine.AssetClass,
|
||||
TimeFrame = config.Engine.TimeFrame,
|
||||
Endpoint = config.Alpaca.TradingBaseUrl,
|
||||
Endpoint = config.Binance.RestBaseUrl,
|
||||
Leverage = config.Binance.Leverage,
|
||||
|
||||
Equity = equity,
|
||||
Cash = account.Cash,
|
||||
BuyingPower = account.BuyingPower,
|
||||
PnlToday = todayBase > 0 ? equity - todayBase : 0,
|
||||
PnlTodayPct = todayBase > 0 ? (equity - todayBase) / todayBase : 0,
|
||||
WalletBalance = account.WalletBalance,
|
||||
AvailableBalance = account.AvailableBalance,
|
||||
UnrealizedPnl = account.UnrealizedPnl,
|
||||
RealizedToday = engine.Risk.DailyRealizedPnl,
|
||||
PnlSession = sessionBase > 0 ? equity - sessionBase : 0,
|
||||
PnlSessionPct = sessionBase > 0 ? (equity - sessionBase) / sessionBase : 0,
|
||||
PnlAllTime = history.TotalProfitLoss,
|
||||
PnlAllTimePct = history.TotalProfitLossPct,
|
||||
HasAllTime = history.HasData && history.BaseValue > 0,
|
||||
UnrealizedPnl = unrealized,
|
||||
RealizedToday = engine.Risk.DailyRealizedPnl,
|
||||
MarginRatio = account.MarginRatio,
|
||||
GrossExposure = exposure,
|
||||
ExposurePct = equity > 0 ? exposure / equity : 0,
|
||||
|
||||
SessionStatus = engine.Session.Describe(),
|
||||
MarketOpen = engine.Session.IsOpen,
|
||||
Halted = engine.Risk.IsHalted,
|
||||
HaltReason = engine.Risk.IsHalted ? engine.Risk.HaltReason : null,
|
||||
TradesToday = engine.Risk.TradesToday,
|
||||
MaxTradesPerDay = config.Risk.MaxTradesPerDay,
|
||||
OpenPositions = positions.Count,
|
||||
MaxOpenPositions = config.Risk.MaxOpenPositions,
|
||||
RiskPerTradePct = config.Risk.MaxRiskPerTradePct,
|
||||
OpenPairs = openPairs,
|
||||
MaxOpenPairs = config.Risk.MaxOpenPairs,
|
||||
MaxDailyLossPct = config.Risk.MaxDailyLossPct,
|
||||
Sizing = config.Risk.DescribeSizing(),
|
||||
|
||||
MarketDataState = engine.MarketData.State.ToString().ToLowerInvariant(),
|
||||
TradeStreamState = engine.TradeUpdates.State.ToString().ToLowerInvariant(),
|
||||
StreamRejection = engine.MarketData.RejectionReason ?? engine.TradeUpdates.RejectionReason,
|
||||
OrderStreamState = engine.UserData.State.ToString().ToLowerInvariant(),
|
||||
StreamRejection = engine.MarketData.RejectionReason ?? engine.UserData.RejectionReason,
|
||||
Reconnects = Math.Max(0, engine.MarketData.ConnectCount - 1),
|
||||
Ticks = engine.Metrics.Trades,
|
||||
Quotes = engine.Metrics.Quotes,
|
||||
Bars = engine.Metrics.Bars,
|
||||
Signals = engine.Metrics.Signals,
|
||||
@@ -458,71 +463,80 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
BarToSignal = engine.Metrics.BarToSignal.Summary(),
|
||||
SignalToOrder = engine.Metrics.SignalToOrder.Summary(),
|
||||
|
||||
Pairs = pairs,
|
||||
Positions = positions,
|
||||
Symbols = symbols,
|
||||
EquityCurve = SnapshotEquityCurve(),
|
||||
Events = SnapshotEvents(),
|
||||
Account = BuildAccount(account),
|
||||
OrderHistory = BuildOrders(engine),
|
||||
Prices = BuildPrices(engine),
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddPosition(
|
||||
List<PositionRow> into, PairPipeline pipe, in PositionView view, string symbol)
|
||||
{
|
||||
if (view.Quantity == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
into.Add(new PositionRow(
|
||||
symbol,
|
||||
pipe.Name,
|
||||
view.Quantity > 0 ? "long" : "short",
|
||||
view.Quantity,
|
||||
view.AverageEntryPrice,
|
||||
view.LastPrice,
|
||||
view.Quantity * view.LastPrice,
|
||||
view.UnrealizedPnl,
|
||||
view.UnrealizedPnlPct,
|
||||
pipe.OpenedAtUtc));
|
||||
}
|
||||
|
||||
private static AccountRow? BuildAccount(AccountState state)
|
||||
{
|
||||
// Before the first reconcile there is no truthful account to show. Returning
|
||||
// null lets the page say "in attesa" instead of rendering a zeroed-out account
|
||||
// that reads like a wiped-out one.
|
||||
if (state.Raw is not { } a)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AccountRow(
|
||||
a.AccountNumber,
|
||||
a.Status,
|
||||
a.Currency,
|
||||
(double)a.Equity,
|
||||
(double)a.LastEquity,
|
||||
(double)a.Cash,
|
||||
(double)a.PortfolioValue,
|
||||
(double)a.BuyingPower,
|
||||
(double)a.DaytradingBuyingPower,
|
||||
(double)a.Multiplier,
|
||||
a.DaytradeCount,
|
||||
a.PatternDayTrader,
|
||||
a.TradingBlocked,
|
||||
a.AccountBlocked,
|
||||
a.TransfersBlocked,
|
||||
a.ShortingEnabled,
|
||||
(double)a.WalletBalance,
|
||||
(double)a.MarginBalance,
|
||||
(double)a.AvailableBalance,
|
||||
(double)a.UnrealizedPnl,
|
||||
(double)a.MaintenanceMargin,
|
||||
a.MarginRatio,
|
||||
a.FeeTier,
|
||||
a.CanTrade,
|
||||
a.MultiAssetsMargin,
|
||||
state.LastUpdateUtc);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<OrderRow> BuildOrders(TradingEngine engine)
|
||||
{
|
||||
IReadOnlyList<AlpacaOrder> source = engine.RecentOrders;
|
||||
IReadOnlyList<BinanceOrder> source = engine.RecentOrders;
|
||||
List<OrderRow> rows = new(source.Count);
|
||||
|
||||
foreach (AlpacaOrder o in source)
|
||||
foreach (BinanceOrder o in source)
|
||||
{
|
||||
rows.Add(new OrderRow(
|
||||
o.Id,
|
||||
o.Id.ToString(CultureInfo.InvariantCulture),
|
||||
o.Symbol,
|
||||
o.Side == Core.Market.Side.Buy ? "acquisto" : "vendita",
|
||||
o.Type,
|
||||
DescribeStatus(o.Status),
|
||||
o.Quantity,
|
||||
o.FilledQuantity,
|
||||
o.FilledAveragePrice,
|
||||
o.AverageFillPrice,
|
||||
o.LimitPrice,
|
||||
o.SubmittedAtUtc,
|
||||
o.FilledAtUtc)
|
||||
o.ReduceOnly,
|
||||
o.SubmittedUtc)
|
||||
{
|
||||
IsWorking = o.IsWorking,
|
||||
});
|
||||
}
|
||||
|
||||
rows.Sort(static (a, b) => b.SubmittedUtc.CompareTo(a.SubmittedUtc));
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -533,53 +547,12 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
OrderStatus.Canceled => "annullato",
|
||||
OrderStatus.Expired => "scaduto",
|
||||
OrderStatus.Rejected => "rifiutato",
|
||||
OrderStatus.New or OrderStatus.Accepted or OrderStatus.PendingNew => "in attesa",
|
||||
OrderStatus.New => "in attesa",
|
||||
_ => status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
private static IReadOnlyList<PriceSeriesRow> BuildPrices(TradingEngine engine)
|
||||
{
|
||||
List<PriceSeriesRow> rows = [];
|
||||
|
||||
foreach (SymbolPipeline pipe in engine.Pipelines)
|
||||
{
|
||||
PriceSnapshot snap = pipe.History.Snapshot();
|
||||
|
||||
int n = snap.Bars.Count;
|
||||
double[] closes = new double[n];
|
||||
double[] highs = new double[n];
|
||||
double[] lows = new double[n];
|
||||
double[] opens = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
Core.Market.Bar b = snap.Bars[i];
|
||||
closes[i] = b.Close;
|
||||
highs[i] = b.High;
|
||||
lows[i] = b.Low;
|
||||
opens[i] = b.Open;
|
||||
}
|
||||
|
||||
double[] live = new double[snap.Ticks.Count];
|
||||
for (int i = 0; i < live.Length; i++)
|
||||
{
|
||||
live[i] = snap.Ticks[i].Price;
|
||||
}
|
||||
|
||||
rows.Add(new PriceSeriesRow(
|
||||
pipe.Symbol,
|
||||
pipe.LastPrice,
|
||||
snap.SessionOpen,
|
||||
snap.SessionHigh,
|
||||
snap.SessionLow,
|
||||
snap.SessionChangePct,
|
||||
closes, highs, lows, opens, live));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private string DescribeMode() =>
|
||||
config.Engine.DryRun ? "DRY-RUN" : config.Alpaca.Paper ? "PAPER" : "LIVE";
|
||||
config.Engine.DryRun ? "DRY-RUN" : config.Binance.Testnet ? "TESTNET" : "REALE";
|
||||
|
||||
private void SampleEquity(double equity)
|
||||
{
|
||||
@@ -623,8 +596,6 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Fully qualified: the Web SDK's implicit usings also bring in
|
||||
// Microsoft.Extensions.Logging.LogLevel.
|
||||
private void RecordEvent(Logging.LogLevel level, DateTime timestamp, string message)
|
||||
{
|
||||
EventRow view = new(
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using Encelado.Alpaca;
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Diagnostics;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>A decision handed from the market-data thread to the order path.</summary>
|
||||
public readonly record struct ExecutionIntent(
|
||||
int SymbolId,
|
||||
string Symbol,
|
||||
Signal Signal,
|
||||
double ReferencePrice,
|
||||
long EnqueuedTimestamp,
|
||||
long DecisionId);
|
||||
|
||||
/// <summary>
|
||||
/// Turns approved signals into Alpaca orders. Strategies never touch the broker: they
|
||||
/// publish intents, this class serialises them through a single consumer so sizing,
|
||||
/// risk checks and submission can never interleave for the same symbol.
|
||||
/// </summary>
|
||||
public sealed class ExecutionRouter(
|
||||
AlpacaTradingClient trading,
|
||||
PortfolioBook book,
|
||||
RiskEngine risk,
|
||||
AccountState account,
|
||||
SessionGuard session,
|
||||
EngineOptions options,
|
||||
Metrics metrics,
|
||||
TradeJournal journal,
|
||||
AnalyticsLog analytics,
|
||||
SymbolPipeline?[] pipelines)
|
||||
{
|
||||
private readonly Channel<ExecutionIntent> _queue = Channel.CreateUnbounded<ExecutionIntent>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
|
||||
private readonly bool _isEquity = options.ResolvedAssetClass == AssetClass.UsEquity;
|
||||
private Task? _consumer;
|
||||
private int _sequence;
|
||||
|
||||
public int QueueDepth { get; private set; }
|
||||
|
||||
public void Start(CancellationToken ct) => _consumer ??= Task.Run(() => ConsumeAsync(ct), CancellationToken.None);
|
||||
|
||||
public bool Enqueue(in ExecutionIntent intent) => _queue.Writer.TryWrite(intent);
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_queue.Writer.TryComplete();
|
||||
if (_consumer is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _consumer.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutdown.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConsumeAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (ExecutionIntent intent in _queue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
QueueDepth = _queue.Reader.Count;
|
||||
try
|
||||
{
|
||||
if (intent.Signal.Kind == SignalKind.Exit)
|
||||
{
|
||||
await HandleExitAsync(intent, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleEntryAsync(intent, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
metrics.CountOrderError();
|
||||
Log.Error($"{intent.Symbol}: execution failed", ex);
|
||||
|
||||
SymbolPipeline? pipe = Pipeline(intent.SymbolId);
|
||||
pipe?.ReleaseEntry();
|
||||
pipe?.ReleaseExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Entries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async Task HandleEntryAsync(ExecutionIntent intent, CancellationToken ct)
|
||||
{
|
||||
SymbolPipeline? pipe = Pipeline(intent.SymbolId);
|
||||
if (pipe is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.CanOpenNewPositions)
|
||||
{
|
||||
Log.Debug($"{intent.Symbol}: entry skipped, session not accepting new positions ({session.Describe()})");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account.HasData)
|
||||
{
|
||||
Log.Warn($"{intent.Symbol}: entry skipped, no account snapshot yet");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account.CanTrade)
|
||||
{
|
||||
Log.Warn($"{intent.Symbol}: entry skipped, the broker has blocked trading on this account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isEquity && account.IsDayTradeBlocked)
|
||||
{
|
||||
Log.Warn($"{intent.Symbol}: entry skipped, PDT flag with equity below $25,000");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.MaxQuoteAgeSeconds > 0 && pipe.QuoteAge > TimeSpan.FromSeconds(options.MaxQuoteAgeSeconds))
|
||||
{
|
||||
Log.Debug($"{intent.Symbol}: entry skipped, top-of-book is {pipe.QuoteAge.TotalSeconds:F0}s stale");
|
||||
return;
|
||||
}
|
||||
|
||||
PositionView position = book.View(intent.Symbol);
|
||||
if (!position.IsFlat)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pipe.TryClaimEntry())
|
||||
{
|
||||
Log.Debug($"{intent.Symbol}: entry skipped, another entry is already in flight");
|
||||
return;
|
||||
}
|
||||
|
||||
bool submitted = false;
|
||||
try
|
||||
{
|
||||
Side side = intent.Signal.EntrySide;
|
||||
double reference = pipe.EntryReferencePrice(side);
|
||||
if (reference <= 0)
|
||||
{
|
||||
reference = intent.ReferencePrice;
|
||||
}
|
||||
|
||||
EntryRequest request = new(
|
||||
intent.Symbol,
|
||||
side,
|
||||
reference,
|
||||
intent.Signal.StopPrice,
|
||||
intent.Signal.Strength,
|
||||
account.Equity,
|
||||
account.BuyingPower,
|
||||
book.GrossExposure,
|
||||
book.OpenPositionCount,
|
||||
position.Quantity,
|
||||
pipe.LastQuote.IsValid ? pipe.LastQuote.RelativeSpread : 0,
|
||||
options.AllowFractionalShares,
|
||||
DateTime.UtcNow);
|
||||
|
||||
RiskVerdict verdict = risk.ApproveEntry(request);
|
||||
|
||||
analytics.Execution(
|
||||
intent.DecisionId, intent.Symbol, side, "risk", verdict.Approved,
|
||||
verdict.Reason.ToString(), verdict.Detail,
|
||||
verdict.Quantity, reference, verdict.StopPrice, intent.Signal.TargetPrice,
|
||||
account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount,
|
||||
null, null, Stopwatch.GetElapsedTime(intent.EnqueuedTimestamp).TotalMilliseconds);
|
||||
|
||||
if (!verdict.Approved)
|
||||
{
|
||||
metrics.CountRiskReject();
|
||||
Log.Debug($"{intent.Symbol}: {side} rejected by risk [{verdict.Reason}] {verdict.Detail}");
|
||||
return;
|
||||
}
|
||||
|
||||
NewOrder order = BuildEntryOrder(intent, side, verdict, reference, out double basePrice);
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"[DRY-RUN] {intent.Symbol} {side} {verdict.Quantity:0.####} @ ~{basePrice:F2} " +
|
||||
$"stop={Fmt(order.StopLossStopPrice)} target={Fmt(order.TakeProfitLimitPrice)} :: {intent.Signal.Reason}"));
|
||||
journal.Record("dry-run-entry", intent.Symbol, side, verdict.Quantity, basePrice,
|
||||
intent.Signal.Reason, null, order.StopLossStopPrice, order.TakeProfitLimitPrice, account.Equity);
|
||||
return;
|
||||
}
|
||||
|
||||
long submitStart = Stopwatch.GetTimestamp();
|
||||
AlpacaOrder placed = await trading.SubmitOrderAsync(order, ct).ConfigureAwait(false);
|
||||
metrics.SignalToOrder.RecordSince(intent.EnqueuedTimestamp);
|
||||
metrics.CountOrderSubmitted();
|
||||
submitted = true;
|
||||
|
||||
risk.RecordEntry(intent.Symbol, DateTime.UtcNow);
|
||||
|
||||
// Without a broker-side bracket the engine has to police the stop itself.
|
||||
if (!order.HasBracket)
|
||||
{
|
||||
pipe.LocalStop = verdict.StopPrice;
|
||||
pipe.LocalTarget = intent.Signal.TargetPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
pipe.ClearProtection();
|
||||
}
|
||||
|
||||
book.SetProtection(intent.Symbol, verdict.StopPrice, intent.Signal.TargetPrice);
|
||||
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"ENTRY {intent.Symbol} {side} {verdict.Quantity:0.####} @ ~{basePrice:F2} " +
|
||||
$"stop={Fmt(order.StopLossStopPrice)} target={Fmt(order.TakeProfitLimitPrice)} " +
|
||||
$"[{Stopwatch.GetElapsedTime(submitStart).TotalMilliseconds:F0}ms] :: {intent.Signal.Reason}"));
|
||||
|
||||
journal.Record("entry", intent.Symbol, side, verdict.Quantity, basePrice, intent.Signal.Reason,
|
||||
placed.Id, order.StopLossStopPrice, order.TakeProfitLimitPrice, account.Equity);
|
||||
|
||||
analytics.Execution(
|
||||
intent.DecisionId, intent.Symbol, side, "order", true, "None", intent.Signal.Reason,
|
||||
verdict.Quantity, basePrice, order.StopLossStopPrice, order.TakeProfitLimitPrice,
|
||||
account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount,
|
||||
placed.Id, null, Stopwatch.GetElapsedTime(submitStart).TotalMilliseconds);
|
||||
}
|
||||
catch (AlpacaApiException ex)
|
||||
{
|
||||
metrics.CountOrderError();
|
||||
Log.Error($"{intent.Symbol}: order rejected by Alpaca ({ex.StatusCode})", ex);
|
||||
|
||||
analytics.Execution(
|
||||
intent.DecisionId, intent.Symbol, intent.Signal.EntrySide, "order", false,
|
||||
$"Http{ex.StatusCode}", ex.Message, 0, intent.ReferencePrice, double.NaN, double.NaN,
|
||||
account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount,
|
||||
null, ex.Message, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The in-flight latch is only held while an order is actually working.
|
||||
if (!submitted)
|
||||
{
|
||||
pipe.ReleaseEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private NewOrder BuildEntryOrder(
|
||||
ExecutionIntent intent,
|
||||
Side side,
|
||||
RiskVerdict verdict,
|
||||
double reference,
|
||||
out double basePrice)
|
||||
{
|
||||
bool useLimit = options.UseLimitEntries;
|
||||
double offset = options.LimitOffsetBps / 10_000.0;
|
||||
|
||||
// A marketable limit: priced through the touch so it fills like a market order
|
||||
// but can never fill at an absurd print.
|
||||
double limitPrice = side == Side.Buy
|
||||
? reference * (1 + offset)
|
||||
: reference * (1 - offset);
|
||||
|
||||
basePrice = useLimit ? limitPrice : reference;
|
||||
|
||||
bool wholeShares = Math.Abs(verdict.Quantity - Math.Floor(verdict.Quantity)) < 1e-9;
|
||||
bool bracketAllowed = options.UseBracketOrders && _isEquity && wholeShares;
|
||||
|
||||
double stop = double.NaN;
|
||||
double target = double.NaN;
|
||||
|
||||
if (bracketAllowed)
|
||||
{
|
||||
double tick = basePrice >= 1 ? 0.01 : 0.0001;
|
||||
|
||||
if (!double.IsNaN(verdict.StopPrice) && verdict.StopPrice > 0)
|
||||
{
|
||||
stop = side == Side.Buy
|
||||
? Math.Min(verdict.StopPrice, basePrice - tick)
|
||||
: Math.Max(verdict.StopPrice, basePrice + tick);
|
||||
if (stop <= 0)
|
||||
{
|
||||
stop = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
double signalTarget = intent.Signal.TargetPrice;
|
||||
if (!double.IsNaN(signalTarget) && signalTarget > 0)
|
||||
{
|
||||
target = side == Side.Buy
|
||||
? Math.Max(signalTarget, basePrice + tick)
|
||||
: Math.Min(signalTarget, basePrice - tick);
|
||||
if (target <= 0)
|
||||
{
|
||||
target = double.NaN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new NewOrder
|
||||
{
|
||||
Symbol = intent.Symbol,
|
||||
Side = side,
|
||||
Quantity = verdict.Quantity,
|
||||
Type = useLimit ? OrderType.Limit : OrderType.Market,
|
||||
LimitPrice = useLimit ? limitPrice : double.NaN,
|
||||
TimeInForce = _isEquity ? TimeInForce.Day : TimeInForce.GoodTillCanceled,
|
||||
ClientOrderId = NextClientOrderId(intent.Symbol),
|
||||
StopLossStopPrice = stop,
|
||||
TakeProfitLimitPrice = target,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exits
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async Task HandleExitAsync(ExecutionIntent intent, CancellationToken ct)
|
||||
{
|
||||
PositionView position = book.View(intent.Symbol);
|
||||
SymbolPipeline? pipe = Pipeline(intent.SymbolId);
|
||||
|
||||
try
|
||||
{
|
||||
if (position.IsFlat)
|
||||
{
|
||||
pipe?.ClearProtection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"[DRY-RUN] EXIT {intent.Symbol} {position.Quantity:0.####} @ ~{intent.ReferencePrice:F2} :: {intent.Signal.Reason}"));
|
||||
journal.Record("dry-run-exit", intent.Symbol, position.Side.Opposite(),
|
||||
Math.Abs(position.Quantity), intent.ReferencePrice, intent.Signal.Reason);
|
||||
return;
|
||||
}
|
||||
|
||||
await FlattenSymbolAsync(intent.Symbol, intent.Signal.Reason, ct).ConfigureAwait(false);
|
||||
pipe?.ClearProtection();
|
||||
}
|
||||
finally
|
||||
{
|
||||
pipe?.ReleaseExit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the symbol's working orders and liquidates it at market. The cancel is
|
||||
/// required first: a resting bracket leg reserves the shares and would make the
|
||||
/// liquidation fail with "insufficient qty available".
|
||||
/// </summary>
|
||||
public async Task FlattenSymbolAsync(string symbol, string reason, CancellationToken ct)
|
||||
{
|
||||
PositionView position = book.View(symbol);
|
||||
|
||||
try
|
||||
{
|
||||
List<AlpacaOrder> open = await trading.ListOrdersAsync("open", 100, symbol, ct).ConfigureAwait(false);
|
||||
foreach (AlpacaOrder order in open)
|
||||
{
|
||||
if (order.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await trading.CancelOrderAsync(order.Id, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (AlpacaApiException ex)
|
||||
{
|
||||
Log.Warn($"{symbol}: could not cancel working orders before flattening: {ex.Message}");
|
||||
}
|
||||
|
||||
AlpacaOrder? closing = await trading.ClosePositionAsync(symbol, null, ct).ConfigureAwait(false);
|
||||
metrics.CountExit();
|
||||
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"EXIT {symbol} {position.Quantity:0.####} @ ~{position.LastPrice:F2} " +
|
||||
$"pnl={position.UnrealizedPnl:F2} :: {reason}"));
|
||||
|
||||
journal.Record("exit", symbol, position.Side.Opposite(), Math.Abs(position.Quantity),
|
||||
position.LastPrice, reason, closing?.Id, equity: account.Equity,
|
||||
realizedPnl: position.UnrealizedPnl);
|
||||
|
||||
Pipeline(symbol)?.ReleaseEntry();
|
||||
}
|
||||
|
||||
/// <summary>Cancels every working order and liquidates the whole book.</summary>
|
||||
public async Task FlattenAllAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
if (options.DryRun)
|
||||
{
|
||||
Log.Info($"[DRY-RUN] flatten-all requested :: {reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Warn($"flattening the entire book :: {reason}");
|
||||
|
||||
try
|
||||
{
|
||||
await trading.CancelAllOrdersAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (AlpacaApiException ex)
|
||||
{
|
||||
Log.Warn($"cancel-all failed: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await trading.CloseAllPositionsAsync(cancelOrders: true, ct).ConfigureAwait(false);
|
||||
journal.Record("flatten-all", "*", Side.None, 0, 0, reason, equity: account.Equity);
|
||||
}
|
||||
catch (AlpacaApiException ex)
|
||||
{
|
||||
Log.Error($"close-all failed: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
foreach (SymbolPipeline? pipe in pipelines)
|
||||
{
|
||||
pipe?.ClearProtection();
|
||||
pipe?.ReleaseEntry();
|
||||
pipe?.ReleaseExit();
|
||||
}
|
||||
}
|
||||
|
||||
private SymbolPipeline? Pipeline(int id) =>
|
||||
(uint)id < (uint)pipelines.Length ? pipelines[id] : null;
|
||||
|
||||
private SymbolPipeline? Pipeline(string symbol)
|
||||
{
|
||||
foreach (SymbolPipeline? pipe in pipelines)
|
||||
{
|
||||
if (pipe is not null && pipe.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return pipe;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string NextClientOrderId(string symbol)
|
||||
{
|
||||
int seq = Interlocked.Increment(ref _sequence);
|
||||
string clean = symbol.Replace("/", string.Empty, StringComparison.Ordinal);
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"enc-{clean}-{DateTime.UtcNow:yyMMddHHmmssfff}-{seq}");
|
||||
}
|
||||
|
||||
private static string Fmt(double value) =>
|
||||
double.IsNaN(value) ? "-" : value.ToString("F2", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the engine knows about one traded symbol: the live book, the mark price
|
||||
/// and funding rate, and the last bar that closed.
|
||||
/// <para>
|
||||
/// One instance per <b>symbol</b>, not per pair. BTCUSDT is the natural hedge leg for
|
||||
/// several pairs at once, and subscribing to it twice would mean two copies of the same
|
||||
/// book drifting apart by a message or two — which on a spread computed from both is a
|
||||
/// signal that is not there.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LegState(int id, string symbol)
|
||||
{
|
||||
private long _lastQuoteTimestamp;
|
||||
|
||||
public int Id { get; } = id;
|
||||
|
||||
public string Symbol { get; } = symbol;
|
||||
|
||||
public Quote LastQuote { get; private set; }
|
||||
|
||||
/// <summary>Most recent usable price: the mid when the book is live, otherwise the last close.</summary>
|
||||
public double LastPrice { get; private set; }
|
||||
|
||||
public Bar LastClosedBar { get; private set; }
|
||||
|
||||
public int BarsSeen { get; private set; }
|
||||
|
||||
/// <summary>Binance's mark price, which is what margin and liquidation are computed against.</summary>
|
||||
public double MarkPrice { get; private set; }
|
||||
|
||||
/// <summary>The funding rate that will be settled at <see cref="NextFundingUtc"/>.</summary>
|
||||
public double FundingRate { get; private set; }
|
||||
|
||||
public DateTime NextFundingUtc { get; private set; } = DateTime.MinValue;
|
||||
|
||||
/// <summary>Age of the last top-of-book update. <see cref="TimeSpan.MaxValue"/> when none was seen.</summary>
|
||||
public TimeSpan QuoteAge
|
||||
{
|
||||
get
|
||||
{
|
||||
long ts = Volatile.Read(ref _lastQuoteTimestamp);
|
||||
return ts == 0 ? TimeSpan.MaxValue : Stopwatch.GetElapsedTime(ts);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnQuote(in Quote quote)
|
||||
{
|
||||
if (!quote.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LastQuote = quote;
|
||||
LastPrice = quote.Mid;
|
||||
Volatile.Write(ref _lastQuoteTimestamp, Stopwatch.GetTimestamp());
|
||||
}
|
||||
|
||||
public void OnBarClosed(in Bar bar)
|
||||
{
|
||||
LastClosedBar = bar;
|
||||
BarsSeen++;
|
||||
|
||||
if (bar.Close > 0)
|
||||
{
|
||||
LastPrice = bar.Close;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnFunding(double markPrice, double fundingRate, DateTime nextFundingUtc)
|
||||
{
|
||||
if (markPrice > 0)
|
||||
{
|
||||
MarkPrice = markPrice;
|
||||
}
|
||||
|
||||
FundingRate = fundingRate;
|
||||
NextFundingUtc = nextFundingUtc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The price an order on this side would actually pay: the far touch when the book
|
||||
/// is usable, the last print otherwise.
|
||||
/// </summary>
|
||||
public double ReferencePrice(Side side)
|
||||
{
|
||||
Quote q = LastQuote;
|
||||
if (q.IsValid)
|
||||
{
|
||||
return side == Side.Buy ? q.AskPrice : q.BidPrice;
|
||||
}
|
||||
|
||||
return LastPrice;
|
||||
}
|
||||
|
||||
public double RelativeSpread => LastQuote.IsValid ? LastQuote.RelativeSpread : 0;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Per-pair state on the market-data path: the two legs, the strategy instance, and the
|
||||
/// latches that stop the engine acting twice on the same idea.
|
||||
/// </summary>
|
||||
public sealed class PairPipeline(int id, string name, LegState legA, LegState legB, StatArbStrategy strategy)
|
||||
{
|
||||
private int _entryInFlight;
|
||||
private int _exitInFlight;
|
||||
private DateTime _lastDecidedBar = DateTime.MinValue;
|
||||
|
||||
public int Id { get; } = id;
|
||||
|
||||
public string Name { get; } = name;
|
||||
|
||||
public LegState LegA { get; } = legA;
|
||||
|
||||
public LegState LegB { get; } = legB;
|
||||
|
||||
public StatArbStrategy Strategy { get; } = strategy;
|
||||
|
||||
/// <summary>The z-score the position was opened at, for the journal and the UI.</summary>
|
||||
public double EntryZScore { get; private set; }
|
||||
|
||||
/// <summary>How many closed bars the current position has been held for.</summary>
|
||||
public int BarsHeld { get; private set; }
|
||||
|
||||
public DateTime OpenedAtUtc { get; private set; }
|
||||
|
||||
/// <summary>When the last cointegration fit ran.</summary>
|
||||
public DateTime LastCalibrationUtc { get; private set; } = DateTime.MinValue;
|
||||
|
||||
/// <summary>Bar time of the last decision, so the same bar cannot be decided twice.</summary>
|
||||
public DateTime LastDecidedBar => _lastDecidedBar;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Latches
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>True between submitting an entry and seeing both legs resolve.</summary>
|
||||
public bool EntryInFlight => Volatile.Read(ref _entryInFlight) != 0;
|
||||
|
||||
public DateTime InFlightSinceUtc { get; private set; }
|
||||
|
||||
public bool TryClaimEntry()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _entryInFlight, 1, 0) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
InFlightSinceUtc = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ReleaseEntry() => Interlocked.Exchange(ref _entryInFlight, 0);
|
||||
|
||||
public bool ExitInFlight => Volatile.Read(ref _exitInFlight) != 0;
|
||||
|
||||
public bool TryClaimExit() => Interlocked.CompareExchange(ref _exitInFlight, 1, 0) == 0;
|
||||
|
||||
public void ReleaseExit() => Interlocked.Exchange(ref _exitInFlight, 0);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bar alignment
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Whether both legs have now closed the <b>same</b> bar, and it has not been
|
||||
/// decided on yet.
|
||||
/// <para>
|
||||
/// This is the gate the whole strategy rests on. Binance closes each symbol's kline
|
||||
/// independently and the two events arrive milliseconds apart, so the first one to
|
||||
/// land would otherwise be paired with the previous bar of the other leg — measuring
|
||||
/// five minutes of ordinary drift as a divergence and manufacturing a signal out of
|
||||
/// nothing but message ordering.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool TryAlign(out Bar barA, out Bar barB)
|
||||
{
|
||||
barA = LegA.LastClosedBar;
|
||||
barB = LegB.LastClosedBar;
|
||||
|
||||
if (barA.Close <= 0 || barB.Close <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (barA.TimeUtc != barB.TimeUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (barA.TimeUtc <= _lastDecidedBar)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Records that a bar has been decided on, so a late second event is ignored.</summary>
|
||||
public void MarkDecided(DateTime barTimeUtc) => _lastDecidedBar = barTimeUtc;
|
||||
|
||||
/// <summary>Whether <paramref name="symbolId"/> is one of this pair's legs.</summary>
|
||||
public bool Involves(int symbolId) => LegA.Id == symbolId || LegB.Id == symbolId;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Position
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The pair's position as the strategy needs to see it, derived from the book.
|
||||
/// <para>
|
||||
/// Derived rather than remembered: the exchange is the authority on what is open, a
|
||||
/// position can be closed by hand or by a liquidation, and a bot that trusts its own
|
||||
/// memory over the venue's will eventually try to exit something that is not there.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public PairPositionView PositionView(PortfolioBook book)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(book);
|
||||
|
||||
return new PairPositionView(
|
||||
book.View(LegA.Symbol).Quantity,
|
||||
book.View(LegB.Symbol).Quantity,
|
||||
EntryZScore,
|
||||
BarsHeld);
|
||||
}
|
||||
|
||||
public bool IsOpen(PortfolioBook book) => PositionView(book).IsOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the two legs disagree about whether the pair is open.
|
||||
/// <para>
|
||||
/// One leg open and the other flat is the one state this strategy must never sit in:
|
||||
/// it is an unhedged directional position that nobody chose to take. It happens when
|
||||
/// a leg is rejected, liquidated, or closed by hand, and the engine checks for it on
|
||||
/// every reconcile rather than waiting for a bar.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsHalfOpen(PortfolioBook book)
|
||||
{
|
||||
PairPositionView view = PositionView(book);
|
||||
bool a = Math.Abs(view.QuantityA) > 1e-12;
|
||||
bool b = Math.Abs(view.QuantityB) > 1e-12;
|
||||
return a != b;
|
||||
}
|
||||
|
||||
public void OnPositionOpened(double entryZ)
|
||||
{
|
||||
EntryZScore = entryZ;
|
||||
BarsHeld = 0;
|
||||
OpenedAtUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void OnPositionClosed()
|
||||
{
|
||||
EntryZScore = 0;
|
||||
BarsHeld = 0;
|
||||
OpenedAtUtc = default;
|
||||
}
|
||||
|
||||
public void CountBarHeld() => BarsHeld++;
|
||||
|
||||
public void OnCalibrated(PairCalibration calibration)
|
||||
{
|
||||
Strategy.Recalibrate(calibration);
|
||||
LastCalibrationUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Net funding the pair would earn per settlement if it were opened on the side
|
||||
/// <paramref name="sideA"/> implies, as a fraction of its own notional.
|
||||
/// <para>
|
||||
/// Funding flows from longs to shorts when the rate is positive. A pair is short one
|
||||
/// leg and long the other, so it collects on one and pays on the other and only the
|
||||
/// difference matters — weighted by how much notional sits on each leg, which the
|
||||
/// hedge ratio decides.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public double NetFundingRate(Side sideA, double beta)
|
||||
{
|
||||
if (beta <= 0 || !double.IsFinite(beta))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double weightA = 1.0 / (1.0 + beta);
|
||||
double weightB = 1.0 - weightA;
|
||||
|
||||
double signA = sideA == Side.Sell ? 1 : -1;
|
||||
double signB = -signA;
|
||||
|
||||
return (signA * LegA.FundingRate * weightA) + (signB * LegB.FundingRate * weightB);
|
||||
}
|
||||
|
||||
/// <summary>The widest of the two books, which is what an entry is gated on.</summary>
|
||||
public double WidestSpread => Math.Max(LegA.RelativeSpread, LegB.RelativeSpread);
|
||||
|
||||
/// <summary>The staler of the two books.</summary>
|
||||
public TimeSpan WorstQuoteAge
|
||||
{
|
||||
get
|
||||
{
|
||||
TimeSpan a = LegA.QuoteAge;
|
||||
TimeSpan b = LegB.QuoteAge;
|
||||
return a > b ? a : b;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using Encelado.Binance;
|
||||
using Encelado.Binance.Rest;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Diagnostics;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>A decision handed from the market-data thread to the order path.</summary>
|
||||
public readonly record struct PairIntent(
|
||||
int PairId,
|
||||
string PairName,
|
||||
PairSignal Signal,
|
||||
double ReferenceA,
|
||||
double ReferenceB,
|
||||
long EnqueuedTimestamp,
|
||||
long DecisionId);
|
||||
|
||||
/// <summary>What happened to an intent. Every one of these ends up in the log.</summary>
|
||||
public enum IntentOutcome : byte
|
||||
{
|
||||
Submitted = 0,
|
||||
DryRun,
|
||||
RefusedByRisk,
|
||||
RefusedByGate,
|
||||
Failed,
|
||||
NothingToDo,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns approved signals into Binance orders. Strategies never touch the exchange: they
|
||||
/// publish intents, this class serialises them through a single consumer so sizing, risk
|
||||
/// checks and submission can never interleave for the same pair.
|
||||
/// <para>
|
||||
/// <b>Every intent produces a log line.</b> Not a debug line — an <c>info</c> or a
|
||||
/// <c>warn</c>, naming the pair and the exact reason. This is a deliberate reversal: the
|
||||
/// previous router recorded most of its refusals at debug level, so a bot running at the
|
||||
/// default verbosity would announce that it was about to enter, decline for a reason
|
||||
/// nobody could see, and appear to have simply ignored its own decision. A refusal is not
|
||||
/// less interesting than an order; it is the thing you go looking for.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PairRouter(
|
||||
BinanceFuturesClient client,
|
||||
PortfolioBook book,
|
||||
RiskEngine risk,
|
||||
AccountState account,
|
||||
EngineOptions options,
|
||||
Metrics metrics,
|
||||
TradeJournal journal,
|
||||
AnalyticsLog analytics,
|
||||
PairPipeline[] pipelines)
|
||||
{
|
||||
private readonly Channel<PairIntent> _queue = Channel.CreateUnbounded<PairIntent>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
|
||||
private Task? _consumer;
|
||||
private int _sequence;
|
||||
|
||||
public int QueueDepth { get; private set; }
|
||||
|
||||
/// <summary>The last refusal per pair, surfaced by the UI so it is visible without the log.</summary>
|
||||
public IReadOnlyDictionary<string, string> LastRefusal => _lastRefusal;
|
||||
|
||||
private readonly Dictionary<string, string> _lastRefusal = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Start(CancellationToken ct) =>
|
||||
_consumer ??= Task.Run(() => ConsumeAsync(ct), CancellationToken.None);
|
||||
|
||||
public bool Enqueue(in PairIntent intent) => _queue.Writer.TryWrite(intent);
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_queue.Writer.TryComplete();
|
||||
if (_consumer is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _consumer.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutdown.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConsumeAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (PairIntent intent in _queue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
QueueDepth = _queue.Reader.Count;
|
||||
try
|
||||
{
|
||||
if (intent.Signal.Kind == PairSignalKind.Exit)
|
||||
{
|
||||
await HandleExitAsync(intent, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleEntryAsync(intent, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
metrics.CountOrderError();
|
||||
Log.Error($"[{intent.PairName}] esecuzione fallita", ex);
|
||||
|
||||
PairPipeline? pipe = Pipeline(intent.PairId);
|
||||
pipe?.ReleaseEntry();
|
||||
pipe?.ReleaseExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Entries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async Task HandleEntryAsync(PairIntent intent, CancellationToken ct)
|
||||
{
|
||||
PairPipeline? pipe = Pipeline(intent.PairId);
|
||||
if (pipe is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Every gate below refuses out loud. The order of the checks is the order in
|
||||
// which they are cheapest to evaluate, not the order of importance.
|
||||
if (!account.HasData)
|
||||
{
|
||||
Refuse(intent, "nessuna istantanea del conto: il primo allineamento con Binance non è ancora arrivato");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account.CanTrade)
|
||||
{
|
||||
Refuse(intent, "Binance riporta che il conto non può operare (permessi della chiave o restrizione IP)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.MaxQuoteAgeSeconds > 0 &&
|
||||
pipe.WorstQuoteAge > TimeSpan.FromSeconds(options.MaxQuoteAgeSeconds))
|
||||
{
|
||||
Refuse(intent, string.Create(CultureInfo.InvariantCulture,
|
||||
$"il book più vecchio delle due gambe ha {pipe.WorstQuoteAge.TotalSeconds:F0}s, " +
|
||||
$"oltre il limite di {options.MaxQuoteAgeSeconds}s"));
|
||||
return;
|
||||
}
|
||||
|
||||
PairPositionView position = pipe.PositionView(book);
|
||||
if (position.IsOpen)
|
||||
{
|
||||
Refuse(intent, "la coppia risulta già aperta sul conto");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pipe.TryClaimEntry())
|
||||
{
|
||||
Refuse(intent, "un ingresso su questa coppia è già in corso");
|
||||
return;
|
||||
}
|
||||
|
||||
bool submitted = false;
|
||||
try
|
||||
{
|
||||
Side sideA = intent.Signal.SideA;
|
||||
Side sideB = intent.Signal.SideB;
|
||||
double beta = intent.Signal.Beta;
|
||||
|
||||
double priceA = Reference(pipe.LegA, sideA, intent.ReferenceA);
|
||||
double priceB = Reference(pipe.LegB, sideB, intent.ReferenceB);
|
||||
|
||||
if (priceA <= 0 || priceB <= 0)
|
||||
{
|
||||
Refuse(intent, "nessun prezzo utilizzabile su una delle due gambe");
|
||||
return;
|
||||
}
|
||||
|
||||
double netFunding = pipe.NetFundingRate(sideA, beta);
|
||||
|
||||
PairEntryRequest request = new(
|
||||
intent.PairName,
|
||||
priceA,
|
||||
priceB,
|
||||
beta,
|
||||
account.Equity,
|
||||
account.AvailableBalance,
|
||||
book.GrossExposure,
|
||||
OpenPairCount(),
|
||||
pipe.LegA.RelativeSpread,
|
||||
pipe.LegB.RelativeSpread,
|
||||
netFunding,
|
||||
account.MarginRatio,
|
||||
client.Options.Leverage,
|
||||
position.IsOpen,
|
||||
DateTime.UtcNow);
|
||||
|
||||
PairVerdict verdict = risk.ApprovePair(request);
|
||||
|
||||
if (!verdict.Approved)
|
||||
{
|
||||
metrics.CountRiskReject();
|
||||
Refuse(intent, $"risk engine [{verdict.Reason}]: {verdict.Detail}", phase: "risk",
|
||||
reason: verdict.Reason.ToString(), netFunding: netFunding);
|
||||
return;
|
||||
}
|
||||
|
||||
// Notional to a sendable size. The exchange's lot step is the last authority:
|
||||
// a quantity that is right to seven decimals and wrong on the step is simply
|
||||
// rejected, and on a two-leg trade a rejection is worse than a refusal.
|
||||
SymbolFilters filtersA = client.Filters(pipe.LegA.Symbol);
|
||||
SymbolFilters filtersB = client.Filters(pipe.LegB.Symbol);
|
||||
|
||||
double quantityA = filtersA.RoundQuantity(verdict.NotionalA / priceA);
|
||||
double quantityB = filtersB.RoundQuantity(verdict.NotionalB / priceB);
|
||||
|
||||
if (!filtersA.IsTradable(quantityA, priceA, out string problemA))
|
||||
{
|
||||
Refuse(intent, $"gamba {pipe.LegA.Symbol}: {problemA}", netFunding: netFunding);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filtersB.IsTradable(quantityB, priceB, out string problemB))
|
||||
{
|
||||
Refuse(intent, $"gamba {pipe.LegB.Symbol}: {problemB}", netFunding: netFunding);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"[DRY-RUN] {intent.PairName} {sideA} {quantityA:0.########} {pipe.LegA.Symbol} @ ~{priceA:F4} + " +
|
||||
$"{sideB} {quantityB:0.########} {pipe.LegB.Symbol} @ ~{priceB:F4} :: {intent.Signal.Reason}"));
|
||||
|
||||
journal.Record("dry-run-entry", intent.PairName, sideA, quantityA, priceA,
|
||||
intent.Signal.Reason, equity: account.Equity);
|
||||
|
||||
Record(intent, "order", true, "DryRun", intent.Signal.Reason, sideA, sideB,
|
||||
verdict.NotionalA, verdict.NotionalB, quantityA, quantityB, priceA, priceB,
|
||||
netFunding, null, null, null, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
long submitStart = Stopwatch.GetTimestamp();
|
||||
|
||||
(BinanceOrder? orderA, BinanceOrder? orderB, string? error) =
|
||||
await SubmitBothAsync(pipe, sideA, sideB, quantityA, quantityB, priceA, priceB, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
double latencyMs = Stopwatch.GetElapsedTime(submitStart).TotalMilliseconds;
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
metrics.CountOrderError();
|
||||
Log.Error($"[{intent.PairName}] ingresso fallito: {error}", null);
|
||||
|
||||
Record(intent, "order", false, "SubmitFailed", error, sideA, sideB,
|
||||
verdict.NotionalA, verdict.NotionalB, quantityA, quantityB, priceA, priceB,
|
||||
netFunding, orderA, orderB, error, latencyMs);
|
||||
return;
|
||||
}
|
||||
|
||||
submitted = true;
|
||||
metrics.SignalToOrder.RecordSince(intent.EnqueuedTimestamp);
|
||||
metrics.CountOrderSubmitted();
|
||||
|
||||
risk.RecordEntry(intent.PairName, DateTime.UtcNow);
|
||||
pipe.OnPositionOpened(intent.Signal.ZScore);
|
||||
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"INGRESSO {intent.PairName} {sideA} {quantityA:0.########} {pipe.LegA.Symbol} @ ~{priceA:F4} " +
|
||||
$"{sideB} {quantityB:0.########} {pipe.LegB.Symbol} @ ~{priceB:F4} " +
|
||||
$"[{latencyMs:F0}ms] :: {intent.Signal.Reason}"));
|
||||
|
||||
if (Math.Abs(netFunding) > 1e-6)
|
||||
{
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"[{intent.PairName}] funding netto atteso {netFunding:P4} per liquidazione " +
|
||||
$"({(netFunding > 0 ? "incassiamo" : "paghiamo")})"));
|
||||
}
|
||||
|
||||
journal.Record("entry", intent.PairName, sideA, quantityA, priceA, intent.Signal.Reason,
|
||||
orderA?.Id.ToString(CultureInfo.InvariantCulture), equity: account.Equity);
|
||||
|
||||
Record(intent, "order", true, "None", intent.Signal.Reason, sideA, sideB,
|
||||
verdict.NotionalA, verdict.NotionalB, quantityA, quantityB, priceA, priceB,
|
||||
netFunding, orderA, orderB, null, latencyMs);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!submitted)
|
||||
{
|
||||
pipe.ReleaseEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends both legs at once and guarantees the book never ends up half hedged.
|
||||
/// <para>
|
||||
/// The two orders go out concurrently rather than one after the other, because the
|
||||
/// gap between them is the only interval in which the position carries a directional
|
||||
/// view nobody asked for. If one is rejected and the other filled, the filled one is
|
||||
/// closed again immediately — a small realised loss is the correct price for not
|
||||
/// holding an unhedged leveraged position by accident.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<(BinanceOrder? A, BinanceOrder? B, string? Error)> SubmitBothAsync(
|
||||
PairPipeline pipe,
|
||||
Side sideA,
|
||||
Side sideB,
|
||||
double quantityA,
|
||||
double quantityB,
|
||||
double priceA,
|
||||
double priceB,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Task<BinanceOrder> taskA = client.SubmitOrderAsync(
|
||||
BuildOrder(pipe.LegA.Symbol, sideA, quantityA, priceA), ct);
|
||||
|
||||
Task<BinanceOrder> taskB = client.SubmitOrderAsync(
|
||||
BuildOrder(pipe.LegB.Symbol, sideB, quantityB, priceB), ct);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(taskA, taskB).ConfigureAwait(false);
|
||||
return (taskA.Result, taskB.Result, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is BinanceApiException or HttpRequestException)
|
||||
{
|
||||
string? failureA = taskA.IsFaulted ? Describe(taskA.Exception) : null;
|
||||
string? failureB = taskB.IsFaulted ? Describe(taskB.Exception) : null;
|
||||
|
||||
// Exactly one succeeded: undo it.
|
||||
if (taskA.IsCompletedSuccessfully && failureB is not null)
|
||||
{
|
||||
await UnwindAsync(pipe.LegA.Symbol, $"la gamba {pipe.LegB.Symbol} è stata rifiutata: {failureB}", ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else if (taskB.IsCompletedSuccessfully && failureA is not null)
|
||||
{
|
||||
await UnwindAsync(pipe.LegB.Symbol, $"la gamba {pipe.LegA.Symbol} è stata rifiutata: {failureA}", ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return (
|
||||
taskA.IsCompletedSuccessfully ? taskA.Result : null,
|
||||
taskB.IsCompletedSuccessfully ? taskB.Result : null,
|
||||
failureA ?? failureB ?? ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Closes a leg that was left alone when its partner failed.</summary>
|
||||
private async Task UnwindAsync(string symbol, string why, CancellationToken ct)
|
||||
{
|
||||
Log.Warn($"{symbol}: chiudo la gamba rimasta scoperta — {why}");
|
||||
|
||||
try
|
||||
{
|
||||
await client.CancelAllOrdersAsync(symbol, ct).ConfigureAwait(false);
|
||||
BinanceOrder? closing = await client.ClosePositionAsync(symbol, ct).ConfigureAwait(false);
|
||||
|
||||
journal.Record("unwind", symbol, Side.None, closing?.Quantity ?? 0, 0, why,
|
||||
closing?.Id.ToString(CultureInfo.InvariantCulture), equity: account.Equity);
|
||||
|
||||
Log.Warn($"{symbol}: gamba scoperta chiusa");
|
||||
}
|
||||
catch (Exception ex) when (ex is BinanceApiException or HttpRequestException)
|
||||
{
|
||||
// The one failure worth shouting about: the account now holds a directional
|
||||
// position the strategy never chose, and only a human can settle it.
|
||||
Log.Error(
|
||||
$"{symbol}: NON sono riuscito a chiudere la gamba scoperta ({ex.Message}). " +
|
||||
"Il conto ha una posizione direzionale non voluta: chiudila a mano su Binance.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private NewFuturesOrder BuildOrder(string symbol, Side side, double quantity, double reference)
|
||||
{
|
||||
bool useLimit = options.UseLimitEntries;
|
||||
double offset = options.LimitOffsetBps / 10_000.0;
|
||||
|
||||
// A marketable limit: priced through the touch so it fills like a market order
|
||||
// but can never fill at an absurd print.
|
||||
double limitPrice = side == Side.Buy
|
||||
? reference * (1 + offset)
|
||||
: reference * (1 - offset);
|
||||
|
||||
return new NewFuturesOrder
|
||||
{
|
||||
Symbol = symbol,
|
||||
Side = side,
|
||||
Quantity = quantity,
|
||||
Type = useLimit ? OrderType.Limit : OrderType.Market,
|
||||
LimitPrice = limitPrice,
|
||||
ReduceOnly = false,
|
||||
ClientOrderId = NextClientOrderId(symbol),
|
||||
TimeInForce = useLimit
|
||||
? (options.PostOnlyEntries ? "GTX" : "GTC")
|
||||
: string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exits
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async Task HandleExitAsync(PairIntent intent, CancellationToken ct)
|
||||
{
|
||||
PairPipeline? pipe = Pipeline(intent.PairId);
|
||||
if (pipe is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PairPositionView position = pipe.PositionView(book);
|
||||
if (!position.IsOpen)
|
||||
{
|
||||
pipe.OnPositionClosed();
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"[DRY-RUN] USCITA {intent.PairName} " +
|
||||
$"{position.QuantityA:0.########}/{position.QuantityB:0.########} :: {intent.Signal.Reason}"));
|
||||
return;
|
||||
}
|
||||
|
||||
await CloseAsync(pipe, intent.Signal.Reason, ct).ConfigureAwait(false);
|
||||
|
||||
Record(intent, "exit", true, "None", intent.Signal.Reason, Side.None, Side.None,
|
||||
0, 0, position.QuantityA, position.QuantityB,
|
||||
pipe.LegA.LastPrice, pipe.LegB.LastPrice, 0, null, null, null,
|
||||
Stopwatch.GetElapsedTime(intent.EnqueuedTimestamp).TotalMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pipe.ReleaseExit();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes both legs of a pair at market.
|
||||
/// <para>
|
||||
/// Working orders are cancelled first: a resting entry leg reserves margin, and the
|
||||
/// close would otherwise be refused for a balance that is not actually committed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task CloseAsync(PairPipeline pipe, string reason, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pipe);
|
||||
|
||||
PairPositionView before = pipe.PositionView(book);
|
||||
|
||||
foreach (LegState leg in new[] { pipe.LegA, pipe.LegB })
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.CancelAllOrdersAsync(leg.Symbol, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (BinanceApiException ex)
|
||||
{
|
||||
Log.Warn($"{leg.Symbol}: non sono riuscito ad annullare gli ordini pendenti: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Both closes at once, for the same reason the entries go out together: the
|
||||
// interval between them is unhedged.
|
||||
Task<BinanceOrder?> closeA = client.ClosePositionAsync(pipe.LegA.Symbol, ct);
|
||||
Task<BinanceOrder?> closeB = client.ClosePositionAsync(pipe.LegB.Symbol, ct);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(closeA, closeB).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is BinanceApiException or HttpRequestException)
|
||||
{
|
||||
Log.Error($"[{pipe.Name}] chiusura parzialmente fallita: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
metrics.CountExit();
|
||||
pipe.OnPositionClosed();
|
||||
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"USCITA {pipe.Name} {pipe.LegA.Symbol} {before.QuantityA:0.########} " +
|
||||
$"{pipe.LegB.Symbol} {before.QuantityB:0.########} :: {reason}"));
|
||||
|
||||
journal.Record("exit", pipe.Name, Side.None, Math.Abs(before.QuantityA),
|
||||
pipe.LegA.LastPrice, reason, equity: account.Equity);
|
||||
}
|
||||
|
||||
/// <summary>Closes every open pair. Used by the kill switch and by shutdown.</summary>
|
||||
public async Task CloseAllAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
if (options.DryRun)
|
||||
{
|
||||
Log.Info($"[DRY-RUN] chiusura di tutto richiesta :: {reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Warn($"chiudo tutte le posizioni :: {reason}");
|
||||
|
||||
foreach (PairPipeline pipe in pipelines)
|
||||
{
|
||||
if (pipe.PositionView(book).IsOpen)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CloseAsync(pipe, reason, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is BinanceApiException or HttpRequestException)
|
||||
{
|
||||
Log.Error($"[{pipe.Name}] chiusura fallita: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
pipe.ReleaseEntry();
|
||||
pipe.ReleaseExit();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reporting
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Records a refusal, loudly. This is the single place an entry can be declined
|
||||
/// without an order, so it is the single place that has to be impossible to miss.
|
||||
/// </summary>
|
||||
private void Refuse(
|
||||
in PairIntent intent,
|
||||
string detail,
|
||||
string phase = "suppressed",
|
||||
string reason = "Suppressed",
|
||||
double netFunding = 0)
|
||||
{
|
||||
Log.Warn($"[{intent.PairName}] NON ENTRO — {detail}");
|
||||
|
||||
lock (_lastRefusal)
|
||||
{
|
||||
_lastRefusal[intent.PairName] = detail;
|
||||
}
|
||||
|
||||
Record(intent, phase, false, reason, detail,
|
||||
intent.Signal.SideA, intent.Signal.SideB, 0, 0, 0, 0,
|
||||
intent.ReferenceA, intent.ReferenceB, netFunding, null, null, null, 0);
|
||||
}
|
||||
|
||||
private void Record(
|
||||
in PairIntent intent,
|
||||
string phase,
|
||||
bool approved,
|
||||
string reason,
|
||||
string detail,
|
||||
Side sideA,
|
||||
Side sideB,
|
||||
double notionalA,
|
||||
double notionalB,
|
||||
double quantityA,
|
||||
double quantityB,
|
||||
double priceA,
|
||||
double priceB,
|
||||
double netFunding,
|
||||
BinanceOrder? orderA,
|
||||
BinanceOrder? orderB,
|
||||
string? error,
|
||||
double latencyMs) =>
|
||||
analytics.Execution(new ExecutionRecord
|
||||
{
|
||||
DecisionId = intent.DecisionId,
|
||||
Pair = intent.PairName,
|
||||
Phase = phase,
|
||||
Approved = approved,
|
||||
Reason = reason,
|
||||
Detail = detail,
|
||||
SideA = sideA,
|
||||
SideB = sideB,
|
||||
NotionalA = notionalA,
|
||||
NotionalB = notionalB,
|
||||
QuantityA = quantityA,
|
||||
QuantityB = quantityB,
|
||||
PriceA = priceA,
|
||||
PriceB = priceB,
|
||||
NetFundingRate = netFunding,
|
||||
Equity = account.Equity,
|
||||
AvailableBalance = account.AvailableBalance,
|
||||
GrossExposure = book.GrossExposure,
|
||||
OpenPairs = OpenPairCount(),
|
||||
OrderIdA = orderA?.Id.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
OrderIdB = orderB?.Id.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
Error = error ?? string.Empty,
|
||||
LatencyMs = latencyMs,
|
||||
});
|
||||
|
||||
private int OpenPairCount()
|
||||
{
|
||||
int open = 0;
|
||||
foreach (PairPipeline pipe in pipelines)
|
||||
{
|
||||
if (pipe.PositionView(book).IsOpen)
|
||||
{
|
||||
open++;
|
||||
}
|
||||
}
|
||||
|
||||
return open;
|
||||
}
|
||||
|
||||
private static double Reference(LegState leg, Side side, double fallback)
|
||||
{
|
||||
double touch = leg.ReferencePrice(side);
|
||||
return touch > 0 ? touch : fallback;
|
||||
}
|
||||
|
||||
private static string Describe(AggregateException? ex) =>
|
||||
ex?.InnerException?.Message ?? ex?.Message ?? "errore sconosciuto";
|
||||
|
||||
private PairPipeline? Pipeline(int id) =>
|
||||
(uint)id < (uint)pipelines.Length ? pipelines[id] : null;
|
||||
|
||||
private string NextClientOrderId(string symbol)
|
||||
{
|
||||
int seq = Interlocked.Increment(ref _sequence);
|
||||
|
||||
// Binance accepts letters, digits, hyphen and underscore, up to 36 characters.
|
||||
string clean = symbol.Length > 10 ? symbol[..10] : symbol;
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"enc-{clean}-{DateTime.UtcNow:yyMMddHHmmssfff}-{seq}");
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>One point on the live price line.</summary>
|
||||
public readonly record struct PricePoint(DateTime TimeUtc, double Price);
|
||||
|
||||
/// <summary>
|
||||
/// Rolling price memory for one symbol, feeding the charts.
|
||||
/// <para>
|
||||
/// Two series, because they answer different questions. The <b>closed bars</b> are what
|
||||
/// the strategy actually decides on — on a daily timeframe there is one per day, and a
|
||||
/// hundred of them is several months of context. The <b>live line</b> is sampled from
|
||||
/// the quote stream roughly once a second and exists so the operator can see the price
|
||||
/// moving right now, between two decisions that are a day apart.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both are bounded ring buffers written on the market-data thread and read by the UI
|
||||
/// thread, so every access is under the same lock. The buffers are small and the lock
|
||||
/// is held for a copy, never for I/O.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PriceHistory(int barCapacity = 180, int tickCapacity = 1800)
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Queue<Bar> _bars = new(barCapacity);
|
||||
private readonly Queue<PricePoint> _ticks = new(tickCapacity);
|
||||
|
||||
private DateTime _lastSampleUtc;
|
||||
private double _sessionOpen;
|
||||
private double _sessionHigh;
|
||||
private double _sessionLow = double.MaxValue;
|
||||
|
||||
/// <summary>Sampling floor for the live line. One second is well under any chart's resolution.</summary>
|
||||
private static readonly TimeSpan SampleEvery = TimeSpan.FromSeconds(1);
|
||||
|
||||
public void AddBar(in Bar bar)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_bars.Count >= barCapacity)
|
||||
{
|
||||
_bars.Dequeue();
|
||||
}
|
||||
|
||||
_bars.Enqueue(bar);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a live price. Rate limited: quotes can arrive hundreds of times a second
|
||||
/// on a busy symbol and a chart that is repainted once a second cannot show them.
|
||||
/// </summary>
|
||||
public void AddPrice(double price, DateTime nowUtc)
|
||||
{
|
||||
if (price <= 0 || !double.IsFinite(price))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_sessionOpen <= 0)
|
||||
{
|
||||
_sessionOpen = price;
|
||||
}
|
||||
|
||||
if (price > _sessionHigh) { _sessionHigh = price; }
|
||||
if (price < _sessionLow) { _sessionLow = price; }
|
||||
|
||||
if (nowUtc - _lastSampleUtc < SampleEvery)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSampleUtc = nowUtc;
|
||||
|
||||
if (_ticks.Count >= tickCapacity)
|
||||
{
|
||||
_ticks.Dequeue();
|
||||
}
|
||||
|
||||
_ticks.Enqueue(new PricePoint(nowUtc, price));
|
||||
}
|
||||
}
|
||||
|
||||
public PriceSnapshot Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return new PriceSnapshot(
|
||||
[.. _bars],
|
||||
[.. _ticks],
|
||||
_sessionOpen,
|
||||
_sessionHigh,
|
||||
_sessionLow == double.MaxValue ? 0 : _sessionLow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called when a new trading session starts, so the day's range restarts too.</summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_sessionOpen = 0;
|
||||
_sessionHigh = 0;
|
||||
_sessionLow = double.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An immutable copy of one symbol's price memory, safe to hand to the UI.</summary>
|
||||
public sealed record PriceSnapshot(
|
||||
IReadOnlyList<Bar> Bars,
|
||||
IReadOnlyList<PricePoint> Ticks,
|
||||
double SessionOpen,
|
||||
double SessionHigh,
|
||||
double SessionLow)
|
||||
{
|
||||
public static readonly PriceSnapshot Empty = new([], [], 0, 0, 0);
|
||||
|
||||
public bool HasBars => Bars.Count > 1;
|
||||
|
||||
public bool HasTicks => Ticks.Count > 1;
|
||||
|
||||
/// <summary>Change since the first live sample, which is what the operator reads as "today".</summary>
|
||||
public double SessionChangePct
|
||||
{
|
||||
get
|
||||
{
|
||||
if (SessionOpen <= 0 || Ticks.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (Ticks[^1].Price - SessionOpen) / SessionOpen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Owns "may we trade right now?". Wraps Alpaca's clock (the authority on holidays
|
||||
/// and early closes), detects session rollovers and enforces the end-of-day flatten
|
||||
/// window.
|
||||
/// </summary>
|
||||
public sealed class SessionGuard(AlpacaTradingClient client, EngineOptions options)
|
||||
{
|
||||
private readonly bool _alwaysOpen = options.ResolvedAssetClass == AssetClass.Crypto;
|
||||
private AlpacaClock? _clock;
|
||||
|
||||
/// <summary>Raised the first time a new trading session is observed.</summary>
|
||||
public Action<DateOnly>? OnNewSession { get; set; }
|
||||
|
||||
public DateOnly SessionDate { get; private set; }
|
||||
|
||||
public bool IsOpen => _alwaysOpen || (_clock?.IsOpen ?? false);
|
||||
|
||||
public DateTime NextCloseUtc => _clock?.NextCloseUtc ?? DateTime.MaxValue;
|
||||
|
||||
public DateTime NextOpenUtc => _clock?.NextOpenUtc ?? DateTime.MaxValue;
|
||||
|
||||
public TimeSpan TimeToClose =>
|
||||
_alwaysOpen || _clock is null ? TimeSpan.MaxValue : _clock.NextCloseUtc - DateTime.UtcNow;
|
||||
|
||||
/// <summary>True inside the last N minutes of the session, where we only reduce risk.</summary>
|
||||
public bool InFlattenWindow =>
|
||||
!_alwaysOpen &&
|
||||
options.FlattenBeforeCloseMinutes > 0 &&
|
||||
IsOpen &&
|
||||
TimeToClose <= TimeSpan.FromMinutes(options.FlattenBeforeCloseMinutes);
|
||||
|
||||
/// <summary>Entries are allowed only in a live session and outside the flatten window.</summary>
|
||||
public bool CanOpenNewPositions => IsOpen && !InFlattenWindow;
|
||||
|
||||
public async Task RefreshAsync(CancellationToken ct)
|
||||
{
|
||||
if (_alwaysOpen)
|
||||
{
|
||||
DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
if (today != SessionDate)
|
||||
{
|
||||
SessionDate = today;
|
||||
OnNewSession?.Invoke(today);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
AlpacaClock clock = await client.GetClockAsync(ct).ConfigureAwait(false);
|
||||
_clock = clock;
|
||||
|
||||
// 16:00 ET always lands on the same UTC calendar day, so the close is a
|
||||
// stable session key without needing a timezone database.
|
||||
DateOnly sessionDate = DateOnly.FromDateTime(clock.NextCloseUtc);
|
||||
if (sessionDate != SessionDate)
|
||||
{
|
||||
SessionDate = sessionDate;
|
||||
OnNewSession?.Invoke(sessionDate);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Log.Warn($"clock refresh failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
if (_alwaysOpen)
|
||||
{
|
||||
return "24/7 session";
|
||||
}
|
||||
|
||||
if (_clock is null)
|
||||
{
|
||||
return "clock unknown";
|
||||
}
|
||||
|
||||
return IsOpen
|
||||
? $"open, closes in {TimeToClose:hh\\:mm\\:ss}{(InFlattenWindow ? " (FLATTEN WINDOW)" : string.Empty)}"
|
||||
: $"closed, opens {_clock.NextOpenUtc:yyyy-MM-dd HH:mm}Z";
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Per-symbol state on the market-data path: the strategy instance, the latest
|
||||
/// top-of-book, the bar aggregator and the flags that stop the engine from firing
|
||||
/// twice on the same idea.
|
||||
/// </summary>
|
||||
public sealed class SymbolPipeline(int id, string symbol, IStrategy strategy, int minutesPerBar)
|
||||
{
|
||||
private long _lastQuoteTimestamp;
|
||||
private int _entryInFlight;
|
||||
private int _exitInFlight;
|
||||
private double _pendingTakerBuyVolume;
|
||||
private double _pendingTakerVolume;
|
||||
|
||||
public int Id { get; } = id;
|
||||
|
||||
public string Symbol { get; } = symbol;
|
||||
|
||||
public IStrategy Strategy { get; } = strategy;
|
||||
|
||||
public BarAggregator Aggregator { get; } = new(minutesPerBar);
|
||||
|
||||
public Quote LastQuote { get; private set; }
|
||||
|
||||
public double LastPrice { get; private set; }
|
||||
|
||||
public Bar LastBar { get; private set; }
|
||||
|
||||
public int BarsSeen { get; private set; }
|
||||
|
||||
/// <summary>Stop/target held locally when the broker could not hold a bracket for us.</summary>
|
||||
public double LocalStop { get; set; } = double.NaN;
|
||||
|
||||
public double LocalTarget { get; set; } = double.NaN;
|
||||
|
||||
public bool IsWarm => Strategy.IsReady;
|
||||
|
||||
/// <summary>True between submitting an entry and seeing it resolve. Blocks duplicates.</summary>
|
||||
public bool EntryInFlight => Volatile.Read(ref _entryInFlight) != 0;
|
||||
|
||||
public DateTime InFlightSinceUtc { get; private set; }
|
||||
|
||||
/// <summary>Atomically claims the in-flight slot. Returns false when another entry is already working.</summary>
|
||||
public bool TryClaimEntry()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _entryInFlight, 1, 0) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
InFlightSinceUtc = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ReleaseEntry() => Interlocked.Exchange(ref _entryInFlight, 0);
|
||||
|
||||
/// <summary>True while an exit is queued or executing. Stops one stop-loss breach
|
||||
/// from queueing an exit on every subsequent quote.</summary>
|
||||
public bool ExitInFlight => Volatile.Read(ref _exitInFlight) != 0;
|
||||
|
||||
public bool TryClaimExit() => Interlocked.CompareExchange(ref _exitInFlight, 1, 0) == 0;
|
||||
|
||||
public void ReleaseExit() => Interlocked.Exchange(ref _exitInFlight, 0);
|
||||
|
||||
public void OnQuote(in Quote quote)
|
||||
{
|
||||
if (!quote.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LastQuote = quote;
|
||||
LastPrice = quote.Mid;
|
||||
Volatile.Write(ref _lastQuoteTimestamp, Stopwatch.GetTimestamp());
|
||||
History.AddPrice(quote.Mid, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Rolling price memory for the charts. Never read on the decision path.</summary>
|
||||
public PriceHistory History { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Records a print and, when the feed says which side crossed the spread,
|
||||
/// accumulates the aggressor breakdown for the bar currently being formed.
|
||||
/// <para>
|
||||
/// Alpaca's bars carry only total volume, so the taker split has to be rebuilt from
|
||||
/// the trade stream. Without it the order-flow filter has nothing to read.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void OnTrade(in Tick tick, bool takerBought, bool aggressorKnown)
|
||||
{
|
||||
if (tick.Price > 0)
|
||||
{
|
||||
LastPrice = tick.Price;
|
||||
History.AddPrice(tick.Price, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (!aggressorKnown || tick.Size <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingTakerVolume += tick.Size;
|
||||
if (takerBought)
|
||||
{
|
||||
_pendingTakerBuyVolume += tick.Size;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps a stream bar with the aggressor volume accumulated while it was forming,
|
||||
/// then resets the accumulator for the next one.
|
||||
/// </summary>
|
||||
public Bar AttachOrderFlow(in Bar bar)
|
||||
{
|
||||
double takerBuy = _pendingTakerBuyVolume;
|
||||
double observed = _pendingTakerVolume;
|
||||
|
||||
_pendingTakerBuyVolume = 0;
|
||||
_pendingTakerVolume = 0;
|
||||
|
||||
if (observed <= 0 || bar.Volume <= 0)
|
||||
{
|
||||
return bar;
|
||||
}
|
||||
|
||||
// The tick stream and the bar's own volume rarely agree exactly (late prints,
|
||||
// feed gaps), so carry the observed *ratio* onto the bar's volume rather than
|
||||
// the raw figure. A ratio is what the delta actually depends on.
|
||||
double ratio = Math.Clamp(takerBuy / observed, 0, 1);
|
||||
|
||||
return bar with { TakerBuyVolume = bar.Volume * ratio };
|
||||
}
|
||||
|
||||
/// <summary>Taker-buy volume seen since the last bar closed. Diagnostics only.</summary>
|
||||
public double PendingTakerBuyVolume => _pendingTakerBuyVolume;
|
||||
|
||||
public void OnBarClosed(in Bar bar)
|
||||
{
|
||||
LastBar = bar;
|
||||
BarsSeen++;
|
||||
if (bar.Close > 0)
|
||||
{
|
||||
LastPrice = bar.Close;
|
||||
}
|
||||
|
||||
History.AddBar(bar);
|
||||
}
|
||||
|
||||
/// <summary>Age of the last top-of-book update. <see cref="TimeSpan.MaxValue"/> when none was seen.</summary>
|
||||
public TimeSpan QuoteAge
|
||||
{
|
||||
get
|
||||
{
|
||||
long ts = Volatile.Read(ref _lastQuoteTimestamp);
|
||||
return ts == 0 ? TimeSpan.MaxValue : Stopwatch.GetElapsedTime(ts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference price for an entry: the far touch when the book is usable (that is
|
||||
/// what we will actually pay), otherwise the last print.
|
||||
/// </summary>
|
||||
public double EntryReferencePrice(Side side)
|
||||
{
|
||||
Quote q = LastQuote;
|
||||
if (q.IsValid)
|
||||
{
|
||||
return side == Side.Buy ? q.AskPrice : q.BidPrice;
|
||||
}
|
||||
|
||||
return LastPrice;
|
||||
}
|
||||
|
||||
/// <summary>Checks a locally held stop/target against the latest price.</summary>
|
||||
public bool ShouldExitLocally(double price, double positionQuantity, out string reason)
|
||||
{
|
||||
reason = string.Empty;
|
||||
if (positionQuantity == 0 || price <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isLong = positionQuantity > 0;
|
||||
|
||||
if (!double.IsNaN(LocalStop) && LocalStop > 0)
|
||||
{
|
||||
if ((isLong && price <= LocalStop) || (!isLong && price >= LocalStop))
|
||||
{
|
||||
reason = $"local stop {LocalStop:F4} hit at {price:F4}";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!double.IsNaN(LocalTarget) && LocalTarget > 0)
|
||||
{
|
||||
if ((isLong && price >= LocalTarget) || (!isLong && price <= LocalTarget))
|
||||
{
|
||||
reason = $"local target {LocalTarget:F4} hit at {price:F4}";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ClearProtection()
|
||||
{
|
||||
LocalStop = double.NaN;
|
||||
LocalTarget = double.NaN;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.Net.Http;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
|
||||
@@ -12,7 +12,6 @@ using Encelado.Bot.Engine;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Bot.Ui;
|
||||
using Encelado.Bot.Ui.Pages;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Bot;
|
||||
|
||||
@@ -20,6 +19,12 @@ namespace Encelado.Bot;
|
||||
/// The shell: side navigation on the left, one page at a time on the right, and the
|
||||
/// start/stop button always reachable at the bottom of the nav.
|
||||
/// <para>
|
||||
/// Four pages, down from seven. The charts, the separate account page and the separate
|
||||
/// orders page were removed rather than reorganised: on a delta-neutral pair strategy the
|
||||
/// candlestick of either leg says nothing — the z-score does — and an account panel that
|
||||
/// only ever showed five numbers belongs next to the positions those numbers describe.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pages are plain <see cref="UserControl"/>s that know nothing about the supervisor.
|
||||
/// Anything they need done is asked for through <see cref="IUiActions"/>, which this
|
||||
/// window implements — so the credential store, the file system and the engine are
|
||||
@@ -32,15 +37,11 @@ public partial class MainWindow : Window, IUiActions
|
||||
private readonly MainViewModel _vm;
|
||||
private readonly BotSupervisor _supervisor;
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly List<ChartWindow> _chartWindows = [];
|
||||
|
||||
private readonly StatusPage _status = new();
|
||||
private readonly PositionsPage _positions = new();
|
||||
private readonly ChartsPage _charts = new();
|
||||
private readonly LogPage _log = new();
|
||||
private readonly OrdersPage _orders = new();
|
||||
private readonly SettingsPage _settings = new();
|
||||
private readonly AccountPage _account = new();
|
||||
|
||||
private bool _busy;
|
||||
private bool _closing;
|
||||
@@ -62,15 +63,13 @@ public partial class MainWindow : Window, IUiActions
|
||||
|
||||
DataContext = _vm;
|
||||
|
||||
foreach (UserControl page in new UserControl[]
|
||||
{ _status, _positions, _charts, _log, _settings, _account, _orders })
|
||||
foreach (UserControl page in new UserControl[] { _status, _positions, _log, _settings })
|
||||
{
|
||||
page.DataContext = _vm;
|
||||
}
|
||||
|
||||
_status.Actions = this;
|
||||
_positions.Actions = this;
|
||||
_charts.Actions = this;
|
||||
_log.Actions = this;
|
||||
_settings.Actions = this;
|
||||
|
||||
@@ -104,13 +103,10 @@ public partial class MainWindow : Window, IUiActions
|
||||
// Glyphs are Segoe MDL2 Assets code points, which ships with Windows.
|
||||
NavItem[] items =
|
||||
[
|
||||
new("Stato", "\uE80F", () => _status),
|
||||
new("Conto", "\uE8C7", () => _account),
|
||||
new("Posizioni", "\uE8A1", () => _positions),
|
||||
new("Ordini", "\uE8A5", () => _orders),
|
||||
new("Grafici", "\uE9D2", () => _charts),
|
||||
new("Log", "\uE81C", () => _log),
|
||||
new("Impostazioni", "\uE713", () => _settings),
|
||||
new("Stato", "", () => _status),
|
||||
new("Operatività", "", () => _positions),
|
||||
new("Log", "", () => _log),
|
||||
new("Impostazioni", "", () => _settings),
|
||||
];
|
||||
|
||||
Nav.ItemsSource = items;
|
||||
@@ -136,7 +132,7 @@ public partial class MainWindow : Window, IUiActions
|
||||
|
||||
foreach (string warning in App.ConfigWarnings)
|
||||
{
|
||||
Log.Warn($"config: {warning}");
|
||||
Log.Warn($"configurazione: {warning}");
|
||||
}
|
||||
|
||||
Log.Info($"Encelado avviato — configurazione {App.ConfigPath}");
|
||||
@@ -154,46 +150,44 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
|
||||
RefreshSettings();
|
||||
AvvisaSeLaStrategiaNonEsistePiu();
|
||||
WarnIfConfigurationIsStale();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Se la configurazione nomina una strategia che non esiste più, lo dice adesso e
|
||||
/// porta dov'è la soluzione.
|
||||
/// Says so, now, if the configuration on disk is the one from the Alpaca version.
|
||||
/// <para>
|
||||
/// Prima il problema veniva a galla solo premendo AVVIA, e sembrava un guasto: il
|
||||
/// bot rifiutava di partire con un errore su un nome che l'utente non ricordava di
|
||||
/// aver scritto. Capita perché l'installazione conserva l'<c>encelado.json</c>
|
||||
/// esistente — che è giusto, le tarature sono sue — quindi un aggiornamento che
|
||||
/// rimuove una strategia lascia dietro un riferimento morto.
|
||||
/// An installation keeps the user's <c>encelado.json</c> across an update — which is
|
||||
/// right, the tuning is theirs — so a release that changes the file's shape leaves a
|
||||
/// file behind that the loader can read but that describes nothing the bot still does.
|
||||
/// Before, the symptom was pressing START and getting an error about a section nobody
|
||||
/// remembered writing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void AvvisaSeLaStrategiaNonEsistePiu()
|
||||
private void WarnIfConfigurationIsStale()
|
||||
{
|
||||
List<string> rotte = [.. _config.EnabledSymbols
|
||||
.Where(static s => !StrategyFactory.IsKnown(s.Strategy))
|
||||
.Select(static s => $"{s.Symbol}: '{s.Strategy}'")];
|
||||
bool stale = App.ConfigWarnings.Any(static w => w.Contains("versione Alpaca", StringComparison.Ordinal));
|
||||
|
||||
if (rotte.Count == 0)
|
||||
if (!stale && _config.EnabledPairs.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Warn($"strategia non più disponibile in configurazione — {string.Join(", ", rotte)}");
|
||||
Log.Warn("la configurazione non contiene nessuna coppia utilizzabile");
|
||||
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"La configurazione fa riferimento a una strategia che non esiste più:\n\n" +
|
||||
string.Join("\n", rotte) + "\n\n" +
|
||||
$"Disponibili: {string.Join(", ", StrategyFactory.Available)}.\n\n" +
|
||||
"Succede dopo un aggiornamento, perché l'installazione non sovrascrive la tua " +
|
||||
"configurazione. Vai in Impostazioni → Strategia, scegline una dall'elenco e salva: " +
|
||||
"il bot non può partire finché resta così.",
|
||||
"Strategia non disponibile",
|
||||
"La configurazione non descrive nessuna coppia operabile.\n\n" +
|
||||
(stale
|
||||
? "Il file proviene dalla versione precedente, che operava su Alpaca con un singolo " +
|
||||
"simbolo. Questa versione opera su coppie cointegrate di futures Binance, e le " +
|
||||
"vecchie sezioni non hanno un equivalente diretto.\n\n"
|
||||
: string.Empty) +
|
||||
"Vai in Impostazioni e premi «Ripristina i valori predefiniti»: il file attuale viene " +
|
||||
"salvato con la data accanto all'originale, quindi non perdi niente.",
|
||||
"Configurazione da aggiornare",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
|
||||
// Portarlo dove si risolve vale più che dirgli dove andare.
|
||||
foreach (NavItem item in Nav.Items.OfType<NavItem>())
|
||||
{
|
||||
if (item.Title == "Impostazioni")
|
||||
@@ -221,10 +215,9 @@ public partial class MainWindow : Window, IUiActions
|
||||
/// Asks the desktop window manager to draw <b>its own</b> title bar dark, so the
|
||||
/// standard Windows frame does not sit in light grey on top of a near-black window.
|
||||
/// <para>
|
||||
/// This is the opposite of custom chrome: the frame stays entirely Windows', with
|
||||
/// its real buttons, snap layouts, rounded corners and accessibility behaviour. The
|
||||
/// only thing being set is which of the two colour schemes Windows uses to paint it.
|
||||
/// Ignored on builds that predate the attribute, which simply leaves it light.
|
||||
/// This is the opposite of custom chrome: the frame stays entirely Windows', with its
|
||||
/// real buttons, snap layouts, rounded corners and accessibility behaviour. The only
|
||||
/// thing being set is which of the two colour schemes Windows uses to paint it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ApplyNativeDarkTitleBar()
|
||||
@@ -261,8 +254,8 @@ public partial class MainWindow : Window, IUiActions
|
||||
{
|
||||
item.Badge = item.Title switch
|
||||
{
|
||||
"Posizioni" when _vm.OpenPositions > 0 =>
|
||||
_vm.OpenPositions.ToString(System.Globalization.CultureInfo.CurrentCulture),
|
||||
"Operatività" when _vm.OpenPairs > 0 =>
|
||||
_vm.OpenPairs.ToString(System.Globalization.CultureInfo.CurrentCulture),
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
@@ -285,7 +278,7 @@ public partial class MainWindow : Window, IUiActions
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_vm.IsRunning && !_config.Alpaca.Paper && !ConfirmLiveTrading())
|
||||
if (!_vm.IsRunning && !_config.Binance.Testnet && !_config.Engine.DryRun && !ConfirmLiveTrading())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -320,7 +313,8 @@ public partial class MainWindow : Window, IUiActions
|
||||
private bool ConfirmLiveTrading() =>
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Questa configurazione opera sul conto LIVE con denaro reale.\n\nAvviare comunque?",
|
||||
"Questa configurazione opera sul conto REALE con denaro vero, e con leva " +
|
||||
$"{_config.Binance.Leverage}x.\n\nAvviare comunque?",
|
||||
"Attenzione — denaro reale",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
@@ -341,17 +335,17 @@ public partial class MainWindow : Window, IUiActions
|
||||
// IUiActions — everything the pages can ask the shell to do
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task ClosePositionAsync(string symbol)
|
||||
public async Task ClosePairAsync(string pairName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbol))
|
||||
if (string.IsNullOrWhiteSpace(pairName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (MessageBox.Show(
|
||||
this,
|
||||
$"Chiudere la posizione su {symbol} al prezzo di mercato?",
|
||||
"Chiusura posizione",
|
||||
$"Chiudere entrambe le gambe di {pairName} al prezzo di mercato?",
|
||||
"Chiusura coppia",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
@@ -360,7 +354,7 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
|
||||
CommandResult result = await _supervisor
|
||||
.ClosePositionAsync(symbol, CancellationToken.None)
|
||||
.ClosePairAsync(pairName, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!result.Ok)
|
||||
@@ -373,9 +367,11 @@ public partial class MainWindow : Window, IUiActions
|
||||
|
||||
public void ForgetCredentials()
|
||||
{
|
||||
string environment = _config.Binance.Testnet ? "TESTNET" : "REALE";
|
||||
|
||||
if (MessageBox.Show(
|
||||
this,
|
||||
$"Rimuovere le chiavi salvate per l'ambiente {(_config.Alpaca.Paper ? "PAPER" : "LIVE")}?",
|
||||
$"Rimuovere le chiavi salvate per l'ambiente {environment}?",
|
||||
"Rimozione credenziali",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
@@ -384,11 +380,70 @@ public partial class MainWindow : Window, IUiActions
|
||||
return;
|
||||
}
|
||||
|
||||
bool removed = CredentialStore.Clear(_config.Alpaca.Paper);
|
||||
bool removed = CredentialStore.Clear(_config.Binance.Testnet);
|
||||
Log.Info(removed ? "credenziali salvate rimosse" : "non c'erano credenziali salvate da rimuovere");
|
||||
RefreshSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the configuration with the factory values.
|
||||
/// <para>
|
||||
/// Confirmed twice-over rather than once, and never while the engine is running: this
|
||||
/// discards every tuned threshold, every disabled pair and every note the operator
|
||||
/// wrote in the file. The backup makes it recoverable; the dialog makes it deliberate.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void RestoreDefaults()
|
||||
{
|
||||
if (_vm.IsRunning)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"Ferma il bot prima di ripristinare la configurazione.\n\n" +
|
||||
"Il motore legge la configurazione all'avvio: riscriverla mentre opera " +
|
||||
"lascerebbe in esecuzione qualcosa che non corrisponde più a nessun file.",
|
||||
"Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (MessageBox.Show(
|
||||
this,
|
||||
"Riscrivere l'intera configurazione con i valori di fabbrica?\n\n" +
|
||||
"Vengono persi: le soglie che hai cambiato, le coppie che hai aggiunto o " +
|
||||
"disattivato, e i commenti che hai scritto nel file.\n\n" +
|
||||
"Il file attuale viene salvato con la data accanto all'originale, quindi è " +
|
||||
"recuperabile. Le chiavi API non vengono toccate.",
|
||||
"Ripristino dei valori predefiniti",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? backup;
|
||||
try
|
||||
{
|
||||
backup = ConfigDefaults.Restore(App.ConfigPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Non sono riuscito a riscrivere la configurazione:\n\n{ex.Message}",
|
||||
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info(backup is null
|
||||
? "configurazione ripristinata ai valori predefiniti"
|
||||
: $"configurazione ripristinata; la precedente è in {backup}");
|
||||
|
||||
MessageBox.Show(this,
|
||||
"Configurazione ripristinata.\n\n" +
|
||||
(backup is null ? string.Empty : $"La precedente è stata salvata in:\n{backup}\n\n") +
|
||||
"Riavvia l'applicazione perché i nuovi valori vengano caricati.",
|
||||
"Ripristino completato", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
public void OpenConfigFile() => OpenInShell(App.ConfigPath);
|
||||
|
||||
public void OpenLogFolder()
|
||||
@@ -505,44 +560,6 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
}
|
||||
|
||||
public void OpenChartWindow(string symbol)
|
||||
{
|
||||
SymbolChartViewModel? chart = null;
|
||||
foreach (SymbolChartViewModel candidate in _vm.Charts)
|
||||
{
|
||||
if (string.Equals(candidate.Symbol, symbol, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
chart = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (chart is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Raise the existing one rather than stacking duplicates on top of each other.
|
||||
foreach (ChartWindow open in _chartWindows)
|
||||
{
|
||||
if (string.Equals(open.Symbol, symbol, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (open.WindowState == WindowState.Minimized)
|
||||
{
|
||||
open.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
open.Activate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ChartWindow window = new(chart) { Owner = this };
|
||||
_chartWindows.Add(window);
|
||||
window.Closed += (_, _) => _chartWindows.Remove(window);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private static void OpenInShell(string path)
|
||||
{
|
||||
try
|
||||
@@ -562,9 +579,10 @@ public partial class MainWindow : Window, IUiActions
|
||||
private void RefreshSettings()
|
||||
{
|
||||
CredentialLookup lookup = CredentialResolver.Resolve(_config);
|
||||
string environment = _config.Binance.Testnet ? "TESTNET" : "REALE";
|
||||
|
||||
string status = lookup.Found
|
||||
? $"Origine: {lookup.Describe()} — ambiente {(_config.Alpaca.Paper ? "PAPER" : "LIVE")}."
|
||||
? $"Origine: {lookup.Describe()} — ambiente {environment}."
|
||||
: "Nessuna credenziale configurata. Il bot non può partire finché non ne inserisci una coppia.";
|
||||
|
||||
string store = CredentialStore.Exists
|
||||
@@ -573,13 +591,13 @@ public partial class MainWindow : Window, IUiActions
|
||||
: $"Nessun archivio salvato. Verrebbe creato in {CredentialStore.FilePath}.";
|
||||
|
||||
string about =
|
||||
"Encelado — motore di trading automatico su Alpaca.\n" +
|
||||
"Encelado — arbitraggio statistico su coppie cointegrate, Binance Futures USDⓈ-M.\n" +
|
||||
$"Versione {Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}\n" +
|
||||
$"Configurazione: {App.ConfigPath}\n" +
|
||||
$"Endpoint: {_config.Alpaca.TradingBaseUrl} feed dati: {_config.Alpaca.DataFeed}";
|
||||
$"Endpoint: {_config.Binance.RestBaseUrl} leva: {_config.Binance.Leverage}x " +
|
||||
$"margine: {_config.Binance.MarginType}";
|
||||
|
||||
_settings.Refresh(_config, status, store, about);
|
||||
_account.Describe(_config.Risk);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -590,8 +608,8 @@ public partial class MainWindow : Window, IUiActions
|
||||
/// Fermare il motore è asincrono e la chiusura di una finestra non lo è: si annulla
|
||||
/// la chiusura, si aspetta, e la si richiede quando lo spegnimento è finito davvero.
|
||||
/// <para>
|
||||
/// Il punto delicato è il secondo tentativo. Chi non vede succedere niente preme la
|
||||
/// X un'altra volta, e prima questo ramo usciva <b>senza</b> annullare: la seconda
|
||||
/// Il punto delicato è il secondo tentativo. Chi non vede succedere niente preme la X
|
||||
/// un'altra volta, e prima questo ramo usciva <b>senza</b> annullare: la seconda
|
||||
/// chiusura andava a buon fine, la finestra entrava nella propria sequenza di
|
||||
/// chiusura, e la <c>Close()</c> in fondo al primo tentativo ci finiva dentro —
|
||||
/// <c>«Non è possibile […] chiamare Close durante la chiusura di un oggetto
|
||||
@@ -603,10 +621,9 @@ public partial class MainWindow : Window, IUiActions
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
// Si annullano i tentativi dell'utente, non la chiusura finale: quella
|
||||
// arriva da ChiudiDavvero, che alza _closed prima di chiamare Close().
|
||||
// Senza questa distinzione la finestra annulla anche se stessa e non si
|
||||
// chiude più.
|
||||
// Si annullano i tentativi dell'utente, non la chiusura finale: quella arriva
|
||||
// da ChiudiDavvero, che alza _closed prima di chiamare Close(). Senza questa
|
||||
// distinzione la finestra annulla anche se stessa e non si chiude più.
|
||||
if (!_closed)
|
||||
{
|
||||
e.Cancel = true;
|
||||
@@ -619,7 +636,11 @@ public partial class MainWindow : Window, IUiActions
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Il bot è in esecuzione. Chiudere l'applicazione lo ferma.\n\n" +
|
||||
"Le posizioni aperte restano aperte sul conto Alpaca.\n\nContinuare?",
|
||||
(_config.Engine.CloseOnShutdown
|
||||
? "Le posizioni aperte verranno chiuse."
|
||||
: "Le posizioni aperte RESTANO aperte sul conto Binance, senza nessuno che " +
|
||||
"applichi lo stop statistico o le chiuda al rientro dello spread.") +
|
||||
"\n\nContinuare?",
|
||||
"Chiusura",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
@@ -634,19 +655,14 @@ public partial class MainWindow : Window, IUiActions
|
||||
_timer.Stop();
|
||||
_supervisor.EventLogged -= _vm.Log.Enqueue;
|
||||
|
||||
foreach (ChartWindow window in _chartWindows.ToArray())
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _supervisor.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Uno spegnimento andato storto non deve lasciare la finestra inchiodata:
|
||||
// si registra e si chiude comunque.
|
||||
// Uno spegnimento andato storto non deve lasciare la finestra inchiodata: si
|
||||
// registra e si chiude comunque.
|
||||
Log.Error("errore durante lo spegnimento", ex);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<Window x:Class="Encelado.Bot.Ui.ChartWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui"
|
||||
Height="620" Width="1020" MinHeight="320" MinWidth="520"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Bg}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextRenderingMode="ClearType">
|
||||
|
||||
<DockPanel Margin="14">
|
||||
|
||||
<Border DockPanel.Dock="Top" Style="{StaticResource Card}" Padding="16,13" Margin="0,0,0,10">
|
||||
<Grid>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Symbol}" FontSize="17" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="25" FontWeight="SemiBold"
|
||||
Margin="16,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="13.5" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{Binding ChangePct, Converter={StaticResource PnlBrush}}"
|
||||
Text="{Binding ChangePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,0,14,0"
|
||||
Text="{Binding SessionHigh, Converter={StaticResource Price}, StringFormat='max {0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,0,20,0"
|
||||
Text="{Binding SessionLow, Converter={StaticResource Price}, StringFormat='min {0}'}"/>
|
||||
<RadioButton x:Name="CandleMode" Content="Candele" IsChecked="True" GroupName="mode"
|
||||
Checked="OnModeChanged" Margin="0,0,12,0"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
<RadioButton x:Name="LiveMode" Content="Diretta" GroupName="mode" Checked="OnModeChanged"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}">
|
||||
<ui:PriceChart x:Name="Chart"
|
||||
Opens="{Binding Opens}" Highs="{Binding Highs}"
|
||||
Lows="{Binding Lows}" Closes="{Binding Closes}"
|
||||
LivePrices="{Binding Live}"
|
||||
EmptyText="in attesa di dati — avvia il bot"/>
|
||||
</Border>
|
||||
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// One symbol's chart in its own window, so it can be put on a second monitor and left
|
||||
/// there. It binds to the same <see cref="SymbolChartViewModel"/> the main window uses,
|
||||
/// which means it updates on the same tick without any extra plumbing.
|
||||
/// </summary>
|
||||
public partial class ChartWindow : Window
|
||||
{
|
||||
public ChartWindow(SymbolChartViewModel chart)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chart);
|
||||
|
||||
InitializeComponent();
|
||||
DataContext = chart;
|
||||
Title = $"{chart.Symbol} — Encelado";
|
||||
}
|
||||
|
||||
/// <summary>The symbol this window is showing, so the shell can raise an existing one.</summary>
|
||||
public string Symbol => ((SymbolChartViewModel)DataContext).Symbol;
|
||||
|
||||
private void OnModeChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Chart is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Chart.Mode = LiveMode.IsChecked == true ? PriceChartMode.Live : PriceChartMode.Candles;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<Window x:Class="Encelado.Bot.Ui.LoginWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Credenziali Alpaca"
|
||||
Width="520" SizeToContent="Height"
|
||||
Title="Credenziali Binance"
|
||||
Width="560" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ResizeMode="NoResize"
|
||||
Background="{StaticResource Bg}"
|
||||
@@ -11,22 +11,25 @@
|
||||
<Border Padding="24">
|
||||
<StackPanel>
|
||||
|
||||
<TextBlock Text="Credenziali Alpaca" FontSize="19" FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="EnvLine" Style="{StaticResource Sub}" Margin="0,4,0,0"/>
|
||||
<TextBlock Text="Credenziali Binance Futures" FontSize="19" FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="EnvLine" Style="{StaticResource Sub}" Margin="0,4,0,0" TextWrapping="Wrap"/>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,18,0,0" Padding="13">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"
|
||||
Text="Genera una coppia di chiavi dalla dashboard Alpaca. Il secret viene mostrato una sola volta, al momento della creazione."/>
|
||||
Text="Crea una coppia di chiavi dalla gestione API di Binance. Il secret viene mostrato una sola volta, al momento della creazione."/>
|
||||
<TextBlock x:Name="PortalLink" Style="{StaticResource Sub}"
|
||||
Foreground="{StaticResource Accent}" Margin="0,6,0,0"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,8,0,0" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource Warn}"
|
||||
Text="La chiave deve avere il permesso "Enable Futures". È una casella separata da quella dello spot, e non averla spuntata è di gran lunga il motivo più comune per cui una chiave corretta viene rifiutata."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="API KEY ID" Style="{StaticResource Label}" Margin="0,18,0,6"/>
|
||||
<TextBlock Text="API KEY" Style="{StaticResource Label}" Margin="0,18,0,6"/>
|
||||
<TextBox x:Name="KeyBox"/>
|
||||
|
||||
<TextBlock Text="API SECRET KEY" Style="{StaticResource Label}" Margin="0,14,0,6"/>
|
||||
<TextBlock Text="API SECRET" Style="{StaticResource Label}" Margin="0,14,0,6"/>
|
||||
<PasswordBox x:Name="SecretBox"/>
|
||||
|
||||
<CheckBox x:Name="SaveBox" Content="Ricorda queste chiavi su questo computer"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
using System.Windows;
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Binance.Rest;
|
||||
using Encelado.Bot.Configuration;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Collects an Alpaca key pair and proves it works before accepting it. Verifying here
|
||||
/// rather than at the first order means a typo surfaces immediately, with a message
|
||||
/// that says what is wrong, instead of as a 401 twenty minutes into a session.
|
||||
/// Collects a Binance key pair and proves it works before accepting it.
|
||||
/// <para>
|
||||
/// Verifying here rather than at the first order means a typo, a missing futures
|
||||
/// permission or an IP restriction surfaces immediately, with a message that says what is
|
||||
/// wrong — instead of as a rejected leg twenty minutes into a session, with the other leg
|
||||
/// already filled.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class LoginWindow : Window
|
||||
{
|
||||
@@ -16,25 +20,27 @@ public partial class LoginWindow : Window
|
||||
public LoginWindow(BotConfig config)
|
||||
{
|
||||
InitializeComponent();
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_config = config;
|
||||
|
||||
bool paper = config.Alpaca.Paper;
|
||||
EnvLine.Text = paper
|
||||
? "Ambiente PAPER — conto simulato, denaro finto."
|
||||
: "Ambiente LIVE — denaro reale.";
|
||||
bool testnet = config.Binance.Testnet;
|
||||
|
||||
PortalLink.Text = paper
|
||||
? "https://app.alpaca.markets/paper/dashboard/overview"
|
||||
: "https://app.alpaca.markets/live/dashboard/overview";
|
||||
EnvLine.Text = testnet
|
||||
? "Ambiente TESTNET — conto simulato, denaro finto. Le chiavi della testnet non funzionano sul conto reale e viceversa."
|
||||
: "Ambiente REALE — gli ordini impegnano denaro vero.";
|
||||
|
||||
PortalLink.Text = testnet
|
||||
? "https://testnet.binancefuture.com/en/futures/BTCUSDT"
|
||||
: "https://www.binance.com/en/my/settings/api-management";
|
||||
|
||||
StorageNote.Text = CredentialStore.IsEncrypted
|
||||
? "Salvate cifrate con DPAPI: leggibili solo dal tuo account Windows."
|
||||
: "Su questo sistema DPAPI non è disponibile: il file sarà in chiaro, con permessi di solo proprietario.";
|
||||
|
||||
if (CredentialStore.Load(paper) is { } existing)
|
||||
if (CredentialStore.Load(testnet) is { } existing)
|
||||
{
|
||||
KeyBox.Text = existing.KeyId;
|
||||
ShowStatus($"Sono già salvate delle chiavi ({CredentialStore.Mask(existing.KeyId)}). " +
|
||||
KeyBox.Text = existing.ApiKey;
|
||||
ShowStatus($"Sono già salvate delle chiavi ({CredentialStore.Mask(existing.ApiKey)}). " +
|
||||
"Inseriscine di nuove per sostituirle.", warning: false);
|
||||
}
|
||||
|
||||
@@ -43,20 +49,20 @@ public partial class LoginWindow : Window
|
||||
|
||||
private async void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string keyId = CredentialStore.Clean(KeyBox.Text) ?? string.Empty;
|
||||
string secret = CredentialStore.Clean(SecretBox.Password) ?? string.Empty;
|
||||
string apiKey = CredentialStore.Clean(KeyBox.Text) ?? string.Empty;
|
||||
string apiSecret = CredentialStore.Clean(SecretBox.Password) ?? string.Empty;
|
||||
|
||||
if (keyId.Length == 0 || secret.Length == 0)
|
||||
if (apiKey.Length == 0 || apiSecret.Length == 0)
|
||||
{
|
||||
ShowStatus("Inserisci sia la key id sia il secret.", warning: true);
|
||||
ShowStatus("Inserisci sia la API key sia il secret.", warning: true);
|
||||
return;
|
||||
}
|
||||
|
||||
SetBusy(true);
|
||||
try
|
||||
{
|
||||
(bool ok, string message, AlpacaAccount? account) = await CredentialResolver
|
||||
.VerifyAsync(keyId, secret, _config.Alpaca.Paper, CancellationToken.None)
|
||||
(bool ok, string message, FuturesAccount? account) = await CredentialResolver
|
||||
.VerifyAsync(apiKey, apiSecret, _config.Binance.Testnet, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!ok)
|
||||
@@ -65,13 +71,15 @@ public partial class LoginWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
CredentialResolver.Apply(_config, keyId, secret, SaveBox.IsChecked == true);
|
||||
CredentialResolver.Apply(_config, apiKey, apiSecret, SaveBox.IsChecked == true);
|
||||
|
||||
string where = SaveBox.IsChecked == true
|
||||
? $" Salvate in {CredentialStore.FilePath}."
|
||||
: " Non salvate: valgono solo per questa sessione.";
|
||||
|
||||
ShowStatus($"{message} — equity {account!.Equity:N2} {account.Currency}.{where}", warning: false);
|
||||
ShowStatus(
|
||||
$"{message} — margine disponibile {account!.AvailableBalance:N2} USDT.{where}",
|
||||
warning: false);
|
||||
|
||||
DialogResult = true;
|
||||
}
|
||||
@@ -100,4 +108,3 @@ public partial class LoginWindow : Window
|
||||
StatusText.Foreground = warning ? Palette.Down : Palette.Up;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ using Encelado.Bot.Engine;
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation state for the operating tab. Refreshed once a second from an immutable
|
||||
/// Presentation state for the whole window. Refreshed once a second from an immutable
|
||||
/// <see cref="BotSnapshot"/>; collections are updated in place so WPF does not rebuild
|
||||
/// (and scroll-reset) the grids on every tick.
|
||||
/// </summary>
|
||||
public sealed class MainViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private string _mode = "PAPER";
|
||||
private string _mode = "TESTNET";
|
||||
private string _modeKind = "paper";
|
||||
private string _stateText = "fermo";
|
||||
private string _stateKind = "stopped";
|
||||
@@ -25,48 +25,42 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
private bool _bannerIsWarning;
|
||||
|
||||
private double _equity;
|
||||
private double _cash;
|
||||
private double _pnlToday;
|
||||
private double _pnlTodayPct;
|
||||
private double _walletBalance;
|
||||
private double _availableBalance;
|
||||
private double _unrealized;
|
||||
private double _realizedToday;
|
||||
private double _pnlSession;
|
||||
private double _pnlSessionPct;
|
||||
private double _pnlAllTime;
|
||||
private double _pnlAllTimePct;
|
||||
private bool _hasAllTime;
|
||||
private double _unrealized;
|
||||
private double _marginRatio;
|
||||
private double _exposurePct;
|
||||
private int _openPositions;
|
||||
private int _maxOpenPositions;
|
||||
private int _openPairs;
|
||||
private int _maxOpenPairs;
|
||||
private int _tradesToday;
|
||||
private int _maxTradesPerDay;
|
||||
private double _maxDailyLossPct;
|
||||
private double _riskPerTradePct;
|
||||
private string _sizing = "—";
|
||||
private int _leverage = 2;
|
||||
|
||||
private string _sessionStatus = "—";
|
||||
private string _marketData = "—";
|
||||
private string _tradeStream = "—";
|
||||
private string _orderStream = "—";
|
||||
private string _uptime = "—";
|
||||
private string _timeFrame = "—";
|
||||
private string _endpoint = "—";
|
||||
private string _latency = string.Empty;
|
||||
private string _counters = string.Empty;
|
||||
private IReadOnlyList<double> _equityCurve = [];
|
||||
|
||||
private AccountRow? _account;
|
||||
|
||||
public ObservableCollection<PairRow> Pairs { get; } = [];
|
||||
|
||||
public ObservableCollection<PositionRow> Positions { get; } = [];
|
||||
|
||||
public ObservableCollection<SymbolRow> Symbols { get; } = [];
|
||||
|
||||
public ObservableCollection<EventRow> Events { get; } = [];
|
||||
|
||||
public ObservableCollection<OrderRow> Orders { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// One entry per charted symbol, created once and updated in place. See
|
||||
/// <see cref="SymbolChartViewModel"/> for why these are not swapped each tick.
|
||||
/// </summary>
|
||||
public ObservableCollection<SymbolChartViewModel> Charts { get; } = [];
|
||||
public ObservableCollection<EventRow> Events { get; } = [];
|
||||
|
||||
private AccountRow? _account;
|
||||
|
||||
/// <summary>The broker's own account view, or null before the first reconcile.</summary>
|
||||
/// <summary>The exchange's own account view, or null before the first reconcile.</summary>
|
||||
public AccountRow? Account { get => _account; private set => Set(ref _account, value); }
|
||||
|
||||
public bool HasAccount => _account is not null;
|
||||
@@ -101,10 +95,6 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
/// </summary>
|
||||
public bool CanToggle => !_isBusy && _stateKind is not ("starting" or "stopping");
|
||||
|
||||
/// <summary>
|
||||
/// Set by the shell around an in-flight start or stop. Guards against a second click
|
||||
/// landing before the engine has reported its new state.
|
||||
/// </summary>
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
@@ -131,39 +121,40 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
public double Equity { get => _equity; private set => Set(ref _equity, value); }
|
||||
|
||||
public double Cash { get => _cash; private set => Set(ref _cash, value); }
|
||||
public double WalletBalance { get => _walletBalance; private set => Set(ref _walletBalance, value); }
|
||||
|
||||
public double PnlToday { get => _pnlToday; private set => Set(ref _pnlToday, value); }
|
||||
public double AvailableBalance
|
||||
{
|
||||
get => _availableBalance;
|
||||
private set => Set(ref _availableBalance, value);
|
||||
}
|
||||
|
||||
public double PnlTodayPct { get => _pnlTodayPct; private set => Set(ref _pnlTodayPct, value); }
|
||||
public double Unrealized { get => _unrealized; private set => Set(ref _unrealized, value); }
|
||||
|
||||
public double RealizedToday { get => _realizedToday; private set => Set(ref _realizedToday, value); }
|
||||
|
||||
public double PnlSession { get => _pnlSession; private set => Set(ref _pnlSession, value); }
|
||||
|
||||
public double PnlSessionPct { get => _pnlSessionPct; private set => Set(ref _pnlSessionPct, value); }
|
||||
|
||||
public double PnlAllTime { get => _pnlAllTime; private set => Set(ref _pnlAllTime, value); }
|
||||
|
||||
public double PnlAllTimePct { get => _pnlAllTimePct; private set => Set(ref _pnlAllTimePct, value); }
|
||||
|
||||
public bool HasAllTime { get => _hasAllTime; private set => Set(ref _hasAllTime, value); }
|
||||
|
||||
public double Unrealized { get => _unrealized; private set => Set(ref _unrealized, value); }
|
||||
/// <summary>Maintenance margin over equity. The number that matters on leveraged futures.</summary>
|
||||
public double MarginRatio { get => _marginRatio; private set => Set(ref _marginRatio, value); }
|
||||
|
||||
public double ExposurePct { get => _exposurePct; private set => Set(ref _exposurePct, value); }
|
||||
|
||||
public int OpenPositions { get => _openPositions; private set => Set(ref _openPositions, value); }
|
||||
public int OpenPairs { get => _openPairs; private set => Set(ref _openPairs, value); }
|
||||
|
||||
public int MaxOpenPositions { get => _maxOpenPositions; private set => Set(ref _maxOpenPositions, value); }
|
||||
public int MaxOpenPairs { get => _maxOpenPairs; private set => Set(ref _maxOpenPairs, value); }
|
||||
|
||||
public int TradesToday { get => _tradesToday; private set => Set(ref _tradesToday, value); }
|
||||
|
||||
public int MaxTradesPerDay { get => _maxTradesPerDay; private set => Set(ref _maxTradesPerDay, value); }
|
||||
|
||||
/// <summary>
|
||||
/// Open positions, with the cap appended only when there is one. A limit of 0 means
|
||||
/// "no limit", so rendering it as "0 / 0" states the opposite of what it means.
|
||||
/// Open pairs with the cap appended only when there is one. A limit of 0 means "no
|
||||
/// limit", so rendering it as "0 / 0" states the opposite of what it means.
|
||||
/// </summary>
|
||||
public string PositionsDisplay => Counter(_openPositions, _maxOpenPositions);
|
||||
public string PairsDisplay => Counter(_openPairs, _maxOpenPairs);
|
||||
|
||||
public string TradesDisplay => Counter(_tradesToday, _maxTradesPerDay);
|
||||
|
||||
@@ -172,19 +163,22 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
? string.Create(CultureInfo.CurrentCulture, $"{value} / {limit}")
|
||||
: value.ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>The loss that halts the session, shown on the account page.</summary>
|
||||
public double MaxDailyLossPct { get => _maxDailyLossPct; private set => Set(ref _maxDailyLossPct, value); }
|
||||
|
||||
public double RiskPerTradePct { get => _riskPerTradePct; private set => Set(ref _riskPerTradePct, value); }
|
||||
public string Sizing { get => _sizing; private set => Set(ref _sizing, value); }
|
||||
|
||||
public string SessionStatus { get => _sessionStatus; private set => Set(ref _sessionStatus, value); }
|
||||
public int Leverage { get => _leverage; private set => Set(ref _leverage, value); }
|
||||
|
||||
public string MarketDataState { get => _marketData; private set => Set(ref _marketData, value); }
|
||||
|
||||
public string TradeStreamState { get => _tradeStream; private set => Set(ref _tradeStream, value); }
|
||||
public string OrderStreamState { get => _orderStream; private set => Set(ref _orderStream, value); }
|
||||
|
||||
public string Uptime { get => _uptime; private set => Set(ref _uptime, value); }
|
||||
|
||||
public string TimeFrame { get => _timeFrame; private set => Set(ref _timeFrame, value); }
|
||||
|
||||
public string Endpoint { get => _endpoint; private set => Set(ref _endpoint, value); }
|
||||
|
||||
public string Latency { get => _latency; private set => Set(ref _latency, value); }
|
||||
|
||||
public string Counters { get => _counters; private set => Set(ref _counters, value); }
|
||||
@@ -196,7 +190,7 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
ArgumentNullException.ThrowIfNull(s);
|
||||
|
||||
Mode = s.Mode;
|
||||
ModeKind = s.DryRun ? "dry" : s.Paper ? "paper" : "live";
|
||||
ModeKind = s.DryRun ? "dry" : s.Testnet ? "paper" : "live";
|
||||
|
||||
IsRunning = s.State is BotState.Running or BotState.Starting;
|
||||
PowerText = IsRunning ? "FERMA" : "AVVIA";
|
||||
@@ -214,63 +208,34 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
_ => "fermo",
|
||||
};
|
||||
|
||||
if (s.Halted)
|
||||
{
|
||||
Banner = $"KILL SWITCH — {s.HaltReason}";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = false;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(s.StreamRejection))
|
||||
{
|
||||
// Above the engine error on purpose: when the broker is refusing the data
|
||||
// stream, that is the cause and anything else is a consequence.
|
||||
Banner = $"DATI DI MERCATO — {s.StreamRejection}";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(s.Error))
|
||||
{
|
||||
Banner = s.Error;
|
||||
HasBanner = true;
|
||||
BannerIsWarning = false;
|
||||
}
|
||||
else if (s.DryRun && IsRunning)
|
||||
{
|
||||
Banner = "DRY-RUN attivo: le decisioni vengono calcolate e mostrate, ma nessun ordine viene inviato.";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
HasBanner = false;
|
||||
}
|
||||
ApplyBanner(s);
|
||||
|
||||
Equity = s.Equity;
|
||||
Cash = s.Cash;
|
||||
PnlToday = s.PnlToday;
|
||||
PnlTodayPct = s.PnlTodayPct;
|
||||
WalletBalance = s.WalletBalance;
|
||||
AvailableBalance = s.AvailableBalance;
|
||||
Unrealized = s.UnrealizedPnl;
|
||||
RealizedToday = s.RealizedToday;
|
||||
PnlSession = s.PnlSession;
|
||||
PnlSessionPct = s.PnlSessionPct;
|
||||
PnlAllTime = s.PnlAllTime;
|
||||
PnlAllTimePct = s.PnlAllTimePct;
|
||||
HasAllTime = s.HasAllTime;
|
||||
Unrealized = s.UnrealizedPnl;
|
||||
MarginRatio = s.MarginRatio;
|
||||
ExposurePct = s.ExposurePct;
|
||||
OpenPositions = s.OpenPositions;
|
||||
MaxOpenPositions = s.MaxOpenPositions;
|
||||
OpenPairs = s.OpenPairs;
|
||||
MaxOpenPairs = s.MaxOpenPairs;
|
||||
TradesToday = s.TradesToday;
|
||||
MaxTradesPerDay = s.MaxTradesPerDay;
|
||||
MaxDailyLossPct = s.MaxDailyLossPct;
|
||||
RiskPerTradePct = s.RiskPerTradePct;
|
||||
Raise(nameof(PositionsDisplay));
|
||||
Sizing = s.Sizing;
|
||||
Leverage = s.Leverage;
|
||||
Raise(nameof(PairsDisplay));
|
||||
Raise(nameof(TradesDisplay));
|
||||
|
||||
SessionStatus = s.SessionStatus;
|
||||
MarketDataState = s.MarketDataState;
|
||||
TradeStreamState = s.TradeStreamState;
|
||||
OrderStreamState = s.OrderStreamState;
|
||||
TimeFrame = s.TimeFrame;
|
||||
Endpoint = s.Endpoint;
|
||||
Uptime = s.Uptime > TimeSpan.Zero ? FormatUptime(s.Uptime) : "—";
|
||||
Latency = $"{s.BarToSignal}\n{s.SignalToOrder}";
|
||||
Counters = $"barre {s.Bars} segnali {s.Signals} ordini {s.Orders} fill {s.Fills} " +
|
||||
Counters = $"barre {s.Bars} segnali {s.Signals} ordini {s.Orders} eseguiti {s.Fills} " +
|
||||
$"uscite {s.Exits} blocchi risk {s.RiskRejects} errori {s.Errors}";
|
||||
|
||||
double[] curve = new double[s.EquityCurve.Count];
|
||||
@@ -284,40 +249,60 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
Account = s.Account;
|
||||
Raise(nameof(HasAccount));
|
||||
|
||||
Sync(Pairs, s.Pairs, static (a, b) => a.Name == b.Name);
|
||||
Sync(Positions, s.Positions, static (a, b) => a.Symbol == b.Symbol);
|
||||
Sync(Symbols, s.Symbols, static (a, b) => a.Symbol == b.Symbol);
|
||||
Sync(Orders, s.OrderHistory, static (a, b) => a.OrderId == b.OrderId);
|
||||
SyncCharts(s.Prices);
|
||||
SyncEvents(s.Events);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches chart view models to symbols by name and updates them in place. The
|
||||
/// collection only changes when the configured symbol set does, which is never
|
||||
/// while the bot is running.
|
||||
/// Picks the one thing most worth saying at the top of the window. Order matters:
|
||||
/// when the exchange is refusing the data stream, that is the cause and everything
|
||||
/// else on screen is a consequence.
|
||||
/// </summary>
|
||||
private void SyncCharts(IReadOnlyList<PriceSeriesRow> source)
|
||||
private void ApplyBanner(BotSnapshot s)
|
||||
{
|
||||
foreach (PriceSeriesRow row in source)
|
||||
if (s.Halted)
|
||||
{
|
||||
SymbolChartViewModel? target = null;
|
||||
foreach (SymbolChartViewModel candidate in Charts)
|
||||
{
|
||||
if (string.Equals(candidate.Symbol, row.Symbol, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
target = candidate;
|
||||
break;
|
||||
}
|
||||
Banner = $"OPERATIVITÀ SOSPESA — {s.HaltReason}";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target is null)
|
||||
if (!string.IsNullOrEmpty(s.StreamRejection))
|
||||
{
|
||||
target = new SymbolChartViewModel(row.Symbol);
|
||||
Charts.Add(target);
|
||||
Banner = $"BINANCE RIFIUTA LA CONNESSIONE — {s.StreamRejection}";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
return;
|
||||
}
|
||||
|
||||
target.Apply(row);
|
||||
if (!string.IsNullOrEmpty(s.Error))
|
||||
{
|
||||
Banner = s.Error;
|
||||
HasBanner = true;
|
||||
BannerIsWarning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.DryRun && IsRunning)
|
||||
{
|
||||
Banner = "DRY-RUN attivo: le decisioni vengono calcolate e mostrate, ma nessun ordine viene inviato.";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!s.Testnet && IsRunning)
|
||||
{
|
||||
Banner = "Conto REALE: gli ordini impegnano denaro vero.";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
return;
|
||||
}
|
||||
|
||||
HasBanner = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Windows.Controls;
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the side navigation. The page itself is created lazily: building all
|
||||
/// seven at startup would make the window slower to appear for the sake of pages the
|
||||
/// One entry in the side navigation. The page itself is created lazily: building them
|
||||
/// all at startup would make the window slower to appear for the sake of pages the
|
||||
/// operator may never open, and the log page in particular is not cheap.
|
||||
/// </summary>
|
||||
public sealed class NavItem(string title, string glyph, Func<UserControl> factory) : INotifyPropertyChanged
|
||||
@@ -53,7 +53,11 @@ public sealed class NavItem(string title, string glyph, Func<UserControl> factor
|
||||
/// </summary>
|
||||
public interface IUiActions
|
||||
{
|
||||
Task ClosePositionAsync(string symbol);
|
||||
/// <summary>
|
||||
/// Closes a whole pair. Deliberately not a per-symbol call: a leg closed on its own
|
||||
/// leaves its partner unhedged, and nothing in the UI should make that easy.
|
||||
/// </summary>
|
||||
Task ClosePairAsync(string pairName);
|
||||
|
||||
void ShowLogin();
|
||||
|
||||
@@ -68,6 +72,6 @@ public interface IUiActions
|
||||
/// <summary>Asks for a new log directory and persists it to the configuration file.</summary>
|
||||
void ChangeLogDirectory();
|
||||
|
||||
/// <summary>Opens the price chart for a symbol in its own resizable window.</summary>
|
||||
void OpenChartWindow(string symbol);
|
||||
/// <summary>Rewrites the configuration with the factory values, after taking a backup.</summary>
|
||||
void RestoreDefaults();
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.AccountPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
|
||||
|
||||
<UserControl.Resources>
|
||||
<!-- One label/value line, the shape the whole page is built from. -->
|
||||
<Style x:Key="Row" TargetType="Grid">
|
||||
<Setter Property="Margin" Value="0,0,0,9"/>
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel>
|
||||
|
||||
<StackPanel Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Conto" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="I valori arrivano da Alpaca e sono aggiornati a ogni riconciliazione, circa ogni 30 secondi. Sono quello che vede il broker, non un calcolo del bot: se non coincidono con la pagina Stato, quelli giusti sono questi."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Nothing truthful to show before the first reconcile. -->
|
||||
<Border Style="{StaticResource Card}" Padding="26"
|
||||
Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}, ConverterParameter=invert}">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="26"
|
||||
Foreground="{StaticResource Faint}" HorizontalAlignment="Center"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,10,0,0" TextAlignment="Center"
|
||||
Text="Nessun dato dal conto.
Avvia il bot: i valori compaiono alla prima riconciliazione."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}}">
|
||||
|
||||
<!-- ==================== headline ==================== -->
|
||||
<UniformGrid Rows="1" Columns="4" Margin="0,0,-10,10">
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="VALORE DEL PORTAFOGLIO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.PortfolioValue, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding Account.Currency}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="VARIAZIONE DI OGGI" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Account.ChangeToday, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding Account.ChangeToday, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}"
|
||||
Text="{Binding Account.ChangeTodayPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="LIQUIDITÀ" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.Cash, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="non investita"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="POTERE D'ACQUISTO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.BuyingPower, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}"
|
||||
Text="{Binding Account.Multiplier, StringFormat='leva {0:0.#}x'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="10"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ==================== identity ==================== -->
|
||||
<Border Grid.Column="0" Style="{StaticResource Card}" VerticalAlignment="Top">
|
||||
<StackPanel>
|
||||
<TextBlock Text="ANAGRAFICA" Style="{StaticResource Head}"/>
|
||||
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Numero di conto" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.AccountNumber}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Stato" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.Status}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Ambiente" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<Border Style="{StaticResource ModeBadge}" HorizontalAlignment="Right" Padding="7,2">
|
||||
<TextBlock Text="{Binding Mode}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="10.5" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Vendita allo scoperto" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.ShortingEnabled, Converter={StaticResource YesNo}}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Ultimo aggiornamento" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Foreground="{StaticResource Dim}"
|
||||
Text="{Binding Account.UpdatedUtc, Converter={StaticResource LocalTime}}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== balances & limits ==================== -->
|
||||
<Border Grid.Column="2" Style="{StaticResource Card}" VerticalAlignment="Top">
|
||||
<StackPanel>
|
||||
<TextBlock Text="SALDI E LIMITI" Style="{StaticResource Head}"/>
|
||||
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Equity" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.Equity, StringFormat=N2}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Equity alla chiusura precedente" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.LastEquity, StringFormat=N2}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Potere d'acquisto intraday" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.DaytradingBuyingPower, StringFormat=N2}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Operazioni intraday (5 giorni)" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.DaytradeCount}"/>
|
||||
</Grid>
|
||||
<Grid Style="{StaticResource Row}">
|
||||
<TextBlock Text="Pattern day trader" Style="{StaticResource Sub}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
|
||||
Text="{Binding Account.PatternDayTrader, Converter={StaticResource YesNo}}"/>
|
||||
</Grid>
|
||||
|
||||
<Border Background="{StaticResource Bg}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,4,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="RESTRIZIONI" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11.5"
|
||||
TextWrapping="Wrap" Text="{Binding Account.Restrictions}"
|
||||
Foreground="{Binding Account.Restrictions, Converter={StaticResource RestrictionBrush}}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ==================== what the bot is allowed to do ==================== -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,20">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="LIMITI IMPOSTI DAL BOT" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Questi non vengono dal broker: sono i vincoli della configurazione, e si applicano prima che un ordine venga inviato. Il broker dice cosa è possibile, questi dicono cosa è permesso."/>
|
||||
</StackPanel>
|
||||
|
||||
<UniformGrid Rows="1" Columns="4" Margin="0,0,-8,0">
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="DIMENSIONE" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock x:Name="SizingText" Margin="0,4,0,0" FontFamily="{StaticResource Mono}"
|
||||
FontSize="11" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="PERDITA MASSIMA GIORNALIERA" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Text="{Binding MaxDailyLossPct, StringFormat=P2}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="POSIZIONI CONTEMPORANEE" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Text="{Binding MaxOpenPositions}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="OPERAZIONI AL GIORNO" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Text="{Binding MaxTradesPerDay}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Windows.Controls;
|
||||
using Encelado.Core.Risk;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public partial class AccountPage : UserControl
|
||||
{
|
||||
public AccountPage() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Describe(RiskLimits limits)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(limits);
|
||||
SizingText.Text = limits.DescribeSizing();
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.ChartsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Grafici" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Le candele sono le barre su cui la strategia decide davvero, una al giorno. La linea è il prezzo in diretta, campionato una volta al secondo, e serve a vedere cosa succede fra una decisione e l'altra — non a suggerirne un'altra."/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<RadioButton x:Name="CandleMode" Content="Candele" IsChecked="True"
|
||||
GroupName="mode" Checked="OnModeChanged" Margin="0,0,14,0"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5"/>
|
||||
<RadioButton x:Name="LiveMode" Content="Diretta" GroupName="mode" Checked="OnModeChanged"
|
||||
Foreground="{StaticResource Dim}" FontSize="12.5"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<ItemsControl ItemsSource="{Binding Charts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
|
||||
<DockPanel>
|
||||
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,10">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Symbol}" FontSize="15" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="19" FontWeight="SemiBold"
|
||||
Margin="14,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="12.5" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{Binding ChangePct, Converter={StaticResource PnlBrush}}"
|
||||
Text="{Binding ChangePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,0,12,0"
|
||||
Text="{Binding SessionHigh, Converter={StaticResource Price}, StringFormat='max {0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,0,14,0"
|
||||
Text="{Binding SessionLow, Converter={StaticResource Price}, StringFormat='min {0}'}"/>
|
||||
<Button Content="Finestra separata" Padding="10,4" FontSize="11"
|
||||
Click="OnPopOut" Tag="{Binding Symbol}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Bound to the page's own property, not to the view model: which way
|
||||
the charts are drawn is a view preference, not bot state. -->
|
||||
<ui:PriceChart Height="300"
|
||||
Mode="{Binding ChartMode,
|
||||
RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
Opens="{Binding Opens}" Highs="{Binding Highs}"
|
||||
Lows="{Binding Lows}" Closes="{Binding Closes}"
|
||||
LivePrices="{Binding Live}"
|
||||
EmptyText="in attesa di dati — avvia il bot"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -1,44 +0,0 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
public partial class ChartsPage : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// A dependency property rather than a plain field so the charts inside the
|
||||
/// ItemsControl can bind to it and repaint themselves when it changes.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty ChartModeProperty = DependencyProperty.Register(
|
||||
nameof(ChartMode), typeof(PriceChartMode), typeof(ChartsPage),
|
||||
new FrameworkPropertyMetadata(PriceChartMode.Candles));
|
||||
|
||||
public ChartsPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
public PriceChartMode ChartMode
|
||||
{
|
||||
get => (PriceChartMode)GetValue(ChartModeProperty);
|
||||
set => SetValue(ChartModeProperty, value);
|
||||
}
|
||||
|
||||
private void OnModeChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Fires during InitializeComponent, before the field is assigned.
|
||||
if (LiveMode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChartMode = LiveMode.IsChecked == true ? PriceChartMode.Live : PriceChartMode.Candles;
|
||||
}
|
||||
|
||||
private void OnPopOut(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: string symbol } && symbol.Length > 0)
|
||||
{
|
||||
Actions?.OpenChartWindow(symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.OrdersPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<DockPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Ordini" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Lo storico come lo riporta Alpaca, non come lo ricorda il bot: dopo una riconnessione l'elenco del broker è l'unico completo. Si aggiorna a ogni riconciliazione, circa ogni 30 secondi. Le righe evidenziate sono ordini ancora aperti."/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Style="{StaticResource Card}" Padding="0,14,0,6">
|
||||
<DockPanel>
|
||||
<Grid DockPanel.Dock="Top" Margin="16,0,16,8">
|
||||
<TextBlock Text="STORICO" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock HorizontalAlignment="Right" Style="{StaticResource Label}">
|
||||
<Run Text="{Binding Orders.Count, Mode=OneWay}"/><Run Text=" ordini"/>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
|
||||
<TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Center" Margin="0,26,0,26"
|
||||
TextAlignment="Center"
|
||||
Text="Nessun ordine.
Compaiono qui appena il bot ne invia uno, o se il conto ne ha di precedenti.">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Orders.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Orders}">
|
||||
<DataGrid.RowStyle>
|
||||
<Style TargetType="DataGridRow" BasedOn="{StaticResource {x:Type DataGridRow}}">
|
||||
<Style.Triggers>
|
||||
<!-- A working order is the one thing on this page that can still change. -->
|
||||
<DataTrigger Binding="{Binding IsWorking}" Value="True">
|
||||
<Setter Property="Background" Value="#145B8CFF"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</DataGrid.RowStyle>
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="ORA" Width="1.3*" Binding="{Binding SubmittedLocal}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="ASSET" Width="1.2*" Binding="{Binding Symbol}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="LATO" Width="1*" Binding="{Binding Side}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Side, Converter={StaticResource SideBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="TIPO" Width="0.9*" Binding="{Binding Type}"/>
|
||||
<DataGridTextColumn Header="QUANTITÀ" Width="1.2*"
|
||||
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="ESEGUITA" Width="1.2*"
|
||||
Binding="{Binding FilledQuantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="PREZZO MEDIO" Width="1.3*"
|
||||
Binding="{Binding FilledAveragePrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="CONTROVALORE" Width="1.3*"
|
||||
Binding="{Binding Notional, StringFormat=N2}"/>
|
||||
<DataGridTextColumn Header="STATO" Width="1.2*" Binding="{Binding Status}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Status, Converter={StaticResource OrderStatusBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -1,8 +0,0 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
public partial class OrdersPage : UserControl
|
||||
{
|
||||
public OrdersPage() => InitializeComponent();
|
||||
}
|
||||
@@ -5,111 +5,140 @@
|
||||
<DockPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Posizioni" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Text="Operatività" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Tutto ciò che è aperto adesso. Lo stop in tabella è sorvegliato dal motore a ogni quotazione, non è un ordine depositato sul broker: Alpaca non accetta bracket order sulle crypto. Se il processo si chiude con una posizione aperta, quella posizione resta senza stop."/>
|
||||
ToolTip="Le gambe aperte sul conto e gli ordini recenti, come li riporta Binance. Una coppia sana ha sempre DUE gambe: se ne compare una sola, il bot la chiude da solo al prossimo allineamento."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Totals strip: reading a dozen rows to work out the exposure is what this avoids. -->
|
||||
<Border DockPanel.Dock="Top" Style="{StaticResource Card}" Margin="0,0,0,10" Padding="16,13">
|
||||
<UniformGrid Rows="1" Columns="4">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="APERTE" Style="{StaticResource Label}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
Text="{Binding PositionsDisplay}"/>
|
||||
|
||||
<!-- ==================== conto ==================== -->
|
||||
<Border Style="{StaticResource Card}" Padding="15,13">
|
||||
<StackPanel>
|
||||
<TextBlock Text="CONTO FUTURES" Style="{StaticResource Head}"/>
|
||||
|
||||
<UniformGrid Rows="1" Columns="5"
|
||||
Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="SALDO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Account.WalletBalance, StringFormat=N2}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="ESPOSIZIONE" Style="{StaticResource Label}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
Text="{Binding ExposurePct, StringFormat='{}{0:0.0%}'}"/>
|
||||
<TextBlock Text="MARGINE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Account.MarginBalance, StringFormat=N2}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="NON REALIZZATO" Style="{StaticResource Label}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
Text="{Binding Unrealized, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding Unrealized, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Text="DISPONIBILE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Account.AvailableBalance, StringFormat=N2}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="OPERAZIONI OGGI" Style="{StaticResource Label}"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
|
||||
Text="{Binding TradesDisplay}"/>
|
||||
<TextBlock Text="MANTENIMENTO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Account.MarginRatio, StringFormat=P1}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="FEE TIER" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.FeeTier}"/>
|
||||
</StackPanel>
|
||||
</UniformGrid>
|
||||
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,10,0,0"
|
||||
Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}}"
|
||||
Text="{Binding Account.Restrictions, StringFormat='Restrizioni: {0}'}"
|
||||
Foreground="{Binding Account.Restrictions, Converter={StaticResource RestrictionBrush}}"/>
|
||||
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"
|
||||
Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}, ConverterParameter=invert}"
|
||||
Text="In attesa del primo allineamento con Binance. Finché il bot è fermo non c'è nessun conto da mostrare."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Padding="0,14,0,6">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="DETTAGLIO" Style="{StaticResource Head}" Margin="16,0,0,8"/>
|
||||
<!-- ==================== gambe aperte ==================== -->
|
||||
<StackPanel Orientation="Horizontal" Margin="2,14,0,9">
|
||||
<TextBlock Text="GAMBE APERTE" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Ogni coppia aperta produce due righe, una long e una short. Le posizioni si chiudono dalla scheda Stato, sulla coppia: chiudere una gamba sola lascerebbe l'altra scoperta."/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="Nessuna posizione aperta"
|
||||
HorizontalAlignment="Center" Margin="0,26,0,26">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Positions.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Positions}">
|
||||
<Border Style="{StaticResource Card}" Padding="0">
|
||||
<DataGrid ItemsSource="{Binding Positions}" AutoGenerateColumns="False"
|
||||
IsReadOnly="True" HeadersVisibility="Column" GridLinesVisibility="None"
|
||||
Background="Transparent" BorderThickness="0" MinHeight="90">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="ASSET" Binding="{Binding Symbol}" Width="1.3*">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="LATO" Binding="{Binding Side}" Width="0.7*"/>
|
||||
<DataGridTextColumn Header="QUANTITÀ" Width="1.2*"
|
||||
<DataGridTextColumn Header="Simbolo" Binding="{Binding Symbol}" Width="120"/>
|
||||
<DataGridTextColumn Header="Coppia" Binding="{Binding Pair}" Width="150"/>
|
||||
<DataGridTextColumn Header="Lato" Binding="{Binding Side}" Width="70"/>
|
||||
<DataGridTextColumn Header="Quantità" Width="120"
|
||||
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="PREZZO MEDIO" Width="1.2*"
|
||||
<DataGridTextColumn Header="Ingresso" Width="110"
|
||||
Binding="{Binding EntryPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="ULTIMO" Width="1.2*"
|
||||
<DataGridTextColumn Header="Ultimo" Width="110"
|
||||
Binding="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="VALORE" Width="1.2*"
|
||||
<DataGridTextColumn Header="Controvalore" Width="120"
|
||||
Binding="{Binding MarketValue, StringFormat=N2}"/>
|
||||
<DataGridTextColumn Header="P&L" Width="1.2*"
|
||||
<DataGridTextColumn Header="P&L" Width="110"
|
||||
Binding="{Binding UnrealizedPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
<Setter Property="Foreground"
|
||||
Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="%" Width="0.9*"
|
||||
Binding="{Binding UnrealizedPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="STOP" Width="1.1*"
|
||||
Binding="{Binding StopPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="BARRE" Width="0.7*" Binding="{Binding BarsHeld}"/>
|
||||
<DataGridTextColumn Header="APERTA" Width="1.2*"
|
||||
Binding="{Binding OpenedAtUtc, Converter={StaticResource LocalTime}}"/>
|
||||
<DataGridTemplateColumn Header="" Width="92">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Button Content="Chiudi" Style="{StaticResource Danger}"
|
||||
Padding="10,3" FontSize="11"
|
||||
Click="OnClosePosition" Tag="{Binding Symbol}"/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="%" Width="*"
|
||||
Binding="{Binding UnrealizedPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== ordini ==================== -->
|
||||
<StackPanel Orientation="Horizontal" Margin="2,14,0,9">
|
||||
<TextBlock Text="ORDINI RECENTI" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Come Binance li riporta, aggiornati a ogni allineamento. Un ordine con 'riduci' è una chiusura: non può mai aprire il lato opposto."/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Style="{StaticResource Card}" Padding="0">
|
||||
<DataGrid ItemsSource="{Binding Orders}" AutoGenerateColumns="False"
|
||||
IsReadOnly="True" HeadersVisibility="Column" GridLinesVisibility="None"
|
||||
Background="Transparent" BorderThickness="0" MinHeight="140" MaxHeight="420">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Quando" Binding="{Binding SubmittedLocal}" Width="130"/>
|
||||
<DataGridTextColumn Header="Simbolo" Binding="{Binding Symbol}" Width="120"/>
|
||||
<DataGridTextColumn Header="Lato" Binding="{Binding Side}" Width="90">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground"
|
||||
Value="{Binding Side, Converter={StaticResource SideBrush}}"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="Tipo" Binding="{Binding Type}" Width="90"/>
|
||||
<DataGridTextColumn Header="Stato" Binding="{Binding Status}" Width="100">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground"
|
||||
Value="{Binding Status, Converter={StaticResource OrderStatusBrush}}"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="Quantità" Width="120"
|
||||
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="Eseguita" Width="120"
|
||||
Binding="{Binding FilledQuantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="Prezzo" Width="110"
|
||||
Binding="{Binding FilledAveragePrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="Riduci" Width="*"
|
||||
Binding="{Binding ReduceOnly, Converter={StaticResource YesNo}}"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,34 +1,18 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// What the account actually holds and what has been sent to the exchange.
|
||||
/// <para>
|
||||
/// There is deliberately no per-leg close button here. A pair is closed as a pair, from
|
||||
/// the status page: closing one leg on its own would leave the other unhedged, which is
|
||||
/// the single state this strategy must never be in.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class PositionsPage : UserControl
|
||||
{
|
||||
public PositionsPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
private async void OnClosePosition(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: string symbol } button || symbol.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Disabled for the round trip so an impatient second click cannot submit a
|
||||
// second closing order against a position that is already on its way out.
|
||||
button.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.ClosePositionAsync(symbol);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
button.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
<!-- ==================== credenziali ==================== -->
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="CREDENZIALI ALPACA" Style="{StaticResource Head}"/>
|
||||
<TextBlock Text="CREDENZIALI BINANCE" Style="{StaticResource Head}"/>
|
||||
<TextBlock x:Name="CredStatus" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
|
||||
<TextBlock x:Name="CredPath" Style="{StaticResource Sub}" TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
|
||||
@@ -181,8 +181,13 @@
|
||||
ToolTip="Il salvataggio riscrive solo i valori cambiati e conserva tutto il resto del file, commenti compresi. Per modifiche che questa pagina non copre, apri il file a mano."/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="ConfigSummary" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
|
||||
<Button Content="Apri il file di configurazione" Click="OnOpenConfig"
|
||||
HorizontalAlignment="Left" Margin="0,14,0,0"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
|
||||
<Button Content="Apri il file di configurazione" Click="OnOpenConfig"/>
|
||||
<Button Content="Ripristina i valori predefiniti" Click="OnRestoreDefaults"
|
||||
Style="{StaticResource Danger}" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,10,0,0" TextWrapping="Wrap"
|
||||
Text="Il ripristino riscrive l'intero file con la configurazione di fabbrica, commenti compresi, dopo averne salvato una copia con la data accanto all'originale. Le chiavi API non vengono toccate."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -42,8 +42,7 @@ public partial class SettingsPage : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
string strategy = config.EnabledSymbols.FirstOrDefault()?.Strategy ?? "trend-filter";
|
||||
_groups = SettingsCatalogue.Build(config, strategy);
|
||||
_groups = SettingsCatalogue.Build(config);
|
||||
|
||||
foreach (SettingGroup group in _groups)
|
||||
{
|
||||
@@ -172,8 +171,21 @@ public partial class SettingsPage : UserControl
|
||||
ConfigWriter.Apply(temporary, changes);
|
||||
|
||||
BotConfig candidate = ConfigLoader.Load(temporary, out _);
|
||||
|
||||
// The whole validator, not a subset. Individual fields can each be
|
||||
// reasonable while the combination is not — a stake that no longer fits
|
||||
// inside the exposure cap, a stop below the entry threshold — and finding
|
||||
// that out at the next start, from a file the operator has already closed,
|
||||
// is the worst moment to find it out.
|
||||
candidate.Risk.Validate();
|
||||
candidate.Engine.Validate();
|
||||
candidate.Logging.Validate();
|
||||
|
||||
foreach (PairConfig pair in candidate.EnabledPairs)
|
||||
{
|
||||
pair.Validate();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or IOException
|
||||
@@ -217,6 +229,8 @@ public partial class SettingsPage : UserControl
|
||||
string.IsNullOrWhiteSpace(file) ? null : $"{file} — {what}";
|
||||
}
|
||||
|
||||
private void OnRestoreDefaults(object sender, RoutedEventArgs e) => Actions?.RestoreDefaults();
|
||||
|
||||
private void OnLogin(object sender, RoutedEventArgs e) => Actions?.ShowLogin();
|
||||
|
||||
private void OnLogout(object sender, RoutedEventArgs e) => Actions?.ForgetCredentials();
|
||||
|
||||
@@ -3,10 +3,147 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
|
||||
|
||||
<UserControl.Resources>
|
||||
|
||||
<!--
|
||||
Una coppia. È la riga che risponde a tutto: dov'è lo spread adesso, se la relazione
|
||||
regge, se siamo dentro, e — quando non lo siamo — che cosa esattamente ce lo impedisce.
|
||||
L'ultima riga è la frase della strategia: non un codice, una frase.
|
||||
-->
|
||||
<DataTemplate x:Key="PairTemplate">
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,9" Padding="15,13">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0">
|
||||
|
||||
<!-- intestazione: nome, stato, z-score -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Name}" FontSize="15" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<Border Style="{StaticResource Chip}" Margin="10,0,0,0" Padding="7,2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StateLabel}" FontSize="10.5">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsOpen}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding AtThreshold}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Up}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Cointegrated}" Value="False">
|
||||
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="z" Style="{StaticResource Label}" Margin="18,0,5,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding ZDisplay}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="15" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
Foreground="{Binding ZScore, Converter={StaticResource PnlBrush}}"/>
|
||||
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="16,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding EntryZ, StringFormat='soglia ±{0:0.0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding BetaDisplay, StringFormat='β {0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding PValueDisplay, StringFormat='p {0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Text="{Binding HalfLifeDisplay, StringFormat='emivita {0}'}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
La barra dello z-score. Il fondo è la distanza fino allo stop, il riempimento
|
||||
è dove siamo adesso: a colpo d'occhio dice se la coppia è ferma, pronta o in
|
||||
pericolo, che è l'unica cosa da leggere davvero in fretta.
|
||||
-->
|
||||
<ProgressBar Height="6" Minimum="0" Maximum="1" Value="{Binding ZProgress, Mode=OneWay}"
|
||||
Margin="0,11,0,0" BorderThickness="0"
|
||||
Background="{StaticResource Panel2}">
|
||||
<ProgressBar.Style>
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ProgressBar">
|
||||
<Border CornerRadius="3" Background="{TemplateBinding Background}"
|
||||
ClipToBounds="True">
|
||||
<Border x:Name="PART_Indicator" CornerRadius="3"
|
||||
HorizontalAlignment="Left"
|
||||
Background="{TemplateBinding Foreground}"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding AtThreshold}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Up}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsOpen}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ProgressBar.Style>
|
||||
</ProgressBar>
|
||||
|
||||
<!-- la frase: che cosa farebbe adesso e cosa dovrebbe cambiare -->
|
||||
<TextBlock Text="{Binding Intent}" Style="{StaticResource Sub}" Margin="0,10,0,0"
|
||||
TextWrapping="Wrap"/>
|
||||
|
||||
<!-- l'ultimo rifiuto, quando ce n'è stato uno -->
|
||||
<TextBlock Margin="0,4,0,0" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource Warn}"
|
||||
Text="{Binding LastRefusal, StringFormat='ultimo rifiuto: {0}'}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding LastRefusal}" Value="{x:Null}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<!-- destra: posizione e comando di chiusura -->
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="20,0,0,0"
|
||||
HorizontalAlignment="Right"
|
||||
Visibility="{Binding IsOpen, Converter={StaticResource BoolVis}}">
|
||||
<TextBlock Style="{StaticResource Value}" HorizontalAlignment="Right"
|
||||
Text="{Binding UnrealizedPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" HorizontalAlignment="Right"
|
||||
Text="{Binding EntryZScore, StringFormat='aperta a z {0:+0.00;-0.00}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" HorizontalAlignment="Right"
|
||||
Text="{Binding BarsHeld, StringFormat='da {0} barre'}"/>
|
||||
<Button Content="Chiudi" Click="OnClosePair" Tag="{Binding Name}"
|
||||
Style="{StaticResource Danger}" Margin="0,8,0,0" MinWidth="86"
|
||||
HorizontalAlignment="Right"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
</UserControl.Resources>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel>
|
||||
|
||||
<!-- Kill switch, engine error or dry-run notice. Never more than one at a time. -->
|
||||
<!-- Sospensione, errore del motore o avviso dry-run. Mai più di uno alla volta. -->
|
||||
<Border Margin="0,0,0,12" CornerRadius="9" Padding="14,10"
|
||||
Visibility="{Binding HasBanner, Converter={StaticResource BoolVis}}">
|
||||
<Border.Style>
|
||||
@@ -36,346 +173,129 @@
|
||||
</TextBlock>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== KPI row ==================== -->
|
||||
<UniformGrid Rows="1" Columns="6" Margin="0,0,-10,12">
|
||||
<!-- ==================== riga dei numeri ==================== -->
|
||||
<UniformGrid Rows="1" Columns="5" Margin="0,0,-10,14">
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="EQUITY" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="MARGINE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Equity, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding Cash, StringFormat='liquidità {0:N2}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L OGGI" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PnlToday, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding PnlToday, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding PnlTodayPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding WalletBalance, StringFormat='saldo {0:N2} USDT'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L SESSIONE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PnlSession, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding PnlSession, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding PnlSession, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding PnlSessionPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L TOTALE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PnlAllTime, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding PnlAllTime, Converter={StaticResource PnlBrush}}"
|
||||
Visibility="{Binding HasAllTime, Converter={StaticResource BoolVis}}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="—" Foreground="{StaticResource Faint}"
|
||||
Visibility="{Binding HasAllTime, Converter={StaticResource BoolVis}, ConverterParameter=invert}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding PnlAllTimePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"
|
||||
Visibility="{Binding HasAllTime, Converter={StaticResource BoolVis}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="storico non disponibile"
|
||||
Visibility="{Binding HasAllTime, Converter={StaticResource BoolVis}, ConverterParameter=invert}"/>
|
||||
<TextBlock Style="{StaticResource Sub}"
|
||||
Text="{Binding PnlSessionPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="NON REALIZZATO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Unrealized, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Unrealized, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding Unrealized, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding ExposurePct, StringFormat='esposizione {0:0.0%}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}"
|
||||
Text="{Binding RealizedToday, StringFormat='realizzato {0:+#,##0.00;-#,##0.00;0.00}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="POSIZIONI" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PositionsDisplay}"/>
|
||||
<TextBlock Style="{StaticResource Sub}">
|
||||
<Run Text="{Binding TradesDisplay, Mode=OneWay}"/><Run Text=" trade oggi"/>
|
||||
</TextBlock>
|
||||
<TextBlock Text="COPPIE APERTE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding PairsDisplay}"/>
|
||||
<TextBlock Style="{StaticResource Sub}"
|
||||
Text="{Binding ExposurePct, StringFormat='esposizione {0:0.0%} dell''equity'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="MARGINE USATO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding MarginRatio, StringFormat=P1}"/>
|
||||
<TextBlock Style="{StaticResource Sub}"
|
||||
Text="{Binding AvailableBalance, StringFormat='libero {0:N2}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
|
||||
<!-- ==================== prices + strategies ==================== -->
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2.1*"/>
|
||||
<ColumnDefinition Width="10"/>
|
||||
<ColumnDefinition Width="1*" MinWidth="330"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- ==================== le coppie ==================== -->
|
||||
<TextBlock Text="COPPIE" Style="{StaticResource Head}" Margin="2,0,0,9"/>
|
||||
|
||||
<StackPanel Grid.Column="0">
|
||||
<ItemsControl ItemsSource="{Binding Pairs}" ItemTemplate="{StaticResource PairTemplate}"/>
|
||||
|
||||
<!--
|
||||
Live price strip. The full charts live on their own page; here the operator
|
||||
just needs the number and which way it is going, which is what fits on a
|
||||
status screen without pushing everything else below the fold.
|
||||
-->
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="PREZZI" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Prezzo in diretta, campionato dallo stream una volta al secondo. Clic su un riquadro per aprire il grafico in una finestra separata."/>
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Charts}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Rows="1"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="14,11" Margin="0,0,8,0"
|
||||
Cursor="Hand" MouseLeftButtonUp="OnChartRequested" Tag="{Binding Symbol}"
|
||||
ToolTip="Apri il grafico in una finestra separata">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Symbol}" FontWeight="SemiBold" FontSize="12.5"/>
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="23" FontWeight="SemiBold"
|
||||
Margin="0,6,0,0"
|
||||
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<TextBlock FontFamily="{StaticResource Mono}" FontSize="11.5"
|
||||
Foreground="{Binding ChangePct, Converter={StaticResource PnlBrush}}"
|
||||
Text="{Binding ChangePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="10,0,0,0"
|
||||
Text="{Binding SessionHigh, Converter={StaticResource Price}, StringFormat='max {0}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="8,0,0,0"
|
||||
Text="{Binding SessionLow, Converter={StaticResource Price}, StringFormat='min {0}'}"/>
|
||||
</StackPanel>
|
||||
<ui:PriceChart Height="52" Margin="0,8,0,0" Mode="Live"
|
||||
LivePrices="{Binding Live}"
|
||||
EmptyText="in attesa di quotazioni"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="Nessun asset configurato" HorizontalAlignment="Center" Margin="0,14">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Border Padding="18">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border" BasedOn="{StaticResource Card}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Charts.Count}" Value="0">
|
||||
<DataTrigger Binding="{Binding Pairs.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border.Style>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"
|
||||
Text="Nessuna coppia configurata. Aggiungine una da Impostazioni."/>
|
||||
</Border>
|
||||
|
||||
<!-- open positions, compact -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0" Padding="0,14,0,6">
|
||||
<StackPanel>
|
||||
<TextBlock Text="POSIZIONI APERTE" Style="{StaticResource Head}" Margin="16,0,0,8"/>
|
||||
<DataGrid ItemsSource="{Binding Positions}" MaxHeight="200">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="ASSET" Binding="{Binding Symbol}" Width="1.2*">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="QUANTITÀ" Width="1.1*"
|
||||
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
|
||||
<DataGridTextColumn Header="INGRESSO" Width="1.1*"
|
||||
Binding="{Binding EntryPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="ULTIMO" Width="1.1*"
|
||||
Binding="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="P&L" Width="1.1*"
|
||||
Binding="{Binding UnrealizedPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="%" Width="0.8*"
|
||||
Binding="{Binding UnrealizedPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Right"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="STOP" Width="1*"
|
||||
Binding="{Binding StopPrice, Converter={StaticResource Price}}"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<TextBlock Text="Nessuna posizione aperta" HorizontalAlignment="Center" Margin="0,16">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Positions.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- equity curve -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="EQUITY DELLA SESSIONE" Style="{StaticResource Head}"/>
|
||||
<ui:SparkChart Height="110" Values="{Binding EquityCurve}"
|
||||
EmptyText="in attesa di dati — avvia il bot"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- activity strip: bounded on purpose, the full log has its own page -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0" Padding="0,14,0,10">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="16,0,16,8">
|
||||
<!-- ==================== attività ==================== -->
|
||||
<StackPanel Orientation="Horizontal" Margin="2,14,0,9">
|
||||
<TextBlock Text="ATTIVITÀ" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12">
|
||||
<TextBlock.ToolTip>
|
||||
<TextBlock>
|
||||
<Run Text="Le ultime "/><Run Text="{Binding StatusLines, Mode=OneWay}"/><Run
|
||||
Text=" righe, per non tenerne migliaia in memoria su una pagina che si guarda di sfuggita. Il log completo, con filtri e ricerca, è nella scheda Log."/>
|
||||
</TextBlock>
|
||||
</TextBlock.ToolTip>
|
||||
</TextBlock>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Le ultime righe di log. La scheda Log tiene tutta la cronologia e può aprire il file su disco."/>
|
||||
</StackPanel>
|
||||
<ScrollViewer x:Name="FeedScroll" VerticalScrollBarVisibility="Auto" MaxHeight="230">
|
||||
<ItemsControl ItemsSource="{Binding Events}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><VirtualizingStackPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
|
||||
<Border Style="{StaticResource Card}" Padding="12,10">
|
||||
<ItemsControl ItemsSource="{Binding Events}" MaxHeight="260">
|
||||
<ItemsControl.Template>
|
||||
<ControlTemplate TargetType="ItemsControl">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsPresenter/>
|
||||
</ScrollViewer>
|
||||
</ControlTemplate>
|
||||
</ItemsControl.Template>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" Margin="16,1">
|
||||
<TextBlock Text="{Binding Time}" Foreground="{StaticResource Faint}"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11" Margin="0,0,10,0"/>
|
||||
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
<StackPanel Orientation="Horizontal" Margin="0,1">
|
||||
<TextBlock Text="{Binding Time}" FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Foreground="{StaticResource Faint}" Width="66"/>
|
||||
<TextBlock Text="{Binding Message}" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== right column ==================== -->
|
||||
<StackPanel Grid.Column="2">
|
||||
|
||||
<Border Style="{StaticResource Card}">
|
||||
<!-- ==================== connessioni ==================== -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0" Padding="14,11">
|
||||
<StackPanel>
|
||||
<TextBlock Text="STRATEGIE" Style="{StaticResource Head}"/>
|
||||
<ItemsControl ItemsSource="{Binding Symbols}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="12,10" Margin="0,0,0,8">
|
||||
<TextBlock Text="COLLEGAMENTO" Style="{StaticResource Head}"/>
|
||||
<UniformGrid Rows="1" Columns="4">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Symbol}" FontWeight="SemiBold" FontSize="13.5"/>
|
||||
<Border Style="{StaticResource Chip}" Margin="8,0,0,0" Padding="6,2">
|
||||
<TextBlock Text="{Binding Strategy}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="9.5" Foreground="{StaticResource Faint}"/>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Chip}" Margin="5,0,0,0" Padding="6,2"
|
||||
Visibility="{Binding InPosition, Converter={StaticResource BoolVis}}">
|
||||
<TextBlock Text="IN POSIZIONE" FontFamily="{StaticResource Mono}"
|
||||
FontSize="9.5" Foreground="{StaticResource Accent}"/>
|
||||
</Border>
|
||||
<TextBlock Text="DATI DI MERCATO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding MarketDataState}" Style="{StaticResource Sub}"/>
|
||||
</StackPanel>
|
||||
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}"
|
||||
FontSize="13.5" FontWeight="SemiBold"
|
||||
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Margin="0,9,0,0"
|
||||
Visibility="{Binding Ready, Converter={StaticResource BoolVis}, ConverterParameter=invert}">
|
||||
<ProgressBar Value="{Binding WarmupProgress, Mode=OneWay}" Maximum="1"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,4,0,0">
|
||||
<Run Text="warm-up "/><Run Text="{Binding BarsSeen, Mode=OneWay}"/><Run Text=" / "/><Run Text="{Binding WarmupBars, Mode=OneWay}"/><Run Text=" barre"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,10,0,0">
|
||||
<StackPanel.Style>
|
||||
<Style TargetType="StackPanel">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Score}" Value="{x:Null}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</StackPanel.Style>
|
||||
<Grid Width="200" HorizontalAlignment="Center" Height="6">
|
||||
<Border Background="{StaticResource Bg}" CornerRadius="3"/>
|
||||
<Border Width="1" HorizontalAlignment="Center" Background="{StaticResource Line}"/>
|
||||
<Border HorizontalAlignment="Left" CornerRadius="3"
|
||||
Margin="{Binding Score, Converter={StaticResource ScoreOffset}}"
|
||||
Width="{Binding Score, Converter={StaticResource ScoreWidth}}"
|
||||
Background="{Binding Score, Converter={StaticResource ScoreBrush}}"/>
|
||||
</Grid>
|
||||
<TextBlock HorizontalAlignment="Center" Margin="0,4,0,0"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11"
|
||||
Foreground="{Binding Score, Converter={StaticResource ScoreBrush}}"
|
||||
Text="{Binding Score, StringFormat='convinzione {0:0.00}'}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Metrics}" Margin="0,9,0,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="{StaticResource Bg}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="7,4" Margin="0,0,5,5">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Name}" Style="{StaticResource Label}" FontSize="9"/>
|
||||
<TextBlock Text="{Binding Display}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="11.5" Margin="0,2,0,0"/>
|
||||
<TextBlock Text="FLUSSO ORDINI" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding OrderStreamState}" Style="{StaticResource Sub}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="MOTORE" Style="{StaticResource Head}"/>
|
||||
<TextBlock Text="{Binding Counters}" Style="{StaticResource Sub}" TextWrapping="Wrap" Margin="0"/>
|
||||
<TextBlock Text="{Binding Latency}" Style="{StaticResource Sub}" TextWrapping="Wrap" Margin="0,8,0,0"/>
|
||||
<TextBlock Text="TIMEFRAME" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding TimeFrame}" Style="{StaticResource Sub}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="SESSIONE" Style="{StaticResource Head}"/>
|
||||
<TextBlock Text="{Binding SessionStatus}" Style="{StaticResource Sub}" TextWrapping="Wrap" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Sub}">
|
||||
<Run Text="dati "/><Run Text="{Binding MarketDataState, Mode=OneWay}"/>
|
||||
<Run Text=" ordini "/><Run Text="{Binding TradeStreamState, Mode=OneWay}"/>
|
||||
<Run Text=" uptime "/><Run Text="{Binding Uptime, Mode=OneWay}"/>
|
||||
</TextBlock>
|
||||
<TextBlock Text="ATTIVO DA" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding Uptime}" Style="{StaticResource Sub}"/>
|
||||
</StackPanel>
|
||||
</UniformGrid>
|
||||
<TextBlock Text="{Binding Counters}" Style="{StaticResource Sub}" Margin="0,10,0,0"
|
||||
FontFamily="{StaticResource Mono}" FontSize="11" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,47 +1,38 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The page the window opens on: is the bot alive, what does it hold, what is it
|
||||
/// thinking, and what has it just done.
|
||||
/// The page the window opens on: is the bot alive, what does each pair look like, what
|
||||
/// is it thinking, and what has it just done.
|
||||
/// </summary>
|
||||
public partial class StatusPage : UserControl
|
||||
{
|
||||
private bool _pinnedToBottom = true;
|
||||
|
||||
public StatusPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
FeedScroll.ScrollChanged += OnFeedScrolled;
|
||||
}
|
||||
public StatusPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the activity strip pinned to the newest line, but only while the operator
|
||||
/// has not scrolled up. Following the tail unconditionally would yank the view away
|
||||
/// from whatever they were trying to read.
|
||||
/// </summary>
|
||||
private void OnFeedScrolled(object sender, ScrollChangedEventArgs e)
|
||||
private async void OnClosePair(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (e.ExtentHeightChange == 0)
|
||||
if (sender is not FrameworkElement { Tag: string pair } button || pair.Length == 0)
|
||||
{
|
||||
_pinnedToBottom = FeedScroll.VerticalOffset >= FeedScroll.ScrollableHeight - 2;
|
||||
}
|
||||
else if (_pinnedToBottom)
|
||||
{
|
||||
FeedScroll.ScrollToEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private void OnChartRequested(object sender, MouseButtonEventArgs e)
|
||||
// Disabled for the round trip so an impatient second click cannot submit a
|
||||
// second closing order against a pair that is already on its way out.
|
||||
button.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
if (sender is FrameworkElement { Tag: string symbol } && symbol.Length > 0)
|
||||
if (Actions is not null)
|
||||
{
|
||||
Actions?.OpenChartWindow(symbol);
|
||||
await Actions.ClosePairAsync(pair);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
button.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>What the chart draws.</summary>
|
||||
public enum PriceChartMode
|
||||
{
|
||||
/// <summary>Closed bars as candles — what the strategy actually decides on.</summary>
|
||||
Candles = 0,
|
||||
|
||||
/// <summary>The live quote line, sampled roughly once a second.</summary>
|
||||
Live = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Price chart drawn straight into a <see cref="DrawingContext"/>: candles or a live
|
||||
/// line, a right-hand price axis, and a marker on the last price.
|
||||
/// <para>
|
||||
/// Hand-drawn for the same reason as <see cref="SparkChart"/> — a couple of hundred
|
||||
/// candles repainted once a second is far cheaper as immediate-mode drawing than as a
|
||||
/// retained visual tree, and it keeps the application free of charting dependencies.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PriceChart : FrameworkElement
|
||||
{
|
||||
private static readonly Typeface Face = new("Cascadia Mono, Consolas");
|
||||
|
||||
private static readonly SolidColorBrush Grid = Frozen(Color.FromArgb(0x30, 0x21, 0x2A, 0x3D));
|
||||
private static readonly SolidColorBrush UpFill = Frozen(Color.FromArgb(0xE0, 0x2E, 0xE6, 0xA8));
|
||||
private static readonly SolidColorBrush DownFill = Frozen(Color.FromArgb(0xE0, 0xFF, 0x5A, 0x7A));
|
||||
private static readonly Pen UpPen = FrozenPen(Color.FromRgb(0x2E, 0xE6, 0xA8), 1);
|
||||
private static readonly Pen DownPen = FrozenPen(Color.FromRgb(0xFF, 0x5A, 0x7A), 1);
|
||||
private static readonly Pen GridPen = FrozenPen(Color.FromArgb(0x28, 0x84, 0x92, 0xAD), 1);
|
||||
|
||||
/// <summary>Width reserved on the right for price labels.</summary>
|
||||
private const double AxisWidth = 62;
|
||||
|
||||
private const double PadY = 10;
|
||||
|
||||
public static readonly DependencyProperty OpensProperty = Series(nameof(Opens));
|
||||
public static readonly DependencyProperty HighsProperty = Series(nameof(Highs));
|
||||
public static readonly DependencyProperty LowsProperty = Series(nameof(Lows));
|
||||
public static readonly DependencyProperty ClosesProperty = Series(nameof(Closes));
|
||||
public static readonly DependencyProperty LivePricesProperty = Series(nameof(LivePrices));
|
||||
|
||||
public static readonly DependencyProperty ModeProperty = DependencyProperty.Register(
|
||||
nameof(Mode), typeof(PriceChartMode), typeof(PriceChart),
|
||||
new FrameworkPropertyMetadata(PriceChartMode.Candles, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty EmptyTextProperty = DependencyProperty.Register(
|
||||
nameof(EmptyText), typeof(string), typeof(PriceChart),
|
||||
new FrameworkPropertyMetadata("in attesa di dati", FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
private static DependencyProperty Series(string name) => DependencyProperty.Register(
|
||||
name, typeof(IReadOnlyList<double>), typeof(PriceChart),
|
||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public IReadOnlyList<double>? Opens
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(OpensProperty);
|
||||
set => SetValue(OpensProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? Highs
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(HighsProperty);
|
||||
set => SetValue(HighsProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? Lows
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(LowsProperty);
|
||||
set => SetValue(LowsProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? Closes
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(ClosesProperty);
|
||||
set => SetValue(ClosesProperty, value);
|
||||
}
|
||||
|
||||
public IReadOnlyList<double>? LivePrices
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(LivePricesProperty);
|
||||
set => SetValue(LivePricesProperty, value);
|
||||
}
|
||||
|
||||
public PriceChartMode Mode
|
||||
{
|
||||
get => (PriceChartMode)GetValue(ModeProperty);
|
||||
set => SetValue(ModeProperty, value);
|
||||
}
|
||||
|
||||
public string EmptyText
|
||||
{
|
||||
get => (string)GetValue(EmptyTextProperty);
|
||||
set => SetValue(EmptyTextProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnRender(DrawingContext dc)
|
||||
{
|
||||
double w = ActualWidth;
|
||||
double h = ActualHeight;
|
||||
if (w <= AxisWidth + 20 || h <= 30)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Mode == PriceChartMode.Live)
|
||||
{
|
||||
RenderLine(dc, w, h);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderCandles(dc, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderCandles(DrawingContext dc, double w, double h)
|
||||
{
|
||||
IReadOnlyList<double>? o = Opens, hi = Highs, lo = Lows, c = Closes;
|
||||
|
||||
int n = c?.Count ?? 0;
|
||||
if (o is null || hi is null || lo is null || c is null || n < 2 ||
|
||||
o.Count < n || hi.Count < n || lo.Count < n)
|
||||
{
|
||||
DrawCentred(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Bounds(hi, lo, n, out double min, out double max))
|
||||
{
|
||||
DrawCentred(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
double plotW = w - AxisWidth;
|
||||
double plotH = h - (PadY * 2);
|
||||
double span = max - min;
|
||||
|
||||
double Y(double v) => PadY + ((1 - ((v - min) / span)) * plotH);
|
||||
|
||||
DrawGrid(dc, plotW, h, min, max, Y);
|
||||
|
||||
// A candle body thinner than a pixel is invisible; below that, fall back to a
|
||||
// close-only line so a long history still shows its shape.
|
||||
double slot = plotW / n;
|
||||
if (slot < 2.5)
|
||||
{
|
||||
DrawPolyline(dc, c, n, i => i / (double)(n - 1) * plotW, Y,
|
||||
c[n - 1] >= c[0] ? UpPen : DownPen);
|
||||
}
|
||||
else
|
||||
{
|
||||
double body = Math.Max(1, slot * 0.62);
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double x = (i * slot) + (slot / 2);
|
||||
bool up = c[i] >= o[i];
|
||||
Pen pen = up ? UpPen : DownPen;
|
||||
Brush fill = up ? UpFill : DownFill;
|
||||
|
||||
// Wick.
|
||||
dc.DrawLine(pen, new Point(Snap(x), Y(hi[i])), new Point(Snap(x), Y(lo[i])));
|
||||
|
||||
double top = Y(Math.Max(o[i], c[i]));
|
||||
double bottom = Y(Math.Min(o[i], c[i]));
|
||||
double height = Math.Max(1, bottom - top);
|
||||
|
||||
dc.DrawRectangle(fill, pen, new Rect(Snap(x - (body / 2)), top, body, height));
|
||||
}
|
||||
}
|
||||
|
||||
DrawAxis(dc, plotW, h, min, max, Y);
|
||||
DrawLastPrice(dc, plotW, c[n - 1], Y, c[n - 1] >= c[0]);
|
||||
}
|
||||
|
||||
private void RenderLine(DrawingContext dc, double w, double h)
|
||||
{
|
||||
IReadOnlyList<double>? values = LivePrices;
|
||||
int n = values?.Count ?? 0;
|
||||
if (values is null || n < 2)
|
||||
{
|
||||
DrawCentred(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Bounds(values, values, n, out double min, out double max))
|
||||
{
|
||||
DrawCentred(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
double plotW = w - AxisWidth;
|
||||
double plotH = h - (PadY * 2);
|
||||
double span = max - min;
|
||||
double Y(double v) => PadY + ((1 - ((v - min) / span)) * plotH);
|
||||
double X(int i) => i / (double)(n - 1) * plotW;
|
||||
|
||||
DrawGrid(dc, plotW, h, min, max, Y);
|
||||
|
||||
bool up = values[n - 1] >= values[0];
|
||||
Color colour = up ? Color.FromRgb(0x2E, 0xE6, 0xA8) : Color.FromRgb(0xFF, 0x5A, 0x7A);
|
||||
|
||||
StreamGeometry area = new();
|
||||
using (StreamGeometryContext ctx = area.Open())
|
||||
{
|
||||
ctx.BeginFigure(new Point(0, h), isFilled: true, isClosed: true);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
ctx.LineTo(new Point(X(i), Y(values[i])), isStroked: false, isSmoothJoin: false);
|
||||
}
|
||||
|
||||
ctx.LineTo(new Point(plotW, h), isStroked: false, isSmoothJoin: false);
|
||||
}
|
||||
|
||||
area.Freeze();
|
||||
|
||||
LinearGradientBrush fill = new(
|
||||
Color.FromArgb(0x3C, colour.R, colour.G, colour.B),
|
||||
Color.FromArgb(0x00, colour.R, colour.G, colour.B),
|
||||
new Point(0, 0), new Point(0, 1));
|
||||
fill.Freeze();
|
||||
|
||||
dc.DrawGeometry(fill, null, area);
|
||||
DrawPolyline(dc, values, n, X, Y, FrozenPen(colour, 1.7));
|
||||
|
||||
DrawAxis(dc, plotW, h, min, max, Y);
|
||||
DrawLastPrice(dc, plotW, values[n - 1], Y, up);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
private static bool Bounds(
|
||||
IReadOnlyList<double> highs, IReadOnlyList<double> lows, int n, out double min, out double max)
|
||||
{
|
||||
min = double.MaxValue;
|
||||
max = double.MinValue;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (double.IsFinite(highs[i]) && highs[i] > 0) { max = Math.Max(max, highs[i]); }
|
||||
if (double.IsFinite(lows[i]) && lows[i] > 0) { min = Math.Min(min, lows[i]); }
|
||||
}
|
||||
|
||||
if (min > max)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Leave a little air, and give a dead-flat series a nominal band so the scale
|
||||
// does not collapse to a division by zero.
|
||||
double span = max - min;
|
||||
if (span <= 0)
|
||||
{
|
||||
span = Math.Max(Math.Abs(max) * 0.002, 0.01);
|
||||
min -= span / 2;
|
||||
max += span / 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
min -= span * 0.04;
|
||||
max += span * 0.04;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DrawGrid(DrawingContext dc, double plotW, double h, double min, double max, Func<double, double> y)
|
||||
{
|
||||
for (int i = 0; i <= 4; i++)
|
||||
{
|
||||
double value = min + ((max - min) * i / 4.0);
|
||||
double py = Snap(y(value));
|
||||
dc.DrawLine(GridPen, new Point(0, py), new Point(plotW, py));
|
||||
}
|
||||
|
||||
dc.DrawLine(GridPen, new Point(Snap(plotW), 0), new Point(Snap(plotW), h));
|
||||
}
|
||||
|
||||
private void DrawAxis(DrawingContext dc, double plotW, double h, double min, double max, Func<double, double> y)
|
||||
{
|
||||
for (int i = 0; i <= 4; i++)
|
||||
{
|
||||
double value = min + ((max - min) * i / 4.0);
|
||||
FormattedText ft = Text(Format(value), 10, Palette.Faint);
|
||||
double py = y(value) - (ft.Height / 2);
|
||||
dc.DrawText(ft, new Point(plotW + 6, Math.Clamp(py, 0, h - ft.Height)));
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawLastPrice(DrawingContext dc, double plotW, double price, Func<double, double> y, bool up)
|
||||
{
|
||||
if (!double.IsFinite(price) || price <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Brush brush = up ? Palette.Up : Palette.Down;
|
||||
Pen pen = up ? UpPen : DownPen;
|
||||
|
||||
double py = Snap(y(price));
|
||||
|
||||
// Dashed marker so it reads as "now" rather than as another grid line.
|
||||
Pen dashed = new(brush, 1) { DashStyle = new DashStyle([4, 3], 0) };
|
||||
dashed.Freeze();
|
||||
dc.DrawLine(dashed, new Point(0, py), new Point(plotW, py));
|
||||
|
||||
FormattedText ft = Text(Format(price), 10.5, Brushes.Black);
|
||||
Rect tag = new(plotW + 2, py - (ft.Height / 2) - 2, ft.Width + 8, ft.Height + 4);
|
||||
dc.DrawRectangle(brush, pen, tag);
|
||||
dc.DrawText(ft, new Point(tag.X + 4, tag.Y + 2));
|
||||
}
|
||||
|
||||
private void DrawPolyline(
|
||||
DrawingContext dc, IReadOnlyList<double> values, int n,
|
||||
Func<int, double> x, Func<double, double> y, Pen pen)
|
||||
{
|
||||
StreamGeometry line = new();
|
||||
using (StreamGeometryContext ctx = line.Open())
|
||||
{
|
||||
ctx.BeginFigure(new Point(x(0), y(values[0])), isFilled: false, isClosed: false);
|
||||
for (int i = 1; i < n; i++)
|
||||
{
|
||||
ctx.LineTo(new Point(x(i), y(values[i])), isStroked: true, isSmoothJoin: true);
|
||||
}
|
||||
}
|
||||
|
||||
line.Freeze();
|
||||
dc.DrawGeometry(null, pen, line);
|
||||
}
|
||||
|
||||
private void DrawCentred(DrawingContext dc, string text, double w, double h)
|
||||
{
|
||||
FormattedText ft = Text(text, 11, Palette.Faint);
|
||||
dc.DrawText(ft, new Point((w - ft.Width) / 2, (h - ft.Height) / 2));
|
||||
}
|
||||
|
||||
private FormattedText Text(string text, double size, Brush brush) => new(
|
||||
text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, Face, size, brush,
|
||||
VisualTreeHelper.GetDpi(this).PixelsPerDip);
|
||||
|
||||
private static string Format(double v) =>
|
||||
v >= 1000 ? v.ToString("N0", CultureInfo.CurrentCulture)
|
||||
: v >= 1 ? v.ToString("N2", CultureInfo.CurrentCulture)
|
||||
: v.ToString("N4", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Aligns a coordinate to the pixel grid so hairlines stay crisp.</summary>
|
||||
private static double Snap(double v) => Math.Round(v) + 0.5;
|
||||
|
||||
private static SolidColorBrush Frozen(Color c)
|
||||
{
|
||||
SolidColorBrush b = new(c);
|
||||
b.Freeze();
|
||||
return b;
|
||||
}
|
||||
|
||||
private static Pen FrozenPen(Color c, double thickness)
|
||||
{
|
||||
Pen p = new(Frozen(c), thickness);
|
||||
p.Freeze();
|
||||
return p;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Encelado.Bot.Engine;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// One charted symbol, updated in place.
|
||||
/// <para>
|
||||
/// Deliberately not a record swapped into the collection each tick. Replacing the item
|
||||
/// would make the <c>ItemsControl</c> tear down and rebuild its container — and with it
|
||||
/// the <see cref="PriceChart"/> — once a second, which flickers and churns. Raising
|
||||
/// property changes on a stable instance repaints the chart and nothing else.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SymbolChartViewModel(string symbol) : INotifyPropertyChanged
|
||||
{
|
||||
private double _lastPrice;
|
||||
private double _sessionOpen;
|
||||
private double _sessionHigh;
|
||||
private double _sessionLow;
|
||||
private double _changePct;
|
||||
private bool _hasBars;
|
||||
private bool _hasLive;
|
||||
private IReadOnlyList<double> _opens = [];
|
||||
private IReadOnlyList<double> _highs = [];
|
||||
private IReadOnlyList<double> _lows = [];
|
||||
private IReadOnlyList<double> _closes = [];
|
||||
private IReadOnlyList<double> _live = [];
|
||||
|
||||
public string Symbol { get; } = symbol;
|
||||
|
||||
public double LastPrice { get => _lastPrice; private set => Set(ref _lastPrice, value); }
|
||||
|
||||
public double SessionOpen { get => _sessionOpen; private set => Set(ref _sessionOpen, value); }
|
||||
|
||||
public double SessionHigh { get => _sessionHigh; private set => Set(ref _sessionHigh, value); }
|
||||
|
||||
public double SessionLow { get => _sessionLow; private set => Set(ref _sessionLow, value); }
|
||||
|
||||
public double ChangePct { get => _changePct; private set => Set(ref _changePct, value); }
|
||||
|
||||
public bool HasBars { get => _hasBars; private set => Set(ref _hasBars, value); }
|
||||
|
||||
public bool HasLive { get => _hasLive; private set => Set(ref _hasLive, value); }
|
||||
|
||||
public IReadOnlyList<double> Opens { get => _opens; private set => Set(ref _opens, value); }
|
||||
|
||||
public IReadOnlyList<double> Highs { get => _highs; private set => Set(ref _highs, value); }
|
||||
|
||||
public IReadOnlyList<double> Lows { get => _lows; private set => Set(ref _lows, value); }
|
||||
|
||||
public IReadOnlyList<double> Closes { get => _closes; private set => Set(ref _closes, value); }
|
||||
|
||||
public IReadOnlyList<double> Live { get => _live; private set => Set(ref _live, value); }
|
||||
|
||||
public void Apply(PriceSeriesRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
LastPrice = row.LastPrice;
|
||||
SessionOpen = row.SessionOpen;
|
||||
SessionHigh = row.SessionHigh;
|
||||
SessionLow = row.SessionLow;
|
||||
ChangePct = row.SessionChangePct;
|
||||
HasBars = row.HasBars;
|
||||
HasLive = row.HasLive;
|
||||
|
||||
// The arrays are rebuilt by the snapshot each tick, so a reference comparison is
|
||||
// enough to know something changed — and a length comparison is enough to know
|
||||
// nothing has, which is the common case between bar closes.
|
||||
if (!ReferenceEquals(_closes, row.BarCloses))
|
||||
{
|
||||
Opens = row.BarOpens;
|
||||
Highs = row.BarHighs;
|
||||
Lows = row.BarLows;
|
||||
Closes = row.BarCloses;
|
||||
}
|
||||
|
||||
Live = row.LivePrices;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>One symbol and the strategy instance that will trade it during a replay.</summary>
|
||||
public sealed record BacktestSymbol(string Symbol, IStrategy Strategy);
|
||||
|
||||
/// <summary>
|
||||
/// Everything that makes a replay realistic. The defaults model Alpaca crypto: a
|
||||
/// marketable-limit slippage plus a per-fill fee, both charged in both directions.
|
||||
/// </summary>
|
||||
public sealed record BacktestSettings
|
||||
{
|
||||
public RiskLimits Risk { get; init; } = new();
|
||||
|
||||
public double StartingEquity { get; init; } = 10_000;
|
||||
|
||||
/// <summary>Price concession paid on every fill, in basis points.</summary>
|
||||
public double SlippageBps { get; init; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Broker fee per fill, in basis points of notional. Alpaca crypto taker fees start
|
||||
/// around 25 bps, so a round trip costs roughly 50 bps. Leaving this at zero is the
|
||||
/// single easiest way to produce a backtest that cannot be reproduced live.
|
||||
/// </summary>
|
||||
public double FeeBps { get; init; } = 25;
|
||||
|
||||
public bool AllowFractional { get; init; } = true;
|
||||
}
|
||||
|
||||
public sealed record ClosedTrade(
|
||||
string Symbol,
|
||||
Side Side,
|
||||
DateTime EntryUtc,
|
||||
DateTime ExitUtc,
|
||||
double Quantity,
|
||||
double EntryPrice,
|
||||
double ExitPrice,
|
||||
double GrossPnl,
|
||||
double Fees,
|
||||
string ExitReason)
|
||||
{
|
||||
/// <summary>Profit after slippage and broker fees. This is the only number that matters.</summary>
|
||||
public double Pnl => GrossPnl - Fees;
|
||||
|
||||
public bool IsWin => Pnl > 0;
|
||||
|
||||
public TimeSpan Holding => ExitUtc - EntryUtc;
|
||||
|
||||
public double ReturnPct => EntryPrice > 0 && Quantity > 0 ? Pnl / (EntryPrice * Quantity) : 0;
|
||||
}
|
||||
|
||||
public sealed record BacktestReport(
|
||||
double StartEquity,
|
||||
double EndEquity,
|
||||
double MaxDrawdownPct,
|
||||
IReadOnlyList<ClosedTrade> Trades,
|
||||
int BarsProcessed,
|
||||
DateTime FromUtc,
|
||||
DateTime ToUtc,
|
||||
double TotalFees)
|
||||
{
|
||||
public static readonly BacktestReport Empty =
|
||||
new(0, 0, 0, [], 0, DateTime.MinValue, DateTime.MinValue, 0);
|
||||
|
||||
public double NetPnl => EndEquity - StartEquity;
|
||||
|
||||
public double ReturnPct => StartEquity > 0 ? NetPnl / StartEquity : 0;
|
||||
|
||||
public int Wins => Trades.Count(t => t.IsWin);
|
||||
|
||||
public int Losses => Trades.Count - Wins;
|
||||
|
||||
public double WinRate => Trades.Count > 0 ? Wins / (double)Trades.Count : 0;
|
||||
|
||||
public double GrossProfit => Trades.Where(t => t.Pnl > 0).Sum(t => t.Pnl);
|
||||
|
||||
public double GrossLoss => -Trades.Where(t => t.Pnl < 0).Sum(t => t.Pnl);
|
||||
|
||||
public double ProfitFactor =>
|
||||
GrossLoss > 0 ? GrossProfit / GrossLoss : GrossProfit > 0 ? double.PositiveInfinity : 0;
|
||||
|
||||
public double AverageWin => Wins > 0 ? GrossProfit / Wins : 0;
|
||||
|
||||
public double AverageLoss => Losses > 0 ? GrossLoss / Losses : 0;
|
||||
|
||||
public double Expectancy => Trades.Count > 0 ? NetPnl / Trades.Count : 0;
|
||||
|
||||
public double Years => (ToUtc - FromUtc).TotalDays / 365.25;
|
||||
|
||||
/// <summary>Compound annual growth rate. Meaningless for very short windows.</summary>
|
||||
public double Cagr
|
||||
{
|
||||
get
|
||||
{
|
||||
if (StartEquity <= 0 || EndEquity <= 0 || Years < 0.08)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.Pow(EndEquity / StartEquity, 1.0 / Years) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Annualised return divided by max drawdown — the ratio that decides if a book is fundable.</summary>
|
||||
public double CalmarRatio => MaxDrawdownPct > 0 ? Cagr / MaxDrawdownPct : 0;
|
||||
|
||||
public TimeSpan AverageHolding => Trades.Count == 0
|
||||
? TimeSpan.Zero
|
||||
: TimeSpan.FromMinutes(Trades.Average(t => t.Holding.TotalMinutes));
|
||||
|
||||
public string Render()
|
||||
{
|
||||
StringBuilder sb = new(1400);
|
||||
CultureInfo ci = CultureInfo.InvariantCulture;
|
||||
|
||||
sb.AppendLine(ci, $"period {FromUtc:yyyy-MM-dd} .. {ToUtc:yyyy-MM-dd} ({Years:F2} years, {BarsProcessed:N0} bars)");
|
||||
sb.AppendLine(ci, $"equity {StartEquity:N2} -> {EndEquity:N2} ({ReturnPct:P2})");
|
||||
sb.AppendLine(ci, $"CAGR {Cagr:P2}");
|
||||
sb.AppendLine(ci, $"max drawdown {MaxDrawdownPct:P2} Calmar {CalmarRatio:F2}");
|
||||
sb.AppendLine(ci, $"net P&L {NetPnl:N2} fees paid {TotalFees:N2}");
|
||||
sb.AppendLine(ci, $"trades {Trades.Count} (wins {Wins} / losses {Losses}, win rate {WinRate:P1})");
|
||||
sb.AppendLine(ci, $"profit factor {(double.IsInfinity(ProfitFactor) ? "inf" : ProfitFactor.ToString("F2", ci))}");
|
||||
sb.AppendLine(ci, $"avg win / loss {AverageWin:N2} / {AverageLoss:N2} expectancy {Expectancy:N2}/trade");
|
||||
sb.AppendLine(ci, $"avg holding {AverageHolding.TotalHours:F1} h");
|
||||
|
||||
if (Trades.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("per symbol:");
|
||||
foreach (IGrouping<string, ClosedTrade> g in Trades.GroupBy(t => t.Symbol).OrderBy(g => g.Key))
|
||||
{
|
||||
sb.AppendLine(ci,
|
||||
$" {g.Key,-12} trades={g.Count(),4} wins={g.Count(t => t.IsWin),4} pnl={g.Sum(t => t.Pnl),14:N2}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("exit reasons:");
|
||||
foreach (IGrouping<string, ClosedTrade> g in Trades.GroupBy(t => Bucket(t.ExitReason)).OrderByDescending(g => g.Count()))
|
||||
{
|
||||
sb.AppendLine(ci, $" {g.Key,-24} {g.Count(),4} pnl={g.Sum(t => t.Pnl),14:N2}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
|
||||
// A trailing stop is how a trend trade normally *takes profit*, so lumping it
|
||||
// in with the protective stop would make a winning exit look like a loss.
|
||||
static string Bucket(string reason) =>
|
||||
reason.Contains("trailing stop", StringComparison.OrdinalIgnoreCase) ? "trailing stop"
|
||||
: reason.Contains("take profit", StringComparison.OrdinalIgnoreCase) ? "take profit"
|
||||
: reason.Contains("stop", StringComparison.OrdinalIgnoreCase) ? "stop loss"
|
||||
: reason.Contains("end of backtest", StringComparison.OrdinalIgnoreCase) ? "end of data"
|
||||
: "signal exit";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Statistics;
|
||||
using Encelado.Core.Strategies;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>Everything that makes a pair replay realistic.</summary>
|
||||
public sealed record PairBacktestSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Taker fee per fill, in basis points of the notional traded. Binance USDⓈ-M
|
||||
/// futures charges 4 bps at VIP0 without the BNB discount.
|
||||
/// </summary>
|
||||
public double TakerFeeBps { get; init; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Price concession on top of the fee, in basis points. A marketable limit on a
|
||||
/// liquid perpetual gives up about half a tick; one basis point is deliberately on
|
||||
/// the pessimistic side of that.
|
||||
/// </summary>
|
||||
public double SlippageBps { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// How many bars the rolling cointegration fit uses. Matches
|
||||
/// <c>engine.calibrationBars</c> so the replay recalibrates exactly as the live bot does.
|
||||
/// </summary>
|
||||
public int CalibrationBars { get; init; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// How often the fit is redone, in bars. 288 five-minute bars is one day, which is
|
||||
/// what the strategy note specifies.
|
||||
/// </summary>
|
||||
public int RecalibrateEveryBars { get; init; } = 288;
|
||||
|
||||
/// <summary>
|
||||
/// Fit β once on the whole history instead of walking it forward.
|
||||
/// <para>
|
||||
/// This is the shape of the reference script, and it is <b>look-ahead biased</b>: the
|
||||
/// hedge ratio it trades with was computed from prices that had not happened yet.
|
||||
/// Useful only to show how much of a result comes from that bias — never to choose a
|
||||
/// parameter.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool FitOnce { get; init; }
|
||||
|
||||
public TimeFrame TimeFrame { get; init; } = TimeFrame.FiveMinutes;
|
||||
|
||||
/// <summary>Cost charged on each position change, as a fraction of the notional deployed.</summary>
|
||||
public double RoundCost => (TakerFeeBps + SlippageBps) / 10_000.0;
|
||||
|
||||
public double BarsPerYear => 365.0 * 24 * 3600 / TimeFrame.Seconds();
|
||||
}
|
||||
|
||||
/// <summary>One completed round trip on the spread.</summary>
|
||||
public sealed record PairTrade(
|
||||
DateTime EntryUtc,
|
||||
DateTime ExitUtc,
|
||||
int Direction,
|
||||
double EntryZ,
|
||||
double ExitZ,
|
||||
double Beta,
|
||||
double ReturnPct,
|
||||
int BarsHeld,
|
||||
string ExitReason)
|
||||
{
|
||||
public bool IsWin => ReturnPct > 0;
|
||||
}
|
||||
|
||||
/// <summary>The result of replaying one pair.</summary>
|
||||
public sealed record PairBacktestReport
|
||||
{
|
||||
public required string PairName { get; init; }
|
||||
|
||||
public required int Bars { get; init; }
|
||||
|
||||
public DateTime FromUtc { get; init; }
|
||||
|
||||
public DateTime ToUtc { get; init; }
|
||||
|
||||
public required IReadOnlyList<PairTrade> Trades { get; init; }
|
||||
|
||||
/// <summary>Compounded return on the notional deployed, as a fraction.</summary>
|
||||
public double NetReturn { get; init; }
|
||||
|
||||
public double GrossReturn { get; init; }
|
||||
|
||||
public double FeesPaid { get; init; }
|
||||
|
||||
public double SharpeRatio { get; init; }
|
||||
|
||||
public double MaxDrawdown { get; init; }
|
||||
|
||||
/// <summary>Fraction of bars spent holding a position.</summary>
|
||||
public double TimeInMarket { get; init; }
|
||||
|
||||
/// <summary>Fraction of recalibrations that produced a cointegrated fit.</summary>
|
||||
public double CointegratedFraction { get; init; }
|
||||
|
||||
public double MedianBeta { get; init; }
|
||||
|
||||
public double MedianHalfLife { get; init; }
|
||||
|
||||
public IReadOnlyList<double> EquityCurve { get; init; } = [];
|
||||
|
||||
public static PairBacktestReport Empty(string name) => new()
|
||||
{
|
||||
PairName = name,
|
||||
Bars = 0,
|
||||
Trades = [],
|
||||
};
|
||||
|
||||
public double Years =>
|
||||
ToUtc > FromUtc ? (ToUtc - FromUtc).TotalDays / 365.25 : 0;
|
||||
|
||||
/// <summary>Compound annual growth rate on the deployed notional.</summary>
|
||||
public double Cagr
|
||||
{
|
||||
get
|
||||
{
|
||||
double years = Years;
|
||||
double growth = 1 + NetReturn;
|
||||
return years > 0.05 && growth > 0 ? Math.Pow(growth, 1 / years) - 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Annual return over the worst peak-to-trough fall. This is the number to rank on:
|
||||
/// it asks what the strategy earned per unit of the pain it actually inflicted,
|
||||
/// which a Sharpe computed on five-minute returns systematically flatters.
|
||||
/// </summary>
|
||||
public double Calmar => MaxDrawdown > 1e-9 ? Cagr / MaxDrawdown : 0;
|
||||
|
||||
public int Wins
|
||||
{
|
||||
get
|
||||
{
|
||||
int wins = 0;
|
||||
foreach (PairTrade t in Trades)
|
||||
{
|
||||
if (t.IsWin)
|
||||
{
|
||||
wins++;
|
||||
}
|
||||
}
|
||||
|
||||
return wins;
|
||||
}
|
||||
}
|
||||
|
||||
public double WinRate => Trades.Count > 0 ? Wins / (double)Trades.Count : 0;
|
||||
|
||||
public double AverageBarsHeld
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Trades.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long total = 0;
|
||||
foreach (PairTrade t in Trades)
|
||||
{
|
||||
total += t.BarsHeld;
|
||||
}
|
||||
|
||||
return total / (double)Trades.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trades per year, which is what decides whether the fee assumption matters.</summary>
|
||||
public double TradesPerYear => Years > 0.05 ? Trades.Count / Years : 0;
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
StringBuilder sb = new(512);
|
||||
sb.Append(CultureInfo.InvariantCulture, $"{PairName,-20} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"barre {Bars,7} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"op {Trades.Count,5} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"netto {NetReturn,9:P2} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"CAGR {Cagr,8:P2} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"DD {MaxDrawdown,7:P2} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"Calmar {Calmar,6:F2} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"Sharpe {SharpeRatio,6:F2} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"vinte {WinRate,6:P1} ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"in mercato {TimeInMarket,6:P1}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays a pair through the <b>same</b> <see cref="StatArbStrategy"/> the live engine
|
||||
/// runs.
|
||||
/// <para>
|
||||
/// That shared instance is the point. A backtest that reimplements the decision rules
|
||||
/// tests the reimplementation: it agrees with the bot right up until the day one of them
|
||||
/// is changed, and then quietly stops measuring anything. Here the only thing the replay
|
||||
/// supplies is the environment — bars, fills, fees — while every entry, exit and refusal
|
||||
/// comes from production code.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cointegration fit walks forward: at each recalibration point β is estimated from
|
||||
/// the <i>preceding</i> window only, exactly as the bot does at runtime. Fitting once over
|
||||
/// the whole file, as the reference script does, trades on a hedge ratio derived from
|
||||
/// prices that had not happened yet, and flatters the result by a wide margin — see
|
||||
/// <see cref="PairBacktestSettings.FitOnce"/>, which exists to measure that gap rather
|
||||
/// than to hide it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// The cointegration fits a replay will install, computed once and reused.
|
||||
/// <para>
|
||||
/// A parameter sweep runs the same bars through the same walk-forward fits hundreds of
|
||||
/// times, changing only the entry and exit thresholds — which the fit does not depend on.
|
||||
/// Recomputing an ADF test with automatic lag selection at every point of every run is
|
||||
/// the difference between a sweep that takes minutes and one that takes a day, and it
|
||||
/// produces identical numbers.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PairCalibrationSchedule
|
||||
{
|
||||
private readonly Dictionary<int, PairCalibration> _fits = [];
|
||||
|
||||
private PairCalibrationSchedule(int stride) => Stride = stride;
|
||||
|
||||
/// <summary>Bars between two fits.</summary>
|
||||
public int Stride { get; }
|
||||
|
||||
public int Count => _fits.Count;
|
||||
|
||||
/// <summary>Fraction of the fits that came back cointegrated.</summary>
|
||||
public double CointegratedFraction
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_fits.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int passed = 0;
|
||||
foreach (PairCalibration fit in _fits.Values)
|
||||
{
|
||||
if (fit.IsCointegrated)
|
||||
{
|
||||
passed++;
|
||||
}
|
||||
}
|
||||
|
||||
return passed / (double)_fits.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGet(int barIndex, out PairCalibration calibration) =>
|
||||
_fits.TryGetValue(barIndex, out calibration);
|
||||
|
||||
/// <summary>
|
||||
/// Runs every walk-forward fit the given settings imply. Each one sees only the
|
||||
/// window that precedes it, so the schedule carries no look-ahead of its own.
|
||||
/// </summary>
|
||||
public static PairCalibrationSchedule Precompute(
|
||||
double[] closeA,
|
||||
double[] closeB,
|
||||
PairBacktestSettings settings,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(closeA);
|
||||
ArgumentNullException.ThrowIfNull(closeB);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
int stride = Math.Max(1, settings.RecalibrateEveryBars);
|
||||
PairCalibrationSchedule schedule = new(stride);
|
||||
|
||||
int n = Math.Min(closeA.Length, closeB.Length);
|
||||
int start = settings.CalibrationBars;
|
||||
|
||||
List<int> points = [];
|
||||
for (int t = start; t < n; t += stride)
|
||||
{
|
||||
points.Add(t);
|
||||
}
|
||||
|
||||
PairCalibration[] results = new PairCalibration[points.Count];
|
||||
|
||||
// Each fit is independent and reads a disjoint slice, so the whole schedule
|
||||
// parallelises cleanly across cores.
|
||||
Parallel.For(0, points.Count, new ParallelOptions { CancellationToken = ct }, i =>
|
||||
{
|
||||
int t = points[i];
|
||||
results[i] = PairBacktest.Calibrate(closeA, closeB, t - settings.CalibrationBars, t);
|
||||
});
|
||||
|
||||
for (int i = 0; i < points.Count; i++)
|
||||
{
|
||||
schedule._fits[points[i]] = results[i];
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PairBacktest
|
||||
{
|
||||
/// <summary>
|
||||
/// Replays two aligned bar series. Both must be the same length and describe the
|
||||
/// same instants — use <see cref="Align"/> to produce them.
|
||||
/// </summary>
|
||||
public static PairBacktestReport Run(
|
||||
string pairName,
|
||||
IReadOnlyList<Bar> barsA,
|
||||
IReadOnlyList<Bar> barsB,
|
||||
StrategyParameters parameters,
|
||||
PairBacktestSettings settings,
|
||||
PairCalibrationSchedule? schedule = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(barsA);
|
||||
ArgumentNullException.ThrowIfNull(barsB);
|
||||
ArgumentNullException.ThrowIfNull(parameters);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
int n = Math.Min(barsA.Count, barsB.Count);
|
||||
if (n < settings.CalibrationBars + 50)
|
||||
{
|
||||
return PairBacktestReport.Empty(pairName);
|
||||
}
|
||||
|
||||
StatArbStrategy strategy = new(parameters);
|
||||
|
||||
double[] closeA = new double[n];
|
||||
double[] closeB = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
closeA[i] = barsA[i].Close;
|
||||
closeB[i] = barsB[i].Close;
|
||||
}
|
||||
|
||||
PairCalibration fixedFit = settings.FitOnce
|
||||
? Calibrate(closeA, closeB, 0, n)
|
||||
: PairCalibration.None;
|
||||
|
||||
List<PairTrade> trades = [];
|
||||
List<double> equity = new(n);
|
||||
List<double> betas = [];
|
||||
List<double> halfLives = [];
|
||||
|
||||
double capital = 1.0;
|
||||
double gross = 1.0;
|
||||
double feesPaid = 0;
|
||||
double peak = 1.0;
|
||||
double maxDrawdown = 0;
|
||||
int cointegrated = 0;
|
||||
int calibrations = 0;
|
||||
int barsInMarket = 0;
|
||||
|
||||
int direction = 0;
|
||||
int barsHeld = 0;
|
||||
double entryZ = 0;
|
||||
double entryBeta = 0;
|
||||
double entryCapital = 1;
|
||||
DateTime entryUtc = default;
|
||||
|
||||
int start = settings.CalibrationBars;
|
||||
|
||||
for (int t = start; t < n; t++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// ---- recalibration, on the same cadence the live engine uses -------
|
||||
if (settings.FitOnce)
|
||||
{
|
||||
if (t == start)
|
||||
{
|
||||
strategy.Recalibrate(fixedFit);
|
||||
Warm(strategy, closeA, closeB, t, settings.CalibrationBars);
|
||||
}
|
||||
}
|
||||
else if ((t - start) % Math.Max(1, settings.RecalibrateEveryBars) == 0)
|
||||
{
|
||||
PairCalibration fit = schedule is not null && schedule.TryGet(t, out PairCalibration cached)
|
||||
? cached
|
||||
: Calibrate(closeA, closeB, t - settings.CalibrationBars, t);
|
||||
|
||||
calibrations++;
|
||||
|
||||
if (fit.IsCointegrated)
|
||||
{
|
||||
cointegrated++;
|
||||
}
|
||||
|
||||
if (fit.IsValid)
|
||||
{
|
||||
betas.Add(fit.Beta);
|
||||
if (double.IsFinite(fit.HalfLifeBars))
|
||||
{
|
||||
halfLives.Add(fit.HalfLifeBars);
|
||||
}
|
||||
}
|
||||
|
||||
strategy.Recalibrate(fit);
|
||||
|
||||
// Recalibrating with a materially different β empties the z-score window,
|
||||
// because a spread computed with a new β is a different series. The live
|
||||
// engine refills it from REST history at that moment; the replay refills
|
||||
// it from the same bars it already has.
|
||||
if (!strategy.IsReady)
|
||||
{
|
||||
Warm(strategy, closeA, closeB, t, settings.CalibrationBars);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the return earned over the bar just finished ------------------
|
||||
// Realised before the new decision, using the position held into it: this is
|
||||
// what stops the replay from acting on a bar it has not lived through yet.
|
||||
double periodReturn = 0;
|
||||
|
||||
if (direction != 0 && t > start)
|
||||
{
|
||||
double retA = SafeReturn(closeA[t - 1], closeA[t]);
|
||||
double retB = SafeReturn(closeB[t - 1], closeB[t]);
|
||||
double beta = entryBeta;
|
||||
|
||||
periodReturn = direction * ((retA - (beta * retB)) / (1 + Math.Abs(beta)));
|
||||
|
||||
gross *= 1 + periodReturn;
|
||||
capital *= 1 + periodReturn;
|
||||
barsInMarket++;
|
||||
barsHeld++;
|
||||
}
|
||||
|
||||
// ---- the decision -------------------------------------------------
|
||||
PairPositionView position = direction == 0
|
||||
? PairPositionView.Flat
|
||||
: new PairPositionView(direction, -direction, entryZ, barsHeld);
|
||||
|
||||
PairSignal signal = strategy.OnBar(closeA[t], closeB[t], position);
|
||||
|
||||
if (direction == 0 && signal.IsEntry)
|
||||
{
|
||||
direction = signal.Kind == PairSignalKind.EnterLongSpread ? 1 : -1;
|
||||
entryZ = signal.ZScore;
|
||||
entryBeta = strategy.Calibration.Beta;
|
||||
entryUtc = barsA[t].TimeUtc;
|
||||
barsHeld = 0;
|
||||
|
||||
double cost = settings.RoundCost;
|
||||
capital *= 1 - cost;
|
||||
feesPaid += cost;
|
||||
entryCapital = capital;
|
||||
}
|
||||
else if (direction != 0 && signal.Kind == PairSignalKind.Exit)
|
||||
{
|
||||
double cost = settings.RoundCost;
|
||||
capital *= 1 - cost;
|
||||
feesPaid += cost;
|
||||
|
||||
trades.Add(new PairTrade(
|
||||
entryUtc,
|
||||
barsA[t].TimeUtc,
|
||||
direction,
|
||||
entryZ,
|
||||
signal.ZScore,
|
||||
entryBeta,
|
||||
entryCapital > 0 ? (capital / entryCapital) - 1 : 0,
|
||||
barsHeld,
|
||||
signal.Reason));
|
||||
|
||||
direction = 0;
|
||||
barsHeld = 0;
|
||||
entryZ = 0;
|
||||
}
|
||||
|
||||
equity.Add(capital);
|
||||
|
||||
if (capital > peak)
|
||||
{
|
||||
peak = capital;
|
||||
}
|
||||
|
||||
double drawdown = peak > 0 ? (peak - capital) / peak : 0;
|
||||
if (drawdown > maxDrawdown)
|
||||
{
|
||||
maxDrawdown = drawdown;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-bar returns of the equity curve itself, so the Sharpe includes the cost of
|
||||
// trading rather than only the price moves.
|
||||
double[] equityReturns = new double[Math.Max(0, equity.Count - 1)];
|
||||
for (int i = 1; i < equity.Count; i++)
|
||||
{
|
||||
equityReturns[i - 1] = equity[i - 1] > 0 ? (equity[i] / equity[i - 1]) - 1 : 0;
|
||||
}
|
||||
|
||||
return new PairBacktestReport
|
||||
{
|
||||
PairName = pairName,
|
||||
Bars = n - start,
|
||||
FromUtc = barsA[start].TimeUtc,
|
||||
ToUtc = barsA[n - 1].TimeUtc,
|
||||
Trades = trades,
|
||||
NetReturn = capital - 1,
|
||||
GrossReturn = gross - 1,
|
||||
FeesPaid = feesPaid,
|
||||
SharpeRatio = Sharpe(equityReturns, settings.BarsPerYear),
|
||||
MaxDrawdown = maxDrawdown,
|
||||
TimeInMarket = n - start > 0 ? barsInMarket / (double)(n - start) : 0,
|
||||
CointegratedFraction = calibrations > 0 ? cointegrated / (double)calibrations : 0,
|
||||
MedianBeta = Median(betas),
|
||||
MedianHalfLife = Median(halfLives),
|
||||
EquityCurve = equity,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refills the strategy's rolling window from the bars immediately before
|
||||
/// <paramref name="upTo"/>, without letting any of them produce a trade.
|
||||
/// </summary>
|
||||
private static void Warm(
|
||||
StatArbStrategy strategy, double[] closeA, double[] closeB, int upTo, int window)
|
||||
{
|
||||
int from = Math.Max(0, upTo - window);
|
||||
for (int i = from; i < upTo; i++)
|
||||
{
|
||||
_ = strategy.OnBar(closeA[i], closeB[i], PairPositionView.Flat);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fits the cointegrating relationship over <c>[from, to)</c>.</summary>
|
||||
public static PairCalibration Calibrate(double[] closeA, double[] closeB, int from, int to)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(closeA);
|
||||
ArgumentNullException.ThrowIfNull(closeB);
|
||||
|
||||
int count = to - from;
|
||||
if (count < 30)
|
||||
{
|
||||
return PairCalibration.None;
|
||||
}
|
||||
|
||||
double[] logA = new double[count];
|
||||
double[] logB = new double[count];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
double a = closeA[from + i];
|
||||
double b = closeB[from + i];
|
||||
if (a <= 0 || b <= 0)
|
||||
{
|
||||
return PairCalibration.None;
|
||||
}
|
||||
|
||||
logA[i] = Math.Log(a);
|
||||
logB[i] = Math.Log(b);
|
||||
}
|
||||
|
||||
CointegrationResult result = Cointegration.Test(logA, logB);
|
||||
|
||||
return new PairCalibration(
|
||||
result.Alpha,
|
||||
result.Beta,
|
||||
result.PValue,
|
||||
result.AdfStatistic,
|
||||
result.CriticalValue5,
|
||||
result.HalfLifeBars,
|
||||
result.IsCointegrated,
|
||||
DateTime.UtcNow,
|
||||
result.Observations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pairs up two bar series on their timestamps, dropping anything that exists on one
|
||||
/// side only.
|
||||
/// <para>
|
||||
/// Necessary rather than tidy. Exchange dumps have gaps — a maintenance window on one
|
||||
/// symbol, a delisting scare on the other — and pairing by index across a gap shifts
|
||||
/// one leg against the other for the rest of the file, which turns the entire spread
|
||||
/// series into noise without producing a single obviously wrong number.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static (List<Bar> A, List<Bar> B) Align(IReadOnlyList<Bar> barsA, IReadOnlyList<Bar> barsB)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(barsA);
|
||||
ArgumentNullException.ThrowIfNull(barsB);
|
||||
|
||||
List<Bar> outA = new(Math.Min(barsA.Count, barsB.Count));
|
||||
List<Bar> outB = new(Math.Min(barsA.Count, barsB.Count));
|
||||
|
||||
int i = 0;
|
||||
int j = 0;
|
||||
|
||||
while (i < barsA.Count && j < barsB.Count)
|
||||
{
|
||||
DateTime ta = barsA[i].TimeUtc;
|
||||
DateTime tb = barsB[j].TimeUtc;
|
||||
|
||||
if (ta == tb)
|
||||
{
|
||||
if (barsA[i].Close > 0 && barsB[j].Close > 0)
|
||||
{
|
||||
outA.Add(barsA[i]);
|
||||
outB.Add(barsB[j]);
|
||||
}
|
||||
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
else if (ta < tb)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
return (outA, outB);
|
||||
}
|
||||
|
||||
private static double SafeReturn(double previous, double current) =>
|
||||
previous > 0 && double.IsFinite(previous) && double.IsFinite(current)
|
||||
? (current / previous) - 1
|
||||
: 0;
|
||||
|
||||
private static double Sharpe(double[] returns, double barsPerYear)
|
||||
{
|
||||
if (returns.Length < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double sum = 0;
|
||||
foreach (double r in returns)
|
||||
{
|
||||
sum += r;
|
||||
}
|
||||
|
||||
double mean = sum / returns.Length;
|
||||
double variance = 0;
|
||||
foreach (double r in returns)
|
||||
{
|
||||
double d = r - mean;
|
||||
variance += d * d;
|
||||
}
|
||||
|
||||
double sd = Math.Sqrt(variance / (returns.Length - 1));
|
||||
return sd > 0 ? mean / sd * Math.Sqrt(barsPerYear) : 0;
|
||||
}
|
||||
|
||||
private static double Median(List<double> values)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
values.Sort();
|
||||
int mid = values.Count / 2;
|
||||
return values.Count % 2 == 1
|
||||
? values[mid]
|
||||
: (values[mid - 1] + values[mid]) / 2;
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic bar-by-bar replay of strategies over historical data.
|
||||
/// <para>
|
||||
/// It drives the same <see cref="IStrategy"/> and <see cref="RiskEngine"/> instances the
|
||||
/// live engine uses, so what it measures is the code that will actually trade. The
|
||||
/// execution assumptions are deliberately pessimistic:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>entries fill at the <b>next</b> bar's open, never at the signal bar's close —
|
||||
/// there is no way to act on a close you have only just observed;</item>
|
||||
/// <item>the stop is checked before the target, so a bar that straddles both is scored
|
||||
/// as a loss;</item>
|
||||
/// <item>every fill pays slippage <i>and</i> a broker fee, in both directions;</item>
|
||||
/// <item>all symbols are merged into one chronological stream, so shared equity and the
|
||||
/// shared risk limits behave exactly as they would live.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class Replayer(BacktestSettings settings)
|
||||
{
|
||||
private sealed class SimState(string symbol, IStrategy strategy, IReadOnlyList<Bar> bars)
|
||||
{
|
||||
public string Symbol { get; } = symbol;
|
||||
|
||||
public IStrategy Strategy { get; } = strategy;
|
||||
|
||||
public IReadOnlyList<Bar> Bars { get; } = bars;
|
||||
|
||||
public double Quantity { get; set; }
|
||||
|
||||
public double EntryPrice { get; set; }
|
||||
|
||||
public double LastPrice { get; set; }
|
||||
|
||||
public double EntryFee { get; set; }
|
||||
|
||||
public double Stop { get; set; } = double.NaN;
|
||||
|
||||
public double Target { get; set; } = double.NaN;
|
||||
|
||||
public DateTime EntryUtc { get; set; }
|
||||
|
||||
public int BarsHeld { get; set; }
|
||||
|
||||
public Signal PendingEntry { get; set; } = Signal.Flat;
|
||||
}
|
||||
|
||||
public BacktestSettings Settings { get; } = settings;
|
||||
|
||||
/// <summary>Progress callback: fraction complete in [0, 1]. Optional.</summary>
|
||||
public Action<double>? OnProgress { get; set; }
|
||||
|
||||
public BacktestReport Run(
|
||||
IReadOnlyList<BacktestSymbol> symbols,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<Bar>> history,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
ArgumentNullException.ThrowIfNull(history);
|
||||
|
||||
List<SimState> states = [];
|
||||
foreach (BacktestSymbol s in symbols)
|
||||
{
|
||||
if (history.TryGetValue(s.Symbol, out IReadOnlyList<Bar>? bars) && bars.Count > 0)
|
||||
{
|
||||
s.Strategy.Reset();
|
||||
states.Add(new SimState(s.Symbol, s.Strategy, bars));
|
||||
}
|
||||
}
|
||||
|
||||
if (states.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No historical data was available for any requested symbol.");
|
||||
}
|
||||
|
||||
return Simulate(states, ct);
|
||||
}
|
||||
|
||||
private BacktestReport Simulate(List<SimState> states, CancellationToken ct)
|
||||
{
|
||||
RiskEngine risk = new(Settings.Risk);
|
||||
|
||||
DateTime from = states.Min(s => s.Bars[0].TimeUtc);
|
||||
DateTime to = states.Max(s => s.Bars[^1].TimeUtc);
|
||||
|
||||
risk.StartSession(Settings.StartingEquity, DateOnly.FromDateTime(from));
|
||||
|
||||
List<ClosedTrade> trades = [];
|
||||
double equity = Settings.StartingEquity;
|
||||
double peak = equity;
|
||||
double maxDrawdown = 0;
|
||||
double totalFees = 0;
|
||||
int processed = 0;
|
||||
DateOnly session = DateOnly.FromDateTime(from);
|
||||
|
||||
(DateTime Time, int State, int Index)[] timeline = BuildTimeline(states);
|
||||
int reportEvery = Math.Max(1, timeline.Length / 100);
|
||||
|
||||
for (int step = 0; step < timeline.Length; step++)
|
||||
{
|
||||
if ((step & 0x3FFF) == 0)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
(DateTime time, int stateIndex, int barIndex) = timeline[step];
|
||||
SimState state = states[stateIndex];
|
||||
Bar bar = state.Bars[barIndex];
|
||||
state.LastPrice = bar.Close;
|
||||
processed++;
|
||||
|
||||
DateOnly today = DateOnly.FromDateTime(time);
|
||||
if (today != session)
|
||||
{
|
||||
session = today;
|
||||
risk.StartSession(equity, today);
|
||||
}
|
||||
|
||||
// 1. A pending entry from the previous bar fills at this bar's open.
|
||||
if (state.PendingEntry.IsEntry && state.Quantity == 0)
|
||||
{
|
||||
totalFees += FillPendingEntry(state, bar, risk, equity, states, ref equity);
|
||||
}
|
||||
|
||||
state.PendingEntry = Signal.Flat;
|
||||
|
||||
// 2. Protective exits, stop before target.
|
||||
if (state.Quantity != 0)
|
||||
{
|
||||
state.BarsHeld++;
|
||||
if (TryProtectiveExit(state, bar, out double exitPrice, out string exitReason))
|
||||
{
|
||||
equity += Close(state, exitPrice, bar.TimeUtc, exitReason, trades, out double fee);
|
||||
totalFees += fee;
|
||||
risk.RecordRealizedPnl(trades[^1].Pnl);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Feed the strategy.
|
||||
PositionView view = new(
|
||||
state.Symbol, state.Quantity, state.EntryPrice, bar.Close,
|
||||
state.Stop, state.Target, state.EntryUtc, state.BarsHeld, 0);
|
||||
|
||||
Signal signal = state.Strategy.OnBar(bar, view);
|
||||
|
||||
if (signal.Kind == SignalKind.Exit && state.Quantity != 0)
|
||||
{
|
||||
double price = Slip(bar.Close, state.Quantity > 0 ? Side.Sell : Side.Buy);
|
||||
equity += Close(state, price, bar.TimeUtc, signal.Reason, trades, out double fee);
|
||||
totalFees += fee;
|
||||
risk.RecordRealizedPnl(trades[^1].Pnl);
|
||||
}
|
||||
else if (signal.IsEntry && state.Quantity == 0 && state.Strategy.IsReady)
|
||||
{
|
||||
state.PendingEntry = signal;
|
||||
}
|
||||
|
||||
// 4. Mark to market and track the drawdown.
|
||||
double marked = equity + OpenPnl(states);
|
||||
peak = Math.Max(peak, marked);
|
||||
if (peak > 0)
|
||||
{
|
||||
maxDrawdown = Math.Max(maxDrawdown, (peak - marked) / peak);
|
||||
}
|
||||
|
||||
risk.UpdateEquity(marked);
|
||||
|
||||
if (step % reportEvery == 0)
|
||||
{
|
||||
OnProgress?.Invoke((double)step / timeline.Length);
|
||||
}
|
||||
}
|
||||
|
||||
// Liquidate whatever is still open at the last observed price.
|
||||
foreach (SimState state in states)
|
||||
{
|
||||
if (state.Quantity != 0)
|
||||
{
|
||||
Bar last = state.Bars[^1];
|
||||
equity += Close(state, last.Close, last.TimeUtc, "end of backtest", trades, out double fee);
|
||||
totalFees += fee;
|
||||
}
|
||||
}
|
||||
|
||||
OnProgress?.Invoke(1.0);
|
||||
|
||||
return new BacktestReport(
|
||||
Settings.StartingEquity, equity, maxDrawdown, trades, processed, from, to, totalFees);
|
||||
}
|
||||
|
||||
private static (DateTime, int, int)[] BuildTimeline(List<SimState> states)
|
||||
{
|
||||
int total = 0;
|
||||
foreach (SimState s in states)
|
||||
{
|
||||
total += s.Bars.Count;
|
||||
}
|
||||
|
||||
(DateTime, int, int)[] timeline = new (DateTime, int, int)[total];
|
||||
int i = 0;
|
||||
for (int s = 0; s < states.Count; s++)
|
||||
{
|
||||
IReadOnlyList<Bar> bars = states[s].Bars;
|
||||
for (int b = 0; b < bars.Count; b++)
|
||||
{
|
||||
timeline[i++] = (bars[b].TimeUtc, s, b);
|
||||
}
|
||||
}
|
||||
|
||||
// A single symbol is already ordered; sorting 300k tuples for nothing is waste.
|
||||
if (states.Count > 1)
|
||||
{
|
||||
Array.Sort(timeline, static (a, b) => a.Item1.CompareTo(b.Item1));
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private double FillPendingEntry(
|
||||
SimState state, in Bar bar, RiskEngine risk, double equity, List<SimState> all, ref double cash)
|
||||
{
|
||||
Signal signal = state.PendingEntry;
|
||||
Side side = signal.EntrySide;
|
||||
double fillPrice = Slip(bar.Open, side);
|
||||
if (fillPrice <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double grossExposure = 0;
|
||||
int openPositions = 0;
|
||||
foreach (SimState s in all)
|
||||
{
|
||||
if (s.Quantity != 0)
|
||||
{
|
||||
openPositions++;
|
||||
grossExposure += Math.Abs(s.Quantity) * s.LastPrice;
|
||||
}
|
||||
}
|
||||
|
||||
EntryRequest request = new(
|
||||
state.Symbol, side, fillPrice, signal.StopPrice, signal.Strength,
|
||||
equity, equity, grossExposure, openPositions, 0, 0,
|
||||
Settings.AllowFractional, bar.TimeUtc);
|
||||
|
||||
RiskVerdict verdict = risk.ApproveEntry(request);
|
||||
if (!verdict.Approved)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double fee = Fee(verdict.Quantity * fillPrice);
|
||||
|
||||
state.Quantity = side == Side.Buy ? verdict.Quantity : -verdict.Quantity;
|
||||
state.EntryPrice = fillPrice;
|
||||
state.EntryFee = fee;
|
||||
state.Stop = verdict.StopPrice;
|
||||
state.Target = signal.TargetPrice;
|
||||
state.EntryUtc = bar.TimeUtc;
|
||||
state.BarsHeld = 0;
|
||||
|
||||
risk.RecordEntry(state.Symbol, bar.TimeUtc);
|
||||
|
||||
// The entry fee leaves the account immediately.
|
||||
cash -= fee;
|
||||
return fee;
|
||||
}
|
||||
|
||||
private static bool TryProtectiveExit(SimState state, in Bar bar, out double price, out string reason)
|
||||
{
|
||||
bool isLong = state.Quantity > 0;
|
||||
|
||||
if (!double.IsNaN(state.Stop) && state.Stop > 0)
|
||||
{
|
||||
if (isLong && bar.Low <= state.Stop)
|
||||
{
|
||||
// A gap through the stop fills at the open, not at the stop price.
|
||||
price = Math.Min(state.Stop, bar.Open);
|
||||
reason = "stop loss";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isLong && bar.High >= state.Stop)
|
||||
{
|
||||
price = Math.Max(state.Stop, bar.Open);
|
||||
reason = "stop loss";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!double.IsNaN(state.Target) && state.Target > 0)
|
||||
{
|
||||
if (isLong && bar.High >= state.Target)
|
||||
{
|
||||
price = state.Target;
|
||||
reason = "take profit";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isLong && bar.Low <= state.Target)
|
||||
{
|
||||
price = state.Target;
|
||||
reason = "take profit";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
price = 0;
|
||||
reason = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private double Close(
|
||||
SimState state, double exitPrice, DateTime exitUtc, string reason,
|
||||
List<ClosedTrade> trades, out double exitFee)
|
||||
{
|
||||
double gross = state.Quantity * (exitPrice - state.EntryPrice);
|
||||
exitFee = Fee(Math.Abs(state.Quantity) * exitPrice);
|
||||
double fees = state.EntryFee + exitFee;
|
||||
|
||||
trades.Add(new ClosedTrade(
|
||||
state.Symbol,
|
||||
state.Quantity > 0 ? Side.Buy : Side.Sell,
|
||||
state.EntryUtc,
|
||||
exitUtc,
|
||||
Math.Abs(state.Quantity),
|
||||
state.EntryPrice,
|
||||
exitPrice,
|
||||
gross,
|
||||
fees,
|
||||
reason));
|
||||
|
||||
state.Quantity = 0;
|
||||
state.EntryPrice = 0;
|
||||
state.EntryFee = 0;
|
||||
state.Stop = double.NaN;
|
||||
state.Target = double.NaN;
|
||||
state.BarsHeld = 0;
|
||||
|
||||
// The entry fee was already deducted when the position opened.
|
||||
return gross - exitFee;
|
||||
}
|
||||
|
||||
private static double OpenPnl(List<SimState> states)
|
||||
{
|
||||
double total = 0;
|
||||
foreach (SimState s in states)
|
||||
{
|
||||
if (s.Quantity != 0 && s.LastPrice > 0)
|
||||
{
|
||||
total += s.Quantity * (s.LastPrice - s.EntryPrice);
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private double Slip(double price, Side side)
|
||||
{
|
||||
double offset = Settings.SlippageBps / 10_000.0;
|
||||
return side == Side.Buy ? price * (1 + offset) : price * (1 - offset);
|
||||
}
|
||||
|
||||
private double Fee(double notional) => Math.Abs(notional) * (Settings.FeeBps / 10_000.0);
|
||||
}
|
||||
@@ -9,7 +9,9 @@ public enum TimeFrame
|
||||
OneMinute = 60,
|
||||
FiveMinutes = 300,
|
||||
FifteenMinutes = 900,
|
||||
ThirtyMinutes = 1800,
|
||||
OneHour = 3600,
|
||||
FourHours = 14_400,
|
||||
OneDay = 86_400,
|
||||
}
|
||||
|
||||
@@ -17,14 +19,16 @@ public static class TimeFrameExtensions
|
||||
{
|
||||
public static int Seconds(this TimeFrame tf) => (int)tf;
|
||||
|
||||
/// <summary>Alpaca wire representation of the timeframe (e.g. <c>5Min</c>).</summary>
|
||||
public static string ToAlpaca(this TimeFrame tf) => tf switch
|
||||
/// <summary>Binance wire representation of the timeframe (e.g. <c>5m</c>).</summary>
|
||||
public static string ToBinance(this TimeFrame tf) => tf switch
|
||||
{
|
||||
TimeFrame.OneMinute => "1Min",
|
||||
TimeFrame.FiveMinutes => "5Min",
|
||||
TimeFrame.FifteenMinutes => "15Min",
|
||||
TimeFrame.OneHour => "1Hour",
|
||||
TimeFrame.OneDay => "1Day",
|
||||
TimeFrame.OneMinute => "1m",
|
||||
TimeFrame.FiveMinutes => "5m",
|
||||
TimeFrame.FifteenMinutes => "15m",
|
||||
TimeFrame.ThirtyMinutes => "30m",
|
||||
TimeFrame.OneHour => "1h",
|
||||
TimeFrame.FourHours => "4h",
|
||||
TimeFrame.OneDay => "1d",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tf), tf, "Unsupported timeframe."),
|
||||
};
|
||||
|
||||
@@ -35,20 +39,15 @@ public static class TimeFrameExtensions
|
||||
case "1MIN" or "1M" or "MINUTE": tf = TimeFrame.OneMinute; return true;
|
||||
case "5MIN" or "5M": tf = TimeFrame.FiveMinutes; return true;
|
||||
case "15MIN" or "15M": tf = TimeFrame.FifteenMinutes; return true;
|
||||
case "30MIN" or "30M": tf = TimeFrame.ThirtyMinutes; return true;
|
||||
case "1HOUR" or "1H" or "HOUR": tf = TimeFrame.OneHour; return true;
|
||||
case "4HOUR" or "4H": tf = TimeFrame.FourHours; return true;
|
||||
case "1DAY" or "1D" or "DAY": tf = TimeFrame.OneDay; return true;
|
||||
default: tf = TimeFrame.OneMinute; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Which asset universe a symbol belongs to. Drives endpoint + session rules.</summary>
|
||||
public enum AssetClass : byte
|
||||
{
|
||||
UsEquity = 0,
|
||||
Crypto = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OHLCV bar. Prices are <see cref="double"/> on purpose: the whole indicator
|
||||
/// path is floating point and double carries 15+ significant digits, far beyond
|
||||
@@ -141,42 +140,45 @@ public enum OrderType : byte
|
||||
|
||||
public enum TimeInForce : byte
|
||||
{
|
||||
/// <summary>Day order — the default for equities.</summary>
|
||||
/// <summary>Day order. Perpetual futures never close, so this behaves as GTC.</summary>
|
||||
Day = 0,
|
||||
GoodTillCanceled = 1,
|
||||
ImmediateOrCancel = 2,
|
||||
FillOrKill = 3,
|
||||
Opening = 4,
|
||||
Closing = 5,
|
||||
|
||||
/// <summary>Post-only: rejected rather than filled if it would take liquidity.</summary>
|
||||
PostOnly = 4,
|
||||
}
|
||||
|
||||
public static class MarketEnumExtensions
|
||||
{
|
||||
public static string ToAlpaca(this Side side) => side switch
|
||||
public static string ToBinance(this Side side) => side switch
|
||||
{
|
||||
Side.Buy => "buy",
|
||||
Side.Sell => "sell",
|
||||
Side.Buy => "BUY",
|
||||
Side.Sell => "SELL",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(side), side, "Side.None is not orderable."),
|
||||
};
|
||||
|
||||
public static string ToAlpaca(this OrderType type) => type switch
|
||||
public static string ToBinance(this OrderType type) => type switch
|
||||
{
|
||||
OrderType.Market => "market",
|
||||
OrderType.Limit => "limit",
|
||||
OrderType.Stop => "stop",
|
||||
OrderType.StopLimit => "stop_limit",
|
||||
OrderType.TrailingStop => "trailing_stop",
|
||||
OrderType.Market => "MARKET",
|
||||
OrderType.Limit => "LIMIT",
|
||||
OrderType.Stop => "STOP_MARKET",
|
||||
OrderType.StopLimit => "STOP",
|
||||
OrderType.TrailingStop => "TRAILING_STOP_MARKET",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
|
||||
};
|
||||
|
||||
public static string ToAlpaca(this TimeInForce tif) => tif switch
|
||||
public static string ToBinance(this TimeInForce tif) => tif switch
|
||||
{
|
||||
TimeInForce.Day => "day",
|
||||
TimeInForce.GoodTillCanceled => "gtc",
|
||||
TimeInForce.ImmediateOrCancel => "ioc",
|
||||
TimeInForce.FillOrKill => "fok",
|
||||
TimeInForce.Opening => "opg",
|
||||
TimeInForce.Closing => "cls",
|
||||
TimeInForce.GoodTillCanceled or TimeInForce.Day => "GTC",
|
||||
TimeInForce.ImmediateOrCancel => "IOC",
|
||||
TimeInForce.FillOrKill => "FOK",
|
||||
|
||||
// Post-only: cancelled rather than filled if it would cross the book. The only
|
||||
// way to be certain of the maker fee, which is the difference between a spread
|
||||
// this strategy can pay and one it cannot.
|
||||
TimeInForce.PostOnly => "GTX",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tif), tif, null),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Encelado.Core.Market;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Core.Risk;
|
||||
|
||||
@@ -8,64 +8,82 @@ public enum RiskReject : byte
|
||||
TradingHalted,
|
||||
DailyLossLimit,
|
||||
DailyProfitTarget,
|
||||
MaxOpenPositions,
|
||||
MaxOpenPairs,
|
||||
MaxDailyTrades,
|
||||
MaxSymbolTrades,
|
||||
MaxPairTrades,
|
||||
Cooldown,
|
||||
ShortingDisabled,
|
||||
AlreadyInPosition,
|
||||
PriceOutOfBand,
|
||||
SpreadTooWide,
|
||||
InvalidStop,
|
||||
HedgeRatioUnusable,
|
||||
MarginRatioTooHigh,
|
||||
SizeTooSmall,
|
||||
InsufficientBuyingPower,
|
||||
InsufficientMargin,
|
||||
ExposureLimit,
|
||||
InvalidRequest,
|
||||
}
|
||||
|
||||
/// <summary>Everything the risk engine needs to size and vet one entry.</summary>
|
||||
public readonly record struct EntryRequest(
|
||||
string Symbol,
|
||||
Side Side,
|
||||
double Price,
|
||||
double StopPrice,
|
||||
double Strength,
|
||||
/// <summary>Everything the risk engine needs to size and vet one pair entry.</summary>
|
||||
public readonly record struct PairEntryRequest(
|
||||
string PairName,
|
||||
double PriceA,
|
||||
double PriceB,
|
||||
double Beta,
|
||||
double Equity,
|
||||
double BuyingPower,
|
||||
double AvailableBalance,
|
||||
double GrossExposure,
|
||||
int OpenPositions,
|
||||
double ExistingQuantity,
|
||||
double RelativeSpread,
|
||||
bool AllowFractional,
|
||||
int OpenPairs,
|
||||
double RelativeSpreadA,
|
||||
double RelativeSpreadB,
|
||||
|
||||
/// <summary>
|
||||
/// Net funding the position would earn per settlement, as a fraction of its own
|
||||
/// notional. Positive means the pair collects; negative means it pays.
|
||||
/// </summary>
|
||||
double NetFundingRate,
|
||||
|
||||
/// <summary>Maintenance margin over margin balance, as Binance currently reports it.</summary>
|
||||
double MarginRatio,
|
||||
|
||||
int Leverage,
|
||||
bool AlreadyOpen,
|
||||
DateTime NowUtc);
|
||||
|
||||
/// <summary>The engine's answer: an approved size, or a machine-readable reason why not.</summary>
|
||||
public readonly record struct RiskVerdict(
|
||||
/// <summary>
|
||||
/// The engine's answer: a notional for each leg, or a machine-readable reason why not.
|
||||
/// <para>
|
||||
/// Notional rather than quantity on purpose. Only the exchange knows each symbol's lot
|
||||
/// step and minimum, so the last conversion — notional to a sendable size — belongs
|
||||
/// next to those rules, not here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct PairVerdict(
|
||||
bool Approved,
|
||||
double Quantity,
|
||||
double StopPrice,
|
||||
double NotionalA,
|
||||
double NotionalB,
|
||||
RiskReject Reason,
|
||||
string Detail)
|
||||
{
|
||||
public static RiskVerdict Reject(RiskReject reason, string detail) =>
|
||||
new(false, 0, double.NaN, reason, detail);
|
||||
public double TotalNotional => NotionalA + NotionalB;
|
||||
|
||||
public static RiskVerdict Approve(double quantity, double stopPrice, string detail) =>
|
||||
new(true, quantity, stopPrice, RiskReject.None, detail);
|
||||
public static PairVerdict Reject(RiskReject reason, string detail) =>
|
||||
new(false, 0, 0, reason, detail);
|
||||
|
||||
public static PairVerdict Approve(double notionalA, double notionalB, string detail) =>
|
||||
new(true, notionalA, notionalB, RiskReject.None, detail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single gate every order must pass. It owns position sizing (fixed-fractional
|
||||
/// risk against the stop distance), the per-session counters and the kill switch.
|
||||
/// The single gate every order must pass. It owns pair sizing, the session counters and
|
||||
/// the kill switch.
|
||||
/// <para>
|
||||
/// Thread safe: entries are vetted on the market-data thread while fills and equity
|
||||
/// updates arrive on the trade-updates thread.
|
||||
/// updates arrive on the user-data thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RiskEngine(RiskLimits limits)
|
||||
{
|
||||
private readonly RiskLimits _limits = limits.Validate();
|
||||
private readonly Dictionary<string, SymbolCounters> _counters = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, PairCounters> _counters = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
private double _sessionStartEquity;
|
||||
@@ -93,6 +111,11 @@ public sealed class RiskEngine(RiskLimits limits)
|
||||
get { lock (_gate) { return _sessionStartEquity; } }
|
||||
}
|
||||
|
||||
public double PeakEquity
|
||||
{
|
||||
get { lock (_gate) { return _peakEquity; } }
|
||||
}
|
||||
|
||||
public double DailyRealizedPnl
|
||||
{
|
||||
get { lock (_gate) { return _dailyRealizedPnl; } }
|
||||
@@ -103,6 +126,11 @@ public sealed class RiskEngine(RiskLimits limits)
|
||||
get { lock (_gate) { return _tradesToday; } }
|
||||
}
|
||||
|
||||
public DateOnly SessionDate
|
||||
{
|
||||
get { lock (_gate) { return _sessionDate; } }
|
||||
}
|
||||
|
||||
/// <summary>Resets the daily counters and rebases the drawdown reference to <paramref name="equity"/>.</summary>
|
||||
public void StartSession(double equity, DateOnly sessionDate)
|
||||
{
|
||||
@@ -119,11 +147,6 @@ public sealed class RiskEngine(RiskLimits limits)
|
||||
}
|
||||
}
|
||||
|
||||
public DateOnly SessionDate
|
||||
{
|
||||
get { lock (_gate) { return _sessionDate; } }
|
||||
}
|
||||
|
||||
public void Halt(string reason)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -188,14 +211,16 @@ public sealed class RiskEngine(RiskLimits limits)
|
||||
if (change <= -_limits.MaxDailyLossPct)
|
||||
{
|
||||
_halted = true;
|
||||
_haltReason = $"daily loss limit hit ({change:P2} <= {-_limits.MaxDailyLossPct:P2})";
|
||||
_haltReason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"perdita giornaliera oltre il limite ({change:P2} contro {-_limits.MaxDailyLossPct:P2})");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_limits.MaxDailyProfitPct > 0 && change >= _limits.MaxDailyProfitPct)
|
||||
{
|
||||
_halted = true;
|
||||
_haltReason = $"daily profit target reached ({change:P2} >= {_limits.MaxDailyProfitPct:P2})";
|
||||
_haltReason = string.Create(CultureInfo.InvariantCulture,
|
||||
$"obiettivo di profitto giornaliero raggiunto ({change:P2})");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -203,203 +228,244 @@ public sealed class RiskEngine(RiskLimits limits)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called once an entry order has actually been submitted.</summary>
|
||||
public void RecordEntry(string symbol, DateTime nowUtc)
|
||||
/// <summary>Called once a pair's entry orders have actually been submitted.</summary>
|
||||
public void RecordEntry(string pairName, DateTime nowUtc)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_tradesToday++;
|
||||
ref SymbolCounters c = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(
|
||||
_counters, symbol, out _);
|
||||
ref PairCounters c = ref System.Runtime.InteropServices.CollectionsMarshal
|
||||
.GetValueRefOrAddDefault(_counters, pairName, out _);
|
||||
c.Trades++;
|
||||
c.LastEntryUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Vets an entry and returns the size to send, or the reason for refusing.</summary>
|
||||
public RiskVerdict ApproveEntry(in EntryRequest r)
|
||||
/// <summary>
|
||||
/// Vets a pair entry and returns the notional for each leg, or the reason for
|
||||
/// refusing.
|
||||
/// <para>
|
||||
/// Every refusal carries a sentence that names the number that caused it and the
|
||||
/// limit it hit. That is not politeness: a bot that declines to trade and cannot say
|
||||
/// why is indistinguishable from a broken one, and this is the last gate before an
|
||||
/// order, so it is where most declines happen.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public PairVerdict ApprovePair(in PairEntryRequest r)
|
||||
{
|
||||
if (r.Side == Side.None || r.Price <= 0 || r.Equity <= 0 || double.IsNaN(r.Price))
|
||||
if (r.PriceA <= 0 || r.PriceB <= 0 || r.Equity <= 0 ||
|
||||
!double.IsFinite(r.PriceA) || !double.IsFinite(r.PriceB))
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.InvalidRequest, "price, side or equity is not usable");
|
||||
return PairVerdict.Reject(RiskReject.InvalidRequest,
|
||||
"prezzi o equity non utilizzabili");
|
||||
}
|
||||
|
||||
// A hedge ratio at or below zero means the fit says the two legs move in
|
||||
// opposite directions, and "hedging" one with the other would double the
|
||||
// exposure rather than cancel it.
|
||||
if (!double.IsFinite(r.Beta) || r.Beta <= 0)
|
||||
{
|
||||
return PairVerdict.Reject(RiskReject.HedgeRatioUnusable,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"β={r.Beta:F4} non è un rapporto di copertura utilizzabile"));
|
||||
}
|
||||
|
||||
if (r.Beta > _limits.MaxHedgeRatio || r.Beta < 1.0 / _limits.MaxHedgeRatio)
|
||||
{
|
||||
return PairVerdict.Reject(RiskReject.HedgeRatioUnusable,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"β={r.Beta:F4} fuori dall'intervallo consentito " +
|
||||
$"[{1.0 / _limits.MaxHedgeRatio:F3}, {_limits.MaxHedgeRatio:F1}]"));
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_halted)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.TradingHalted, _haltReason);
|
||||
return PairVerdict.Reject(RiskReject.TradingHalted, _haltReason);
|
||||
}
|
||||
|
||||
if (r.Side == Side.Sell && !_limits.AllowShorting && r.ExistingQuantity <= 0)
|
||||
if (r.AlreadyOpen)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.ShortingDisabled, "shorting is disabled");
|
||||
return PairVerdict.Reject(RiskReject.AlreadyInPosition, "la coppia è già aperta");
|
||||
}
|
||||
|
||||
if (r.ExistingQuantity != 0)
|
||||
if (_limits.MaxRelativeSpread > 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.AlreadyInPosition,
|
||||
$"already holding {r.ExistingQuantity:0.####}");
|
||||
double widest = Math.Max(r.RelativeSpreadA, r.RelativeSpreadB);
|
||||
if (widest > _limits.MaxRelativeSpread)
|
||||
{
|
||||
return PairVerdict.Reject(RiskReject.SpreadTooWide,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"book largo {widest:P4} contro il massimo {_limits.MaxRelativeSpread:P4}: " +
|
||||
$"un giro completo attraversa lo spread quattro volte"));
|
||||
}
|
||||
}
|
||||
|
||||
if (r.Price < _limits.MinPrice || r.Price > _limits.MaxPrice)
|
||||
if (_limits.MaxMarginRatio > 0 && r.MarginRatio > _limits.MaxMarginRatio)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.PriceOutOfBand,
|
||||
$"price {r.Price:F2} outside [{_limits.MinPrice:F2}, {_limits.MaxPrice:F2}]");
|
||||
return PairVerdict.Reject(RiskReject.MarginRatioTooHigh,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"margine di mantenimento al {r.MarginRatio:P1} dell'equity, " +
|
||||
$"oltre il {_limits.MaxMarginRatio:P0}"));
|
||||
}
|
||||
|
||||
if (_limits.MaxRelativeSpread > 0 && r.RelativeSpread > _limits.MaxRelativeSpread)
|
||||
if (_limits.MaxOpenPairs > 0 && r.OpenPairs >= _limits.MaxOpenPairs)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.SpreadTooWide,
|
||||
$"spread {r.RelativeSpread:P3} > {_limits.MaxRelativeSpread:P3}");
|
||||
}
|
||||
|
||||
// Each of these caps is disabled by setting it to 0.
|
||||
if (_limits.MaxOpenPositions > 0 && r.OpenPositions >= _limits.MaxOpenPositions)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.MaxOpenPositions,
|
||||
$"{r.OpenPositions} positions already open");
|
||||
return PairVerdict.Reject(RiskReject.MaxOpenPairs,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"{r.OpenPairs} coppie già aperte, massimo {_limits.MaxOpenPairs}"));
|
||||
}
|
||||
|
||||
if (_limits.MaxTradesPerDay > 0 && _tradesToday >= _limits.MaxTradesPerDay)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.MaxDailyTrades,
|
||||
$"{_tradesToday} trades today");
|
||||
return PairVerdict.Reject(RiskReject.MaxDailyTrades,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"{_tradesToday} operazioni oggi, massimo {_limits.MaxTradesPerDay}"));
|
||||
}
|
||||
|
||||
if (_counters.TryGetValue(r.Symbol, out SymbolCounters counters))
|
||||
if (_counters.TryGetValue(r.PairName, out PairCounters counters))
|
||||
{
|
||||
if (_limits.MaxTradesPerSymbolPerDay > 0 &&
|
||||
counters.Trades >= _limits.MaxTradesPerSymbolPerDay)
|
||||
if (_limits.MaxTradesPerPairPerDay > 0 &&
|
||||
counters.Trades >= _limits.MaxTradesPerPairPerDay)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.MaxSymbolTrades,
|
||||
$"{counters.Trades} trades today on {r.Symbol}");
|
||||
return PairVerdict.Reject(RiskReject.MaxPairTrades,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"{counters.Trades} operazioni oggi su {r.PairName}, " +
|
||||
$"massimo {_limits.MaxTradesPerPairPerDay}"));
|
||||
}
|
||||
|
||||
double elapsed = (r.NowUtc - counters.LastEntryUtc).TotalSeconds;
|
||||
if (counters.LastEntryUtc != default && elapsed < _limits.MinSecondsBetweenEntries)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.Cooldown,
|
||||
$"{elapsed:F0}s since last entry, need {_limits.MinSecondsBetweenEntries}s");
|
||||
return PairVerdict.Reject(RiskReject.Cooldown,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"{elapsed:F0}s dall'ultimo ingresso, ne servono {_limits.MinSecondsBetweenEntries}"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sizing -------------------------------------------------------
|
||||
double stop = ResolveStop(r);
|
||||
double riskPerShare = Math.Abs(r.Price - stop);
|
||||
if (riskPerShare <= 0)
|
||||
int leverage = Math.Max(1, r.Leverage);
|
||||
double margin = _limits.ResolveStake(r.Equity);
|
||||
double total = margin * leverage;
|
||||
|
||||
double tilt = FundingTilt(r.NetFundingRate);
|
||||
total *= tilt;
|
||||
|
||||
double capped = ApplyCaps(total, r, out RiskReject cap, out string capDetail);
|
||||
if (capped <= 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.InvalidStop, "stop equals entry price");
|
||||
return PairVerdict.Reject(cap, capDetail);
|
||||
}
|
||||
|
||||
if (riskPerShare / r.Price > _limits.MaxStopDistancePct)
|
||||
// Delta-neutral split. The spread is ln(A) − β·ln(B), so a 1% move in B moves
|
||||
// the spread by β%: the second leg must carry β times the notional of the
|
||||
// first for the two to cancel. Anything else leaves a directional residue.
|
||||
double notionalA = capped / (1 + r.Beta);
|
||||
double notionalB = capped - notionalA;
|
||||
|
||||
if (notionalA < _limits.MinOrderNotional || notionalB < _limits.MinOrderNotional)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.InvalidStop,
|
||||
$"stop is {riskPerShare / r.Price:P2} away, max {_limits.MaxStopDistancePct:P2}");
|
||||
return PairVerdict.Reject(RiskReject.SizeTooSmall,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"gambe da {notionalA:F2} e {notionalB:F2} USDT, sotto il minimo " +
|
||||
$"{_limits.MinOrderNotional:F2}: alza lo stake o togli una coppia"));
|
||||
}
|
||||
|
||||
double strength = r.Strength is > 0 and <= 1 ? r.Strength : 1.0;
|
||||
double quantity;
|
||||
string sizing;
|
||||
string tiltNote = Math.Abs(tilt - 1) < 1e-9
|
||||
? string.Empty
|
||||
: string.Create(CultureInfo.InvariantCulture,
|
||||
$", funding {r.NetFundingRate:P4} → size ×{tilt:F2}");
|
||||
|
||||
if (_limits.HasExplicitStake)
|
||||
{
|
||||
// The operator has fixed the stake, so conviction does not scale it:
|
||||
// "20% of the account" that silently becomes 12% on a weaker signal is
|
||||
// not the instruction that was given.
|
||||
double stake = _limits.ResolveStake(r.Equity);
|
||||
quantity = stake / r.Price;
|
||||
sizing = $"stake {stake:F2} ({_limits.DescribeSizing()})";
|
||||
}
|
||||
else
|
||||
{
|
||||
double riskBudget = r.Equity * _limits.MaxRiskPerTradePct * strength;
|
||||
quantity = riskBudget / riskPerShare;
|
||||
sizing = $"risk {riskBudget:F2} @ {riskPerShare:F4}/share";
|
||||
}
|
||||
|
||||
quantity = CapByNotional(quantity, r.Price, r.Equity * _limits.MaxPositionNotionalPct);
|
||||
|
||||
double exposureHeadroom = (r.Equity * _limits.MaxGrossExposurePct) - r.GrossExposure;
|
||||
if (exposureHeadroom <= 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.ExposureLimit,
|
||||
$"gross exposure {r.GrossExposure:F0} already at the {_limits.MaxGrossExposurePct:P0} cap");
|
||||
}
|
||||
|
||||
quantity = CapByNotional(quantity, r.Price, exposureHeadroom);
|
||||
|
||||
if (_limits.MaxOrderNotional > 0)
|
||||
{
|
||||
quantity = CapByNotional(quantity, r.Price, _limits.MaxOrderNotional);
|
||||
}
|
||||
|
||||
if (r.BuyingPower > 0)
|
||||
{
|
||||
quantity = CapByNotional(quantity, r.Price, r.BuyingPower * 0.98);
|
||||
}
|
||||
|
||||
if (!r.AllowFractional)
|
||||
{
|
||||
quantity = Math.Floor(quantity);
|
||||
}
|
||||
else
|
||||
{
|
||||
quantity = Math.Round(quantity, 6, MidpointRounding.ToZero);
|
||||
}
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.SizeTooSmall,
|
||||
$"computed size rounds to zero ({sizing})");
|
||||
}
|
||||
|
||||
double notional = quantity * r.Price;
|
||||
if (notional < _limits.MinOrderNotional)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.SizeTooSmall,
|
||||
$"notional {notional:F2} < minimum {_limits.MinOrderNotional:F2}");
|
||||
}
|
||||
|
||||
if (r.BuyingPower > 0 && notional > r.BuyingPower)
|
||||
{
|
||||
return RiskVerdict.Reject(RiskReject.InsufficientBuyingPower,
|
||||
$"notional {notional:F2} > buying power {r.BuyingPower:F2}");
|
||||
}
|
||||
|
||||
return RiskVerdict.Approve(quantity, stop,
|
||||
$"{sizing} -> {quantity:0.######} ({notional:F2} notional)");
|
||||
return PairVerdict.Approve(notionalA, notionalB,
|
||||
string.Create(CultureInfo.InvariantCulture,
|
||||
$"{_limits.DescribeSizing()} × leva {leverage} = {capped:F2} USDT " +
|
||||
$"(A {notionalA:F2} / B {notionalB:F2} a β={r.Beta:F4}){tiltNote}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Uses the strategy's stop when sane, otherwise falls back to a percentage stop.</summary>
|
||||
private double ResolveStop(in EntryRequest r)
|
||||
/// <summary>
|
||||
/// How much the funding rate moves the size.
|
||||
/// <para>
|
||||
/// One step up or one step down, never a proportional scaling. A rate that is twice
|
||||
/// as favourable does not make the spread twice as likely to revert, and sizing off
|
||||
/// a number that swings every eight hours is how a position ends up larger for a
|
||||
/// reason that has nothing to do with the trade.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private double FundingTilt(double netRate)
|
||||
{
|
||||
bool longSide = r.Side == Side.Buy;
|
||||
|
||||
if (!double.IsNaN(r.StopPrice) && r.StopPrice > 0 &&
|
||||
((longSide && r.StopPrice < r.Price) || (!longSide && r.StopPrice > r.Price)))
|
||||
if (_limits.FundingTiltPct <= 0 || !double.IsFinite(netRate))
|
||||
{
|
||||
return r.StopPrice;
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return longSide
|
||||
? r.Price * (1 - _limits.DefaultStopPct)
|
||||
: r.Price * (1 + _limits.DefaultStopPct);
|
||||
if (netRate > _limits.FundingTiltThreshold)
|
||||
{
|
||||
return 1 + _limits.FundingTiltPct;
|
||||
}
|
||||
|
||||
private static double CapByNotional(double quantity, double price, double maxNotional)
|
||||
if (netRate < -_limits.FundingTiltThreshold)
|
||||
{
|
||||
if (maxNotional <= 0)
|
||||
return 1 - _limits.FundingTiltPct;
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/// <summary>Applies the exposure, order-size and margin caps, in that order.</summary>
|
||||
private double ApplyCaps(double total, in PairEntryRequest r, out RiskReject reason, out string detail)
|
||||
{
|
||||
reason = RiskReject.None;
|
||||
detail = string.Empty;
|
||||
|
||||
if (_limits.MaxGrossExposurePct > 0)
|
||||
{
|
||||
double headroom = (r.Equity * _limits.MaxGrossExposurePct) - r.GrossExposure;
|
||||
if (headroom <= 0)
|
||||
{
|
||||
reason = RiskReject.ExposureLimit;
|
||||
detail = string.Create(CultureInfo.InvariantCulture,
|
||||
$"esposizione lorda {r.GrossExposure:F0} già al tetto di " +
|
||||
$"{_limits.MaxGrossExposurePct:F1}× l'equity");
|
||||
return 0;
|
||||
}
|
||||
|
||||
double max = maxNotional / price;
|
||||
return quantity > max ? max : quantity;
|
||||
total = Math.Min(total, headroom);
|
||||
}
|
||||
|
||||
private struct SymbolCounters
|
||||
if (_limits.MaxOrderNotional > 0)
|
||||
{
|
||||
// The cap is per leg, so the pair as a whole may reach it on both.
|
||||
total = Math.Min(total, _limits.MaxOrderNotional * 2);
|
||||
}
|
||||
|
||||
if (r.AvailableBalance > 0)
|
||||
{
|
||||
// Margin needed is notional / leverage. Two per cent is left behind for the
|
||||
// taker fee and for the mark price moving between sizing and submission —
|
||||
// a leg that is refused for a few cents leaves the book half hedged.
|
||||
double affordable = r.AvailableBalance * 0.98 * Math.Max(1, r.Leverage);
|
||||
if (affordable <= 0)
|
||||
{
|
||||
reason = RiskReject.InsufficientMargin;
|
||||
detail = "nessun margine disponibile sul conto";
|
||||
return 0;
|
||||
}
|
||||
|
||||
total = Math.Min(total, affordable);
|
||||
}
|
||||
|
||||
if (total <= 0)
|
||||
{
|
||||
reason = RiskReject.SizeTooSmall;
|
||||
detail = "la dimensione calcolata è nulla";
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private struct PairCounters
|
||||
{
|
||||
public int Trades;
|
||||
public DateTime LastEntryUtc;
|
||||
|
||||
@@ -1,139 +1,170 @@
|
||||
namespace Encelado.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Every hard boundary the engine is allowed to operate inside. Defaults are
|
||||
/// deliberately conservative: a misconfigured bot should be boring, not broke.
|
||||
/// Every hard boundary the bot operates inside. Each cap is disabled by setting it to
|
||||
/// zero, so a configuration can be as loose or as tight as the operator wants without
|
||||
/// any of them needing a separate on/off switch.
|
||||
/// </summary>
|
||||
public sealed class RiskLimits
|
||||
{
|
||||
/// <summary>Fraction of equity risked between entry and stop on a single trade.</summary>
|
||||
public double MaxRiskPerTradePct { get; set; } = 0.005;
|
||||
|
||||
/// <summary>
|
||||
/// Notional committed per entry, as a fraction of account equity. 0 means "not set":
|
||||
/// the engine then sizes by risk-to-stop, which is the original behaviour.
|
||||
/// <para>
|
||||
/// This is the *stake*, not the risk. At 0.20 the bot buys 20% of the account and
|
||||
/// the amount actually at risk is that times the stop distance — with a 30% stop,
|
||||
/// 6% of equity. Sizing by risk instead makes every trade lose the same amount when
|
||||
/// it is wrong, regardless of how far away its stop happens to be; sizing by stake
|
||||
/// makes every trade the same size, which is easier to reason about but means a
|
||||
/// wide-stop trade can hurt several times more than a narrow-stop one.
|
||||
/// </para>
|
||||
/// Fraction of account equity committed as margin to one pair. With leverage L the
|
||||
/// notional actually put to work is <c>equity × stakePct × L</c>, split between the
|
||||
/// two legs by the hedge ratio.
|
||||
/// </summary>
|
||||
public double StakePct { get; set; }
|
||||
public double StakePct { get; set; } = 0.20;
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on the committed notional, in account currency. Combined with
|
||||
/// <see cref="StakePct"/> it is an upper bound (the percentage is used, but never
|
||||
/// more than this); on its own it is a fixed order size. 0 means "not set".
|
||||
/// </summary>
|
||||
/// <summary>A fixed margin amount per pair, in USDT. Overrides <see cref="StakePct"/> when set.</summary>
|
||||
public double StakeAmount { get; set; }
|
||||
|
||||
/// <summary>True when the operator has taken sizing into their own hands.</summary>
|
||||
public bool HasExplicitStake => StakePct > 0 || StakeAmount > 0;
|
||||
/// <summary>
|
||||
/// How much the funding rate is allowed to move the size, as a fraction.
|
||||
/// <para>
|
||||
/// A delta-neutral pair collects funding on one leg and pays it on the other. When
|
||||
/// the net is in our favour the position earns a yield simply for existing, and it is
|
||||
/// worth being slightly larger; when it is against us the spread has to travel
|
||||
/// further just to break even. 12% is the middle of the range the strategy note
|
||||
/// gives. Zero switches the tilt off entirely.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public double FundingTiltPct { get; set; } = 0.12;
|
||||
|
||||
/// <summary>
|
||||
/// The notional to commit for one entry at the given equity. Only meaningful when
|
||||
/// <see cref="HasExplicitStake"/> is true.
|
||||
/// Net funding rate per settlement below which the tilt does nothing. Rates hover
|
||||
/// around ±0.01% most of the time, and tilting on noise is just churn.
|
||||
/// </summary>
|
||||
public double ResolveStake(double equity)
|
||||
{
|
||||
if (StakePct <= 0)
|
||||
{
|
||||
return StakeAmount;
|
||||
}
|
||||
public double FundingTiltThreshold { get; set; } = 0.0001;
|
||||
|
||||
double byPercent = equity * StakePct;
|
||||
return StakeAmount > 0 ? Math.Min(byPercent, StakeAmount) : byPercent;
|
||||
}
|
||||
/// <summary>Total notional across every leg, as a multiple of equity. 0 disables the cap.</summary>
|
||||
public double MaxGrossExposurePct { get; set; } = 2.0;
|
||||
|
||||
/// <summary>One line describing the sizing rule in force, for the settings screen.</summary>
|
||||
public string DescribeSizing() =>
|
||||
StakePct > 0 && StakeAmount > 0
|
||||
? $"{StakePct:P2} dell'equity per operazione, mai oltre {StakeAmount:N0}"
|
||||
: StakePct > 0 ? $"{StakePct:P2} dell'equity per operazione"
|
||||
: StakeAmount > 0 ? $"importo fisso di {StakeAmount:N0} per operazione"
|
||||
: $"deciso dal bot: {MaxRiskPerTradePct:P2} di equity a rischio fino allo stop";
|
||||
/// <summary>How many pairs may be open at once. 0 = no limit.</summary>
|
||||
public int MaxOpenPairs { get; set; } = 4;
|
||||
|
||||
/// <summary>Hard cap on one symbol's notional, as a fraction of equity.</summary>
|
||||
public double MaxPositionNotionalPct { get; set; } = 0.10;
|
||||
|
||||
/// <summary>Cap on the sum of all position notionals, as a fraction of equity.</summary>
|
||||
public double MaxGrossExposurePct { get; set; } = 1.00;
|
||||
|
||||
/// <summary>Simultaneous open positions. 0 means no limit.</summary>
|
||||
public int MaxOpenPositions { get; set; } = 5;
|
||||
|
||||
/// <summary>Entries per session across all symbols. 0 means no limit.</summary>
|
||||
/// <summary>0 = no limit.</summary>
|
||||
public int MaxTradesPerDay { get; set; } = 40;
|
||||
|
||||
/// <summary>Entries per session on one symbol. 0 means no limit.</summary>
|
||||
public int MaxTradesPerSymbolPerDay { get; set; } = 6;
|
||||
/// <summary>0 = no limit.</summary>
|
||||
public int MaxTradesPerPairPerDay { get; set; } = 8;
|
||||
|
||||
/// <summary>Session is halted once equity drops this fraction below the session open.</summary>
|
||||
public double MaxDailyLossPct { get; set; } = 0.03;
|
||||
|
||||
/// <summary>Optional profit lock-in: halt for the day above this gain. 0 disables.</summary>
|
||||
public double MaxDailyProfitPct { get; set; }
|
||||
|
||||
/// <summary>Debounce between two entries on the same symbol.</summary>
|
||||
/// <summary>Cooling-off period between two entries on the same pair. 0 disables it.</summary>
|
||||
public int MinSecondsBetweenEntries { get; set; } = 60;
|
||||
|
||||
/// <summary>Widest acceptable bid/ask spread as a fraction of mid. 0 disables the check.</summary>
|
||||
public double MaxRelativeSpread { get; set; } = 0.004;
|
||||
/// <summary>Session loss that trips the kill switch, as a fraction of starting equity.</summary>
|
||||
public double MaxDailyLossPct { get; set; } = 0.06;
|
||||
|
||||
public double MinPrice { get; set; } = 1.0;
|
||||
/// <summary>Session profit at which the bot stands down. 0 disables it.</summary>
|
||||
public double MaxDailyProfitPct { get; set; }
|
||||
|
||||
public double MaxPrice { get; set; } = 100_000;
|
||||
/// <summary>
|
||||
/// Widest book either leg may show and still be entered.
|
||||
/// <para>
|
||||
/// This matters far more here than on a directional model. A pair trade crosses the
|
||||
/// spread four times — in on both legs, out on both legs — so a book that is 10 bps
|
||||
/// wide costs 40 bps a round trip, against a mean reversion that is often worth less
|
||||
/// than that. A wide book is not a small tax on this strategy; it is the difference
|
||||
/// between an edge and no edge.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public double MaxRelativeSpread { get; set; } = 0.0006;
|
||||
|
||||
/// <summary>Orders smaller than this notional are not worth the commission-free slippage.</summary>
|
||||
public double MinOrderNotional { get; set; } = 25;
|
||||
/// <summary>Smallest notional per leg, in USDT. Binance also has its own floor per symbol.</summary>
|
||||
public double MinOrderNotional { get; set; } = 20;
|
||||
|
||||
/// <summary>Absolute ceiling on a single order's notional. 0 disables.</summary>
|
||||
/// <summary>Largest notional per leg, in USDT. 0 = no limit.</summary>
|
||||
public double MaxOrderNotional { get; set; }
|
||||
|
||||
public bool AllowShorting { get; set; }
|
||||
/// <summary>
|
||||
/// Highest maintenance-margin-to-balance ratio at which a new pair may still be
|
||||
/// opened. Above it the account is close enough to a liquidation that adding
|
||||
/// exposure is how a bad day becomes a terminal one.
|
||||
/// </summary>
|
||||
public double MaxMarginRatio { get; set; } = 0.50;
|
||||
|
||||
/// <summary>Fallback stop distance (fraction of price) when a strategy supplies none.</summary>
|
||||
public double DefaultStopPct { get; set; } = 0.02;
|
||||
/// <summary>
|
||||
/// Hedge ratios outside <c>[1/MaxHedgeRatio, MaxHedgeRatio]</c> are refused. A β of
|
||||
/// 12 is not a hedge, it is a leveraged bet on the second leg wearing a hedge's name.
|
||||
/// </summary>
|
||||
public double MaxHedgeRatio { get; set; } = 5.0;
|
||||
|
||||
/// <summary>Rejects nonsensical stops that are further than this fraction from entry.</summary>
|
||||
public double MaxStopDistancePct { get; set; } = 0.15;
|
||||
public bool HasExplicitStake => StakePct > 0 || StakeAmount > 0;
|
||||
|
||||
/// <summary>The margin to commit to one pair, given current equity.</summary>
|
||||
public double ResolveStake(double equity) =>
|
||||
StakeAmount > 0 ? StakeAmount : equity * StakePct;
|
||||
|
||||
public string DescribeSizing() =>
|
||||
StakeAmount > 0
|
||||
? $"{StakeAmount:F2} USDT per coppia"
|
||||
: $"{StakePct:P1} dell'equity per coppia";
|
||||
|
||||
public RiskLimits Validate()
|
||||
{
|
||||
Require(MaxRiskPerTradePct is > 0 and <= 0.25, nameof(MaxRiskPerTradePct), "must be in (0, 0.25]");
|
||||
Require(MaxPositionNotionalPct is > 0 and <= 1.0, nameof(MaxPositionNotionalPct), "must be in (0, 1]");
|
||||
Require(StakePct is >= 0 and <= 1.0, nameof(StakePct), "must be in [0, 1] — it is a fraction of equity, so 0.2 means 20%");
|
||||
Require(StakeAmount >= 0, nameof(StakeAmount), "must be >= 0");
|
||||
if (StakePct is < 0 or > 1)
|
||||
{
|
||||
throw new InvalidOperationException("risk.stakePct must be a fraction between 0 and 1.");
|
||||
}
|
||||
|
||||
if (StakeAmount < 0)
|
||||
{
|
||||
throw new InvalidOperationException("risk.stakeAmount cannot be negative.");
|
||||
}
|
||||
|
||||
if (!HasExplicitStake)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"risk.stakePct or risk.stakeAmount must be set: the bot has no other way to size a pair.");
|
||||
}
|
||||
|
||||
if (FundingTiltPct is < 0 or > 0.5)
|
||||
{
|
||||
throw new InvalidOperationException("risk.fundingTiltPct must be between 0 and 0.5.");
|
||||
}
|
||||
|
||||
if (MaxGrossExposurePct is < 0 or > 20)
|
||||
{
|
||||
throw new InvalidOperationException("risk.maxGrossExposurePct must be between 0 and 20.");
|
||||
}
|
||||
|
||||
if (MaxDailyLossPct is <= 0 or > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"risk.maxDailyLossPct must be a fraction between 0 and 1. There is no way to " +
|
||||
"disable the daily kill switch: on leveraged futures it is the last stop before " +
|
||||
"a liquidation.");
|
||||
}
|
||||
|
||||
if (MaxDailyProfitPct is < 0 or > 5)
|
||||
{
|
||||
throw new InvalidOperationException("risk.maxDailyProfitPct must be between 0 and 5.");
|
||||
}
|
||||
|
||||
if (MaxRelativeSpread is < 0 or > 0.1)
|
||||
{
|
||||
throw new InvalidOperationException("risk.maxRelativeSpread must be between 0 and 0.1.");
|
||||
}
|
||||
|
||||
if (MaxMarginRatio is <= 0 or > 1)
|
||||
{
|
||||
throw new InvalidOperationException("risk.maxMarginRatio must be between 0 and 1.");
|
||||
}
|
||||
|
||||
if (MaxHedgeRatio is < 1 or > 100)
|
||||
{
|
||||
throw new InvalidOperationException("risk.maxHedgeRatio must be between 1 and 100.");
|
||||
}
|
||||
|
||||
if (MinOrderNotional < 0)
|
||||
{
|
||||
throw new InvalidOperationException("risk.minOrderNotional cannot be negative.");
|
||||
}
|
||||
|
||||
if (MaxOrderNotional > 0 && MaxOrderNotional < MinOrderNotional)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"risk.maxOrderNotional is below risk.minOrderNotional: no order could ever be sent.");
|
||||
}
|
||||
|
||||
// Caught here rather than silently clipped at order time: an operator who asks
|
||||
// for 60% and gets 50% without being told would keep believing the wrong number.
|
||||
Require(StakePct <= MaxPositionNotionalPct, nameof(StakePct),
|
||||
$"is {StakePct:P0} but maxPositionNotionalPct caps a position at {MaxPositionNotionalPct:P0}; " +
|
||||
"raise the cap or lower the stake");
|
||||
Require(MaxGrossExposurePct is > 0 and <= 4.0, nameof(MaxGrossExposurePct), "must be in (0, 4]");
|
||||
// 0 disables the cap, the same convention maxOrderNotional and maxDailyProfitPct
|
||||
// already use. Negative is a typo, not an intention.
|
||||
Require(MaxOpenPositions >= 0, nameof(MaxOpenPositions), "must be >= 0 (0 = no limit)");
|
||||
Require(MaxTradesPerDay >= 0, nameof(MaxTradesPerDay), "must be >= 0 (0 = no limit)");
|
||||
Require(MaxTradesPerSymbolPerDay >= 0, nameof(MaxTradesPerSymbolPerDay), "must be >= 0 (0 = no limit)");
|
||||
Require(MaxDailyLossPct is > 0 and <= 1.0, nameof(MaxDailyLossPct), "must be in (0, 1]");
|
||||
Require(MaxDailyProfitPct >= 0, nameof(MaxDailyProfitPct), "must be >= 0");
|
||||
Require(MinSecondsBetweenEntries >= 0, nameof(MinSecondsBetweenEntries), "must be >= 0");
|
||||
Require(MinPrice > 0 && MaxPrice > MinPrice, nameof(MinPrice), "must satisfy 0 < MinPrice < MaxPrice");
|
||||
Require(MinOrderNotional > 0, nameof(MinOrderNotional), "must be > 0");
|
||||
Require(DefaultStopPct is > 0 and < 1, nameof(DefaultStopPct), "must be in (0, 1)");
|
||||
Require(MaxStopDistancePct is > 0 and < 1, nameof(MaxStopDistancePct), "must be in (0, 1)");
|
||||
return this;
|
||||
}
|
||||
|
||||
private static void Require(bool condition, string field, string requirement)
|
||||
{
|
||||
if (!condition)
|
||||
{
|
||||
throw new InvalidOperationException($"risk.{field} {requirement}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Core.Statistics;
|
||||
|
||||
/// <summary>
|
||||
/// What the Engle-Granger procedure found out about a pair of price series.
|
||||
/// </summary>
|
||||
public sealed record CointegrationResult(
|
||||
double Alpha,
|
||||
double Beta,
|
||||
double PValue,
|
||||
double AdfStatistic,
|
||||
double CriticalValue5,
|
||||
double HalfLifeBars,
|
||||
double SpreadMean,
|
||||
double SpreadStdDev,
|
||||
int Observations,
|
||||
int Lags)
|
||||
{
|
||||
public static readonly CointegrationResult Failed =
|
||||
new(0, 0, 1.0, double.NaN, double.NaN, double.NaN, 0, 0, 0, 0);
|
||||
|
||||
public bool IsValid => Observations > 0 && double.IsFinite(Beta) && SpreadStdDev > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the relationship is real enough to trade at the conventional 5% level.
|
||||
/// Decided on the critical value, not on the interpolated p — see
|
||||
/// <see cref="MacKinnon.ApproximatePValue"/>.
|
||||
/// </summary>
|
||||
public bool IsCointegrated =>
|
||||
IsValid && double.IsFinite(AdfStatistic) && AdfStatistic <= CriticalValue5;
|
||||
|
||||
public string Describe() => IsValid
|
||||
? string.Create(CultureInfo.InvariantCulture,
|
||||
$"β={Beta:F4} α={Alpha:F4} ADF={AdfStatistic:F3} (5%={CriticalValue5:F3}) " +
|
||||
$"p≈{PValue:F4} emivita={HalfLifeBars:F1} barre σ={SpreadStdDev:F5} n={Observations}")
|
||||
: "cointegrazione non calcolabile";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Engle-Granger two-step test: is the gap between two assets a thing that comes
|
||||
/// back, or two things that happen to have drifted together?
|
||||
/// <para>
|
||||
/// Step one fits <c>ln(Pₐ) = α + β·ln(P_b)</c>. β is the <b>hedge ratio</b> — how much of
|
||||
/// the second asset offsets one unit of the first — and it is what makes the pair
|
||||
/// delta-neutral rather than merely diversified.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Step two asks whether the leftover, <c>ln(Pₐ) − α − β·ln(P_b)</c>, mean-reverts. That
|
||||
/// is the entire question. Two assets can be correlated for years and still never close
|
||||
/// a gap once it opens; correlation says they move together, cointegration says the
|
||||
/// distance between them is bounded. Only the second is tradeable, and betting on the
|
||||
/// first is how a pairs book quietly turns into a directional one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Logs rather than prices, because the relationship between two assets is proportional:
|
||||
/// a fixed spread in dollars means something different at 300 than at 3,000, whereas a
|
||||
/// fixed spread in logs is the same ratio at both.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Cointegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests two <b>log</b> price series, aligned and oldest first.
|
||||
/// </summary>
|
||||
public static CointegrationResult Test(IReadOnlyList<double> logA, IReadOnlyList<double> logB)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logA);
|
||||
ArgumentNullException.ThrowIfNull(logB);
|
||||
|
||||
int n = Math.Min(logA.Count, logB.Count);
|
||||
if (n < 30)
|
||||
{
|
||||
return CointegrationResult.Failed;
|
||||
}
|
||||
|
||||
double[] a = new double[n];
|
||||
double[] b = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
a[i] = logA[i];
|
||||
b[i] = logB[i];
|
||||
|
||||
if (!double.IsFinite(a[i]) || !double.IsFinite(b[i]))
|
||||
{
|
||||
return CointegrationResult.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Ols.FitLine(b, a, out double alpha, out double beta))
|
||||
{
|
||||
return CointegrationResult.Failed;
|
||||
}
|
||||
|
||||
double[] residuals = new double[n];
|
||||
double sum = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
residuals[i] = a[i] - alpha - (beta * b[i]);
|
||||
sum += residuals[i];
|
||||
}
|
||||
|
||||
double mean = sum / n;
|
||||
double variance = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double d = residuals[i] - mean;
|
||||
variance += d * d;
|
||||
}
|
||||
|
||||
double stdDev = n > 1 ? Math.Sqrt(variance / (n - 1)) : 0;
|
||||
|
||||
// No deterministic term: the residuals of a regression that already carried an
|
||||
// intercept have mean zero by construction, and adding another one would spend a
|
||||
// degree of freedom to estimate something known to be zero. The critical values
|
||||
// are the cointegration ones, which account for β having been estimated rather
|
||||
// than given.
|
||||
AdfResult adf = DickeyFuller.Test(
|
||||
residuals, DickeyFullerCase.None, DickeyFullerTable.Cointegration);
|
||||
|
||||
return new CointegrationResult(
|
||||
alpha,
|
||||
beta,
|
||||
adf.PValue,
|
||||
adf.Statistic,
|
||||
adf.CriticalValue5,
|
||||
HalfLife(residuals),
|
||||
mean,
|
||||
stdDev,
|
||||
n,
|
||||
adf.Lags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience overload that takes raw prices and takes the logs itself.
|
||||
/// </summary>
|
||||
public static CointegrationResult TestPrices(IReadOnlyList<double> pricesA, IReadOnlyList<double> pricesB)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pricesA);
|
||||
ArgumentNullException.ThrowIfNull(pricesB);
|
||||
|
||||
int n = Math.Min(pricesA.Count, pricesB.Count);
|
||||
double[] logA = new double[n];
|
||||
double[] logB = new double[n];
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (pricesA[i] <= 0 || pricesB[i] <= 0)
|
||||
{
|
||||
return CointegrationResult.Failed;
|
||||
}
|
||||
|
||||
logA[i] = Math.Log(pricesA[i]);
|
||||
logB[i] = Math.Log(pricesB[i]);
|
||||
}
|
||||
|
||||
return Test(logA, logB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many bars it takes a deviation to decay by half, from the Ornstein-Uhlenbeck
|
||||
/// fit <c>Δeₜ = λ·eₜ₋₁ + ε</c> with <c>halfLife = −ln2/λ</c>.
|
||||
/// <para>
|
||||
/// This decides the <i>pace</i> of a pair, not whether it is tradeable. A spread with
|
||||
/// a two-bar half-life reverts faster than the fees can be earned back; one with a
|
||||
/// four-hundred-bar half-life ties up margin for days per trade and will have broken
|
||||
/// its own relationship long before it closes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It cannot tell a mean-reverting series from a random walk, and must never be used
|
||||
/// to try. Measured over four hundred steps, a pure random walk returns an apparent
|
||||
/// half-life with a median around sixty bars and a range from twenty to several
|
||||
/// hundred — comfortably inside any band a real pair would satisfy. Separating the
|
||||
/// two is what <see cref="DickeyFuller"/> is for, and this number is only meaningful
|
||||
/// once that test has already passed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static double HalfLife(IReadOnlyList<double> series)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(series);
|
||||
|
||||
int n = series.Count;
|
||||
if (n < 20)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double[] lagged = new double[n - 1];
|
||||
double[] delta = new double[n - 1];
|
||||
|
||||
for (int i = 1; i < n; i++)
|
||||
{
|
||||
lagged[i - 1] = series[i - 1];
|
||||
delta[i - 1] = series[i] - series[i - 1];
|
||||
}
|
||||
|
||||
if (!Ols.FitLine(lagged, delta, out _, out double lambda))
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
// λ ≥ 0 means a deviation grows rather than decays: there is no half-life, and
|
||||
// saying so as NaN is more honest than returning a large number.
|
||||
if (lambda >= -1e-12)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double halfLife = -Math.Log(2) / lambda;
|
||||
|
||||
// A half-life longer than the sample is not a measurement either: nothing about a
|
||||
// horizon can be established over less than one of them.
|
||||
return halfLife >= n ? double.NaN : halfLife;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Core.Statistics;
|
||||
|
||||
/// <summary>What the Dickey-Fuller regression includes besides the lagged level.</summary>
|
||||
public enum DickeyFullerCase : byte
|
||||
{
|
||||
/// <summary>No deterministic term. Used on residuals, which are mean-zero by construction.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>An intercept. The ordinary unit-root test on a raw series.</summary>
|
||||
Constant = 1,
|
||||
}
|
||||
|
||||
/// <summary>Which table of critical values applies to a test statistic.</summary>
|
||||
public enum DickeyFullerTable : byte
|
||||
{
|
||||
/// <summary>A unit-root test on an observed series.</summary>
|
||||
UnitRoot = 0,
|
||||
|
||||
/// <summary>
|
||||
/// A cointegration test on residuals that were themselves estimated. The critical
|
||||
/// values are further into the tail than the unit-root ones, because fitting the
|
||||
/// hedge ratio already made the residuals look more stationary than they are.
|
||||
/// </summary>
|
||||
Cointegration = 1,
|
||||
}
|
||||
|
||||
/// <summary>The outcome of an Augmented Dickey-Fuller test.</summary>
|
||||
public readonly record struct AdfResult(
|
||||
double Statistic,
|
||||
double PValue,
|
||||
int Lags,
|
||||
int Observations,
|
||||
double CriticalValue1,
|
||||
double CriticalValue5,
|
||||
double CriticalValue10)
|
||||
{
|
||||
public static readonly AdfResult Invalid =
|
||||
new(double.NaN, 1.0, 0, 0, double.NaN, double.NaN, double.NaN);
|
||||
|
||||
public bool IsValid => double.IsFinite(Statistic);
|
||||
|
||||
/// <summary>
|
||||
/// True when the null of a unit root is rejected at 5%, i.e. the series mean-reverts.
|
||||
/// Compared against the critical value rather than the interpolated p-value, so the
|
||||
/// decision is exact at the threshold that matters.
|
||||
/// </summary>
|
||||
public bool RejectsUnitRoot => IsValid && Statistic <= CriticalValue5;
|
||||
|
||||
public string Describe() => IsValid
|
||||
? string.Create(CultureInfo.InvariantCulture,
|
||||
$"ADF {Statistic:F3} (5% = {CriticalValue5:F3}), p≈{PValue:F4}, {Lags} lag, {Observations} oss.")
|
||||
: "ADF non calcolabile";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Augmented Dickey-Fuller test: does this series pull back towards its mean, or
|
||||
/// does it wander?
|
||||
/// <para>
|
||||
/// It regresses the change in the series on its own previous level:
|
||||
/// <c>Δyₜ = γ·yₜ₋₁ + Σδᵢ·Δyₜ₋ᵢ + ε</c>. If γ is reliably negative, a high value tends to
|
||||
/// be followed by a fall and a low value by a rise — the series is pulled back, which is
|
||||
/// the entire premise of trading a spread. If γ is indistinguishable from zero the series
|
||||
/// is a random walk and any apparent relationship is a coincidence waiting to end.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The lagged differences are the "augmented" part: without them, ordinary
|
||||
/// autocorrelation in the residuals inflates the statistic and makes almost anything
|
||||
/// look mean-reverting.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DickeyFuller
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the test on <paramref name="series"/>, in order, oldest first.
|
||||
/// </summary>
|
||||
/// <param name="maxLag">
|
||||
/// Highest lag order to consider. Negative selects Schwert's rule,
|
||||
/// <c>⌊12·(n/100)^¼⌋</c>, and the order is then chosen by AIC — the same default the
|
||||
/// reference implementations use.
|
||||
/// </param>
|
||||
public static AdfResult Test(
|
||||
IReadOnlyList<double> series,
|
||||
DickeyFullerCase deterministic = DickeyFullerCase.Constant,
|
||||
DickeyFullerTable table = DickeyFullerTable.UnitRoot,
|
||||
int maxLag = -1)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(series);
|
||||
|
||||
int n = series.Count;
|
||||
if (n < 20)
|
||||
{
|
||||
return AdfResult.Invalid;
|
||||
}
|
||||
|
||||
if (maxLag < 0)
|
||||
{
|
||||
maxLag = (int)Math.Floor(12 * Math.Pow(n / 100.0, 0.25));
|
||||
}
|
||||
|
||||
maxLag = Math.Clamp(maxLag, 0, Math.Max(0, (n / 4) - 3));
|
||||
|
||||
double[] differences = new double[n - 1];
|
||||
for (int i = 1; i < n; i++)
|
||||
{
|
||||
differences[i - 1] = series[i] - series[i - 1];
|
||||
}
|
||||
|
||||
AdfResult best = AdfResult.Invalid;
|
||||
double bestAic = double.PositiveInfinity;
|
||||
|
||||
for (int lag = 0; lag <= maxLag; lag++)
|
||||
{
|
||||
if (!TryFit(series, differences, lag, deterministic, out OlsFit? fit, out int used) || fit is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double aic = fit.Aic;
|
||||
if (aic >= bestAic)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bestAic = aic;
|
||||
|
||||
// The lagged level is always the first regressor, so its t-statistic is the
|
||||
// one the whole test is about.
|
||||
double statistic = fit.TStatistic(0);
|
||||
(double c1, double c5, double c10) = MacKinnon.CriticalValues(table, used);
|
||||
|
||||
best = new AdfResult(
|
||||
statistic,
|
||||
MacKinnon.ApproximatePValue(statistic, c1, c5, c10),
|
||||
lag,
|
||||
used,
|
||||
c1, c5, c10);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and fits one ADF regression at a given lag order.
|
||||
/// <para>
|
||||
/// Column layout: the lagged level first, then the constant if one was asked for,
|
||||
/// then the lagged differences. Keeping the level at index 0 regardless of the
|
||||
/// deterministic term is what lets the caller read its t-statistic without knowing
|
||||
/// how the matrix was laid out.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool TryFit(
|
||||
IReadOnlyList<double> series,
|
||||
double[] differences,
|
||||
int lag,
|
||||
DickeyFullerCase deterministic,
|
||||
out OlsFit? fit,
|
||||
out int observations)
|
||||
{
|
||||
fit = null;
|
||||
|
||||
// Δy is one shorter than y, and `lag` further observations are consumed by the
|
||||
// lagged differences on the right-hand side.
|
||||
int start = lag;
|
||||
observations = differences.Length - start;
|
||||
|
||||
int columns = 1 + (deterministic == DickeyFullerCase.Constant ? 1 : 0) + lag;
|
||||
if (observations <= columns + 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double[][] x = new double[observations][];
|
||||
double[] y = new double[observations];
|
||||
|
||||
for (int t = 0; t < observations; t++)
|
||||
{
|
||||
int index = start + t;
|
||||
double[] row = new double[columns];
|
||||
int column = 0;
|
||||
|
||||
// Δy[index] is the change from series[index] to series[index + 1], so the
|
||||
// level that precedes it is series[index].
|
||||
row[column++] = series[index];
|
||||
|
||||
if (deterministic == DickeyFullerCase.Constant)
|
||||
{
|
||||
row[column++] = 1;
|
||||
}
|
||||
|
||||
for (int l = 1; l <= lag; l++)
|
||||
{
|
||||
row[column++] = differences[index - l];
|
||||
}
|
||||
|
||||
x[t] = row;
|
||||
y[t] = differences[index];
|
||||
}
|
||||
|
||||
fit = Ols.Fit(x, y);
|
||||
return fit is not null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Critical values for the Dickey-Fuller family of tests.
|
||||
/// <para>
|
||||
/// The statistic does not follow a t-distribution — under the null the regressor is
|
||||
/// itself a random walk, and the usual asymptotics do not apply — so the thresholds come
|
||||
/// from MacKinnon's simulated response surfaces:
|
||||
/// <c>CV(T) = β∞ + β₁/T + β₂/T²</c>. The asymptotic term dominates; the two correction
|
||||
/// terms are worth a few hundredths at the sample sizes used here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MacKinnon
|
||||
{
|
||||
// β∞, β₁, β₂ at 1%, 5% and 10%.
|
||||
// Unit-root test with an intercept (MacKinnon, one variable).
|
||||
private static readonly (double Inf, double B1, double B2)[] UnitRootSurface =
|
||||
[
|
||||
(-3.43035, -6.5393, -16.786),
|
||||
(-2.86154, -2.8903, -4.234),
|
||||
(-2.56677, -1.5384, -2.809),
|
||||
];
|
||||
|
||||
// Engle-Granger cointegration test, two variables, with an intercept.
|
||||
private static readonly (double Inf, double B1, double B2)[] CointegrationSurface =
|
||||
[
|
||||
(-3.89644, -10.9519, -22.527),
|
||||
(-3.33613, -6.1101, -6.823),
|
||||
(-3.04445, -4.2412, -2.720),
|
||||
];
|
||||
|
||||
/// <summary>The 1%, 5% and 10% thresholds at a given number of observations.</summary>
|
||||
public static (double One, double Five, double Ten) CriticalValues(DickeyFullerTable table, int observations)
|
||||
{
|
||||
(double Inf, double B1, double B2)[] surface =
|
||||
table == DickeyFullerTable.Cointegration ? CointegrationSurface : UnitRootSurface;
|
||||
|
||||
double t = Math.Max(observations, 10);
|
||||
|
||||
return (Evaluate(surface[0], t), Evaluate(surface[1], t), Evaluate(surface[2], t));
|
||||
|
||||
static double Evaluate((double Inf, double B1, double B2) s, double t) =>
|
||||
s.Inf + (s.B1 / t) + (s.B2 / (t * t));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An approximate p-value for a Dickey-Fuller statistic.
|
||||
/// <para>
|
||||
/// This interpolates the three known quantiles on the probit scale — where the
|
||||
/// relationship between the statistic and the tail probability is close to straight —
|
||||
/// and extrapolates from the nearest pair outside them. It is <b>not</b> MacKinnon's
|
||||
/// own p-value response surface, and away from the tabulated points it should be read
|
||||
/// as an indication rather than a measurement.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The decision the bot actually takes does not depend on it: a pair is accepted when
|
||||
/// the statistic clears the 5% critical value, which is one of the exact points.
|
||||
/// The number exists so the log can say <i>how far past</i> the threshold a pair sits.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static double ApproximatePValue(double statistic, double c1, double c5, double c10)
|
||||
{
|
||||
if (!double.IsFinite(statistic))
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// More negative than the 1% value: extrapolate along the 1%-5% slope.
|
||||
if (statistic <= c1)
|
||||
{
|
||||
return Extrapolate(statistic, c1, 0.01, c5, 0.05);
|
||||
}
|
||||
|
||||
if (statistic <= c5)
|
||||
{
|
||||
return Interpolate(statistic, c1, 0.01, c5, 0.05);
|
||||
}
|
||||
|
||||
if (statistic <= c10)
|
||||
{
|
||||
return Interpolate(statistic, c5, 0.05, c10, 0.10);
|
||||
}
|
||||
|
||||
// Above the 10% value the series is comfortably a random walk; report a large
|
||||
// p rather than pretending to resolve the difference between 0.4 and 0.7.
|
||||
double beyond = Extrapolate(statistic, c5, 0.05, c10, 0.10);
|
||||
return Math.Clamp(beyond, 0.10, 0.9999);
|
||||
}
|
||||
|
||||
private static double Interpolate(double x, double xa, double pa, double xb, double pb)
|
||||
{
|
||||
double za = Normal.Quantile(pa);
|
||||
double zb = Normal.Quantile(pb);
|
||||
|
||||
double weight = Math.Abs(xb - xa) < 1e-12 ? 0 : (x - xa) / (xb - xa);
|
||||
return Math.Clamp(Normal.Cdf(za + (weight * (zb - za))), 1e-6, 0.9999);
|
||||
}
|
||||
|
||||
private static double Extrapolate(double x, double xa, double pa, double xb, double pb) =>
|
||||
Interpolate(x, xa, pa, xb, pb);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
namespace Encelado.Core.Statistics;
|
||||
|
||||
/// <summary>
|
||||
/// The two normal-distribution functions the statistical tests need. Both are rational
|
||||
/// approximations accurate to roughly seven decimal places, which is several orders of
|
||||
/// magnitude finer than any decision taken on them here.
|
||||
/// </summary>
|
||||
public static class Normal
|
||||
{
|
||||
/// <summary>Cumulative distribution function of the standard normal.</summary>
|
||||
public static double Cdf(double z)
|
||||
{
|
||||
if (double.IsNaN(z))
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
// Φ(z) = ½·erfc(−z/√2). The complementary error function is what stays accurate
|
||||
// in the far tail, where a p-value of interest actually lives.
|
||||
return 0.5 * Erfc(-z / Math.Sqrt(2));
|
||||
}
|
||||
|
||||
/// <summary>Inverse cumulative distribution: the z with <c>Cdf(z) == p</c>.</summary>
|
||||
public static double Quantile(double p)
|
||||
{
|
||||
if (p is <= 0 or >= 1 || double.IsNaN(p))
|
||||
{
|
||||
return p <= 0 ? double.NegativeInfinity : p >= 1 ? double.PositiveInfinity : double.NaN;
|
||||
}
|
||||
|
||||
// Acklam's algorithm: a piecewise rational approximation with a relative error
|
||||
// below 1.15e-9 across the whole range.
|
||||
const double PLow = 0.02425;
|
||||
const double PHigh = 1 - PLow;
|
||||
|
||||
double[] a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
|
||||
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
|
||||
double[] b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
|
||||
6.680131188771972e+01, -1.328068155288572e+01];
|
||||
double[] c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
|
||||
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
|
||||
double[] d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
|
||||
3.754408661907416e+00];
|
||||
|
||||
if (p < PLow)
|
||||
{
|
||||
double q = Math.Sqrt(-2 * Math.Log(p));
|
||||
return ((((((c[0] * q) + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
||||
((((((d[0] * q) + d[1]) * q + d[2]) * q + d[3]) * q) + 1);
|
||||
}
|
||||
|
||||
if (p > PHigh)
|
||||
{
|
||||
double q = Math.Sqrt(-2 * Math.Log(1 - p));
|
||||
return -((((((c[0] * q) + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
|
||||
((((((d[0] * q) + d[1]) * q + d[2]) * q + d[3]) * q) + 1);
|
||||
}
|
||||
|
||||
double r = p - 0.5;
|
||||
double s = r * r;
|
||||
return ((((((a[0] * s) + a[1]) * s + a[2]) * s + a[3]) * s + a[4]) * s + a[5]) * r /
|
||||
((((((b[0] * s) + b[1]) * s + b[2]) * s + b[3]) * s + b[4]) * s + 1);
|
||||
}
|
||||
|
||||
/// <summary>Complementary error function, by the Numerical Recipes Chebyshev fit.</summary>
|
||||
private static double Erfc(double x)
|
||||
{
|
||||
double z = Math.Abs(x);
|
||||
double t = 2.0 / (2.0 + z);
|
||||
double ty = (4 * t) - 2;
|
||||
|
||||
double[] cof =
|
||||
[
|
||||
-1.3026537197817094, 6.4196979235649026e-1, 1.9476473204185836e-2,
|
||||
-9.561514786808631e-3, -9.46595344482036e-4, 3.66839497852761e-4,
|
||||
4.2523324806907e-5, -2.0278578112534e-5, -1.624290004647e-6,
|
||||
1.303655835580e-6, 1.5626441722e-8, -8.5238095915e-8,
|
||||
6.529054439e-9, 5.059343495e-9, -9.91364156e-10,
|
||||
-2.27365122e-10, 9.6467911e-11, 2.394038e-12,
|
||||
-6.886027e-12, 8.94487e-13, 3.13092e-13,
|
||||
-1.12708e-13, 3.81e-16, 7.106e-15,
|
||||
];
|
||||
|
||||
double d = 0;
|
||||
double dd = 0;
|
||||
|
||||
for (int j = cof.Length - 1; j > 0; j--)
|
||||
{
|
||||
double tmp = d;
|
||||
d = (ty * d) - dd + cof[j];
|
||||
dd = tmp;
|
||||
}
|
||||
|
||||
double result = t * Math.Exp((-z * z) + (0.5 * (cof[0] + (ty * d))) - dd);
|
||||
return x >= 0 ? result : 2.0 - result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
namespace Encelado.Core.Statistics;
|
||||
|
||||
/// <summary>
|
||||
/// The fit of an ordinary least squares regression: the coefficients, how precisely
|
||||
/// each is known, and what the model failed to explain.
|
||||
/// </summary>
|
||||
public sealed class OlsFit
|
||||
{
|
||||
internal OlsFit(double[] coefficients, double[] standardErrors, double[] residuals,
|
||||
double residualSumOfSquares, double totalSumOfSquares, int observations)
|
||||
{
|
||||
Coefficients = coefficients;
|
||||
StandardErrors = standardErrors;
|
||||
Residuals = residuals;
|
||||
ResidualSumOfSquares = residualSumOfSquares;
|
||||
TotalSumOfSquares = totalSumOfSquares;
|
||||
Observations = observations;
|
||||
}
|
||||
|
||||
public double[] Coefficients { get; }
|
||||
|
||||
public double[] StandardErrors { get; }
|
||||
|
||||
public double[] Residuals { get; }
|
||||
|
||||
public double ResidualSumOfSquares { get; }
|
||||
|
||||
public double TotalSumOfSquares { get; }
|
||||
|
||||
public int Observations { get; }
|
||||
|
||||
public int Parameters => Coefficients.Length;
|
||||
|
||||
public int DegreesOfFreedom => Observations - Parameters;
|
||||
|
||||
public double RSquared =>
|
||||
TotalSumOfSquares > 0 ? 1 - (ResidualSumOfSquares / TotalSumOfSquares) : 0;
|
||||
|
||||
/// <summary>
|
||||
/// The t-statistic of one coefficient: how many standard errors it sits away from
|
||||
/// zero. This is the number the Dickey-Fuller test is built on.
|
||||
/// </summary>
|
||||
public double TStatistic(int index) =>
|
||||
(uint)index < (uint)Coefficients.Length && StandardErrors[index] > 0
|
||||
? Coefficients[index] / StandardErrors[index]
|
||||
: double.NaN;
|
||||
|
||||
/// <summary>Akaike information criterion, used to pick the lag order of an ADF regression.</summary>
|
||||
public double Aic
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Observations <= 0 || ResidualSumOfSquares <= 0)
|
||||
{
|
||||
return double.PositiveInfinity;
|
||||
}
|
||||
|
||||
double sigmaSquared = ResidualSumOfSquares / Observations;
|
||||
return (Observations * Math.Log(sigmaSquared)) + (2 * Parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordinary least squares by normal equations and a Cholesky factorisation.
|
||||
/// <para>
|
||||
/// The design matrices here are tiny — two columns for a hedge ratio, at most a dozen
|
||||
/// for a lagged Dickey-Fuller regression — and always well conditioned, because the
|
||||
/// regressors are prices and their own differences rather than anything constructed.
|
||||
/// A QR decomposition would be more robust in general and is not needed for this;
|
||||
/// Cholesky on <c>XᵀX</c> is a few dozen lines and runs in microseconds, which matters
|
||||
/// when the whole basket is refitted on a schedule.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Ols
|
||||
{
|
||||
/// <summary>
|
||||
/// Fits <c>y = Xβ + ε</c>. <paramref name="x"/> is row-major: one array per
|
||||
/// observation, each holding that observation's regressors. No intercept is added —
|
||||
/// include a column of ones when one is wanted.
|
||||
/// </summary>
|
||||
/// <returns>The fit, or <see langword="null"/> when the system is singular or underdetermined.</returns>
|
||||
public static OlsFit? Fit(IReadOnlyList<double[]> x, IReadOnlyList<double> y)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(x);
|
||||
ArgumentNullException.ThrowIfNull(y);
|
||||
|
||||
int n = Math.Min(x.Count, y.Count);
|
||||
if (n == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int k = x[0].Length;
|
||||
if (k == 0 || n <= k)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// XᵀX and Xᵀy, accumulated in one pass. XᵀX is symmetric, so only the lower
|
||||
// triangle is built and the factorisation reads what it needs from there.
|
||||
double[,] xtx = new double[k, k];
|
||||
double[] xty = new double[k];
|
||||
|
||||
for (int t = 0; t < n; t++)
|
||||
{
|
||||
double[] row = x[t];
|
||||
if (row.Length != k)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double yt = y[t];
|
||||
for (int i = 0; i < k; i++)
|
||||
{
|
||||
double xi = row[i];
|
||||
xty[i] += xi * yt;
|
||||
for (int j = 0; j <= i; j++)
|
||||
{
|
||||
xtx[i, j] += xi * row[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < k; i++)
|
||||
{
|
||||
for (int j = i + 1; j < k; j++)
|
||||
{
|
||||
xtx[i, j] = xtx[j, i];
|
||||
}
|
||||
}
|
||||
|
||||
double[,]? chol = Cholesky(xtx, k);
|
||||
if (chol is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double[] beta = SolveCholesky(chol, xty, k);
|
||||
|
||||
double[] residuals = new double[n];
|
||||
double rss = 0;
|
||||
double meanY = 0;
|
||||
|
||||
for (int t = 0; t < n; t++)
|
||||
{
|
||||
meanY += y[t];
|
||||
}
|
||||
|
||||
meanY /= n;
|
||||
|
||||
double tss = 0;
|
||||
for (int t = 0; t < n; t++)
|
||||
{
|
||||
double[] row = x[t];
|
||||
double predicted = 0;
|
||||
for (int i = 0; i < k; i++)
|
||||
{
|
||||
predicted += beta[i] * row[i];
|
||||
}
|
||||
|
||||
double residual = y[t] - predicted;
|
||||
residuals[t] = residual;
|
||||
rss += residual * residual;
|
||||
|
||||
double centred = y[t] - meanY;
|
||||
tss += centred * centred;
|
||||
}
|
||||
|
||||
int degreesOfFreedom = n - k;
|
||||
double sigmaSquared = degreesOfFreedom > 0 ? rss / degreesOfFreedom : 0;
|
||||
|
||||
// The standard errors need the diagonal of (XᵀX)⁻¹, which the factorisation
|
||||
// already almost has: solving against each unit vector recovers one column.
|
||||
double[] standardErrors = new double[k];
|
||||
double[] unit = new double[k];
|
||||
|
||||
for (int i = 0; i < k; i++)
|
||||
{
|
||||
Array.Clear(unit);
|
||||
unit[i] = 1;
|
||||
double[] column = SolveCholesky(chol, unit, k);
|
||||
double variance = sigmaSquared * column[i];
|
||||
standardErrors[i] = variance > 0 ? Math.Sqrt(variance) : 0;
|
||||
}
|
||||
|
||||
return new OlsFit(beta, standardErrors, residuals, rss, tss, n);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits <c>y = α + βx</c> in closed form. This is the hedge-ratio regression, run on
|
||||
/// every candidate pair on every recalibration, so it avoids building a matrix at all.
|
||||
/// </summary>
|
||||
/// <returns><see langword="false"/> when x has no variance to regress against.</returns>
|
||||
public static bool FitLine(
|
||||
ReadOnlySpan<double> x, ReadOnlySpan<double> y, out double alpha, out double beta)
|
||||
{
|
||||
alpha = 0;
|
||||
beta = 0;
|
||||
|
||||
int n = Math.Min(x.Length, y.Length);
|
||||
if (n < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double sumX = 0, sumY = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
sumX += x[i];
|
||||
sumY += y[i];
|
||||
}
|
||||
|
||||
double meanX = sumX / n;
|
||||
double meanY = sumY / n;
|
||||
|
||||
double covariance = 0;
|
||||
double varianceX = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double dx = x[i] - meanX;
|
||||
covariance += dx * (y[i] - meanY);
|
||||
varianceX += dx * dx;
|
||||
}
|
||||
|
||||
if (varianceX <= 0 || !double.IsFinite(varianceX))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
beta = covariance / varianceX;
|
||||
alpha = meanY - (beta * meanX);
|
||||
return double.IsFinite(beta) && double.IsFinite(alpha);
|
||||
}
|
||||
|
||||
/// <summary>Lower-triangular Cholesky factor, or null when the matrix is not positive definite.</summary>
|
||||
private static double[,]? Cholesky(double[,] a, int n)
|
||||
{
|
||||
double[,] l = new double[n, n];
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
for (int j = 0; j <= i; j++)
|
||||
{
|
||||
double sum = a[i, j];
|
||||
for (int p = 0; p < j; p++)
|
||||
{
|
||||
sum -= l[i, p] * l[j, p];
|
||||
}
|
||||
|
||||
if (i == j)
|
||||
{
|
||||
// A non-positive pivot means the regressors are collinear — two
|
||||
// identical price series, or a constant column that is all zeros.
|
||||
if (sum <= 1e-300 || !double.IsFinite(sum))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
l[i, j] = Math.Sqrt(sum);
|
||||
}
|
||||
else
|
||||
{
|
||||
l[i, j] = sum / l[j, j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return l;
|
||||
}
|
||||
|
||||
/// <summary>Solves <c>LLᵀ·z = b</c> by forward then back substitution.</summary>
|
||||
private static double[] SolveCholesky(double[,] l, double[] b, int n)
|
||||
{
|
||||
double[] z = new double[n];
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double sum = b[i];
|
||||
for (int j = 0; j < i; j++)
|
||||
{
|
||||
sum -= l[i, j] * z[j];
|
||||
}
|
||||
|
||||
z[i] = sum / l[i, i];
|
||||
}
|
||||
|
||||
for (int i = n - 1; i >= 0; i--)
|
||||
{
|
||||
double sum = z[i];
|
||||
for (int j = i + 1; j < n; j++)
|
||||
{
|
||||
sum -= l[j, i] * z[j];
|
||||
}
|
||||
|
||||
z[i] = sum / l[i, i];
|
||||
}
|
||||
|
||||
return z;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
public enum SignalKind : byte
|
||||
{
|
||||
None = 0,
|
||||
EnterLong = 1,
|
||||
EnterShort = 2,
|
||||
Exit = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A strategy's decision for one symbol. Prices are absolute; <see cref="double.NaN"/>
|
||||
/// means "let the risk engine pick a default".
|
||||
/// </summary>
|
||||
public readonly record struct Signal(
|
||||
SignalKind Kind,
|
||||
double Strength,
|
||||
double StopPrice,
|
||||
double TargetPrice,
|
||||
string Reason)
|
||||
{
|
||||
public static readonly Signal Flat = new(SignalKind.None, 0, double.NaN, double.NaN, string.Empty);
|
||||
|
||||
public bool IsEntry => Kind is SignalKind.EnterLong or SignalKind.EnterShort;
|
||||
|
||||
public Side EntrySide => Kind switch
|
||||
{
|
||||
SignalKind.EnterLong => Side.Buy,
|
||||
SignalKind.EnterShort => Side.Sell,
|
||||
_ => Side.None,
|
||||
};
|
||||
|
||||
public static Signal EnterLong(string reason, double stopPrice = double.NaN, double targetPrice = double.NaN, double strength = 1.0) =>
|
||||
new(SignalKind.EnterLong, Math.Clamp(strength, 0, 1), stopPrice, targetPrice, reason);
|
||||
|
||||
public static Signal EnterShort(string reason, double stopPrice = double.NaN, double targetPrice = double.NaN, double strength = 1.0) =>
|
||||
new(SignalKind.EnterShort, Math.Clamp(strength, 0, 1), stopPrice, targetPrice, reason);
|
||||
|
||||
public static Signal Exit(string reason) =>
|
||||
new(SignalKind.Exit, 1.0, double.NaN, double.NaN, reason);
|
||||
}
|
||||
|
||||
/// <summary>A named internal reading a strategy exposes for the dashboard.</summary>
|
||||
public readonly record struct StrategyMetric(string Name, double Value, string Format = "F2");
|
||||
|
||||
/// <summary>
|
||||
/// One instance per (symbol, strategy) pair: implementations own their indicator
|
||||
/// state, so they must never be shared across symbols.
|
||||
/// </summary>
|
||||
public interface IStrategy
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>Number of closed bars required before signals become meaningful.</summary>
|
||||
int WarmupBars { get; }
|
||||
|
||||
/// <summary>True once every internal indicator has enough history.</summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Feeds a newly closed bar and returns the resulting decision. This is the
|
||||
/// primary decision point and runs on the market-data thread.
|
||||
/// </summary>
|
||||
Signal OnBar(in Bar bar, in PositionView position);
|
||||
|
||||
/// <summary>
|
||||
/// Optional intra-bar reaction (trailing stops, hard exits). Default: do nothing.
|
||||
/// Called on every top-of-book update, so it must stay allocation free.
|
||||
/// </summary>
|
||||
Signal OnQuote(in Quote quote, in PositionView position) => Signal.Flat;
|
||||
|
||||
/// <summary>
|
||||
/// Latest internal readings, surfaced by the dashboard so the operator can see
|
||||
/// <i>why</i> the strategy is doing what it is doing. Read at UI refresh rate, not
|
||||
/// on the decision path. Default: nothing to show.
|
||||
/// </summary>
|
||||
IReadOnlyList<StrategyMetric> Diagnostics => [];
|
||||
|
||||
/// <summary>
|
||||
/// One sentence saying what the strategy would do <b>at this instant and this price</b>,
|
||||
/// and what would have to change for it to do something else.
|
||||
/// <para>
|
||||
/// Unlike <see cref="OnBar"/> this must not touch any indicator state: it is called
|
||||
/// between decisions, from the quote path and from the dashboard, purely to answer
|
||||
/// the operator's question "why isn't it doing anything?". On a daily timeframe a
|
||||
/// strategy is silent for weeks at a time, and silence is indistinguishable from a
|
||||
/// hang unless it can say what it is waiting for.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
string Explain(double price, in PositionView position) => string.Empty;
|
||||
|
||||
void Reset();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loosely typed parameter bag: config files carry <c>{"fast": 9, "slow": 21}</c>
|
||||
/// and the strategy reads what it needs with a fallback. Keeps the config format
|
||||
/// open without reflection-based binding.
|
||||
/// </summary>
|
||||
public sealed class StrategyParameters
|
||||
{
|
||||
private readonly Dictionary<string, double> _values;
|
||||
|
||||
public StrategyParameters(IReadOnlyDictionary<string, double>? values = null) =>
|
||||
_values = values is null
|
||||
? new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, double>(values, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static StrategyParameters Empty => new();
|
||||
|
||||
public double Get(string key, double fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? v : fallback;
|
||||
|
||||
public int GetInt(string key, int fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? (int)Math.Round(v) : fallback;
|
||||
|
||||
public bool GetBool(string key, bool fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? v != 0 : fallback;
|
||||
|
||||
public StrategyParameters Set(string key, double value)
|
||||
{
|
||||
_values[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, double> Values => _values;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Strategies.Pairs;
|
||||
|
||||
/// <summary>What a pair strategy wants done, as one decision covering both legs.</summary>
|
||||
public enum PairSignalKind : byte
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/// <summary>The spread is unusually low: buy leg A, sell leg B.</summary>
|
||||
EnterLongSpread = 1,
|
||||
|
||||
/// <summary>The spread is unusually high: sell leg A, buy leg B.</summary>
|
||||
EnterShortSpread = 2,
|
||||
|
||||
/// <summary>Close both legs.</summary>
|
||||
Exit = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pair decision. Both legs are described by one object on purpose: a delta-neutral
|
||||
/// trade that fills on one side only is not half a trade, it is a directional bet
|
||||
/// nobody chose to take, so the two legs must never be able to be decided separately.
|
||||
/// </summary>
|
||||
public readonly record struct PairSignal(
|
||||
PairSignalKind Kind,
|
||||
double ZScore,
|
||||
double Beta,
|
||||
string Reason)
|
||||
{
|
||||
public static readonly PairSignal Flat = new(PairSignalKind.None, 0, 0, string.Empty);
|
||||
|
||||
public bool IsEntry => Kind is PairSignalKind.EnterLongSpread or PairSignalKind.EnterShortSpread;
|
||||
|
||||
/// <summary>Which way leg A goes. Leg B always goes the other way.</summary>
|
||||
public Side SideA => Kind switch
|
||||
{
|
||||
PairSignalKind.EnterLongSpread => Side.Buy,
|
||||
PairSignalKind.EnterShortSpread => Side.Sell,
|
||||
_ => Side.None,
|
||||
};
|
||||
|
||||
public Side SideB => SideA.Opposite();
|
||||
|
||||
public static PairSignal LongSpread(double z, double beta, string reason) =>
|
||||
new(PairSignalKind.EnterLongSpread, z, beta, reason);
|
||||
|
||||
public static PairSignal ShortSpread(double z, double beta, string reason) =>
|
||||
new(PairSignalKind.EnterShortSpread, z, beta, reason);
|
||||
|
||||
public static PairSignal Exit(double z, string reason) =>
|
||||
new(PairSignalKind.Exit, z, 0, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the engine currently holds on a pair, as the strategy needs to see it.
|
||||
/// </summary>
|
||||
public readonly record struct PairPositionView(
|
||||
double QuantityA,
|
||||
double QuantityB,
|
||||
double EntryZScore,
|
||||
int BarsHeld)
|
||||
{
|
||||
public static readonly PairPositionView Flat = new(0, 0, 0, 0);
|
||||
|
||||
public bool IsOpen => Math.Abs(QuantityA) > 1e-12 || Math.Abs(QuantityB) > 1e-12;
|
||||
|
||||
/// <summary>+1 when long the spread (long A, short B), −1 when short it, 0 when flat.</summary>
|
||||
public int Direction => QuantityA > 1e-12 ? 1 : QuantityA < -1e-12 ? -1 : 0;
|
||||
|
||||
public string Describe() => !IsOpen
|
||||
? "flat"
|
||||
: string.Create(CultureInfo.InvariantCulture,
|
||||
$"{(Direction > 0 ? "long" : "short")} spread, aperta a z={EntryZScore:F2}, da {BarsHeld} barre");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fitted relationship between two assets: what the engine measured on the last
|
||||
/// recalibration and hands to the strategy to trade on.
|
||||
/// </summary>
|
||||
public readonly record struct PairCalibration(
|
||||
double Alpha,
|
||||
double Beta,
|
||||
double PValue,
|
||||
double AdfStatistic,
|
||||
double CriticalValue5,
|
||||
double HalfLifeBars,
|
||||
bool IsCointegrated,
|
||||
DateTime FittedUtc,
|
||||
int Observations)
|
||||
{
|
||||
public static readonly PairCalibration None =
|
||||
new(0, 0, 1, double.NaN, double.NaN, double.NaN, false, DateTime.MinValue, 0);
|
||||
|
||||
public bool IsValid => Observations > 0 && double.IsFinite(Beta) && Beta != 0;
|
||||
|
||||
public TimeSpan Age => FittedUtc == DateTime.MinValue ? TimeSpan.MaxValue : DateTime.UtcNow - FittedUtc;
|
||||
|
||||
public string Describe() => !IsValid
|
||||
? "mai calibrata"
|
||||
: string.Create(CultureInfo.InvariantCulture,
|
||||
$"β={Beta:F4}, ADF={AdfStatistic:F2} vs {CriticalValue5:F2}, p≈{PValue:F4}, " +
|
||||
$"emivita {HalfLifeBars:F0} barre, {(IsCointegrated ? "cointegrata" : "NON cointegrata")}");
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Indicators;
|
||||
|
||||
namespace Encelado.Core.Strategies.Pairs;
|
||||
|
||||
/// <summary>
|
||||
/// Statistical arbitrage on a cointegrated pair: trade the gap between two assets, not
|
||||
/// the assets.
|
||||
/// <para>
|
||||
/// The position is long one leg and short the other in the ratio the cointegration fit
|
||||
/// produced, so the book carries no view on whether crypto goes up or down — it carries
|
||||
/// a view on whether these two instruments, which have historically kept a bounded
|
||||
/// distance from each other, will close the distance they have just opened. That is the
|
||||
/// whole reason this can run from a domestic connection: a delta-neutral spread on a
|
||||
/// five-minute bar does not care about fifty milliseconds of latency, where a
|
||||
/// directional intraday model would be picked off by anyone co-located.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Four numbers govern it. Enter when the spread is <c>entryZ</c> standard deviations
|
||||
/// from its rolling mean; take profit when it comes back inside <c>exitZ</c>; cut when it
|
||||
/// reaches <c>stopZ</c>, which is not a price stop but a statement that the relationship
|
||||
/// the trade was premised on has stopped holding; and refuse to trade at all unless the
|
||||
/// last cointegration test passed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The stop is the part that is easy to get wrong. On a mean-reverting spread, a
|
||||
/// deviation getting <i>worse</i> looks like a better entry, and averaging into it works
|
||||
/// until the one time the relationship has genuinely broken — at which point the position
|
||||
/// is unbounded and hedged against nothing. Three and a half standard deviations is the
|
||||
/// line where "unusual" stops being evidence for the trade and starts being evidence
|
||||
/// against the model.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class StatArbStrategy
|
||||
{
|
||||
private readonly RollingZScore _zScore;
|
||||
private readonly int _window;
|
||||
|
||||
private PairCalibration _calibration = PairCalibration.None;
|
||||
private double _spread;
|
||||
private double _z;
|
||||
private double _lastCloseA;
|
||||
private double _lastCloseB;
|
||||
private int _barsSeen;
|
||||
|
||||
public StatArbStrategy(StrategyParameters p)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(p);
|
||||
|
||||
_window = p.GetInt("zWindow", 100);
|
||||
if (_window < 20)
|
||||
{
|
||||
throw new ArgumentException("zWindow must be at least 20 bars.", nameof(p));
|
||||
}
|
||||
|
||||
EntryZ = p.Get("entryZ", 2.0);
|
||||
ExitZ = p.Get("exitZ", 0.2);
|
||||
StopZ = p.Get("stopZ", 3.5);
|
||||
MaxPValue = p.Get("maxPValue", 0.05);
|
||||
MinHalfLifeBars = p.Get("minHalfLife", 2);
|
||||
MaxHalfLifeBars = p.Get("maxHalfLife", 400);
|
||||
MaxBarsInTrade = p.GetInt("maxBarsInTrade", 0);
|
||||
RequireCointegration = p.GetBool("requireCointegration", true);
|
||||
|
||||
if (EntryZ <= ExitZ)
|
||||
{
|
||||
throw new ArgumentException("entryZ must be above exitZ, otherwise every entry exits immediately.", nameof(p));
|
||||
}
|
||||
|
||||
if (StopZ <= EntryZ)
|
||||
{
|
||||
throw new ArgumentException("stopZ must be above entryZ, otherwise every entry stops out immediately.", nameof(p));
|
||||
}
|
||||
|
||||
_zScore = new RollingZScore(_window);
|
||||
}
|
||||
|
||||
public string Name => "statarb";
|
||||
|
||||
/// <summary>How far from the mean the spread must go before a position is opened.</summary>
|
||||
public double EntryZ { get; }
|
||||
|
||||
/// <summary>How close to the mean it must come back before the position is closed at a profit.</summary>
|
||||
public double ExitZ { get; }
|
||||
|
||||
/// <summary>Where the deviation stops being an opportunity and becomes a broken model.</summary>
|
||||
public double StopZ { get; }
|
||||
|
||||
/// <summary>The cointegration p-value a pair must beat to be tradeable.</summary>
|
||||
public double MaxPValue { get; }
|
||||
|
||||
public double MinHalfLifeBars { get; }
|
||||
|
||||
public double MaxHalfLifeBars { get; }
|
||||
|
||||
/// <summary>Hard time stop in bars. 0 disables it.</summary>
|
||||
public int MaxBarsInTrade { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a failing cointegration test blocks trading. Off is for research only:
|
||||
/// a spread that is not cointegrated has no mean to revert to.
|
||||
/// </summary>
|
||||
public bool RequireCointegration { get; }
|
||||
|
||||
/// <summary>Bars needed before the z-score is meaningful.</summary>
|
||||
public int WarmupBars => _window + 2;
|
||||
|
||||
public bool IsReady => _zScore.IsReady && _calibration.IsValid;
|
||||
|
||||
public int BarsSeen => _barsSeen;
|
||||
|
||||
public PairCalibration Calibration => _calibration;
|
||||
|
||||
public double ZScore => _z;
|
||||
|
||||
public double Spread => _spread;
|
||||
|
||||
public double SpreadMean => _zScore.IsReady ? _zScore.Mean : double.NaN;
|
||||
|
||||
public double SpreadStdDev => _zScore.IsReady ? _zScore.StandardDeviation : double.NaN;
|
||||
|
||||
/// <summary>
|
||||
/// Installs a fresh cointegration fit.
|
||||
/// <para>
|
||||
/// The rolling z-score is <b>not</b> reset when β changes materially — it is, when it
|
||||
/// does. A spread computed with a new β is a different series, and mixing its values
|
||||
/// into a mean built from the old one produces a z-score that describes neither. The
|
||||
/// cost is a warm-up after every recalibration, which is why "materially" has a
|
||||
/// threshold rather than being any change at all.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void Recalibrate(PairCalibration calibration)
|
||||
{
|
||||
bool betaMoved = _calibration.IsValid && calibration.IsValid &&
|
||||
Math.Abs(calibration.Beta - _calibration.Beta) >
|
||||
Math.Max(1e-4, Math.Abs(_calibration.Beta) * 0.05);
|
||||
|
||||
_calibration = calibration;
|
||||
|
||||
if (betaMoved)
|
||||
{
|
||||
_zScore.Reset();
|
||||
_barsSeen = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds one closed bar of each leg and returns the resulting decision.
|
||||
/// <para>
|
||||
/// Both closes must come from the <i>same</i> bar. A spread built from leg A at
|
||||
/// 10:05 and leg B at 10:00 measures the five minutes between them as if it were a
|
||||
/// divergence, which is the single easiest way to manufacture a signal that is not
|
||||
/// there — the engine aligns them before calling this.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public PairSignal OnBar(double closeA, double closeB, in PairPositionView position)
|
||||
{
|
||||
if (closeA <= 0 || closeB <= 0 || !_calibration.IsValid)
|
||||
{
|
||||
return PairSignal.Flat;
|
||||
}
|
||||
|
||||
_lastCloseA = closeA;
|
||||
_lastCloseB = closeB;
|
||||
|
||||
_spread = Math.Log(closeA) - (_calibration.Beta * Math.Log(closeB)) - _calibration.Alpha;
|
||||
_z = _zScore.Update(_spread);
|
||||
_barsSeen++;
|
||||
|
||||
if (!_zScore.IsReady)
|
||||
{
|
||||
return PairSignal.Flat;
|
||||
}
|
||||
|
||||
return position.IsOpen ? EvaluateOpen(position) : EvaluateFlat();
|
||||
}
|
||||
|
||||
private PairSignal EvaluateOpen(in PairPositionView position)
|
||||
{
|
||||
double magnitude = Math.Abs(_z);
|
||||
|
||||
// The statistical stop comes first. Everything below it assumes the relationship
|
||||
// still holds, and this is the branch that says it does not.
|
||||
if (magnitude >= StopZ)
|
||||
{
|
||||
return PairSignal.Exit(_z, string.Create(CultureInfo.InvariantCulture,
|
||||
$"stop statistico: z={_z:F2} oltre {StopZ:F1} — la cointegrazione si è rotta"));
|
||||
}
|
||||
|
||||
if (magnitude <= ExitZ)
|
||||
{
|
||||
return PairSignal.Exit(_z, string.Create(CultureInfo.InvariantCulture,
|
||||
$"spread rientrato: z={_z:F2} dentro ±{ExitZ:F2}"));
|
||||
}
|
||||
|
||||
// A deviation that flips sign past the entry threshold has not reverted, it has
|
||||
// overshot through the mean and out the other side. Holding through that is
|
||||
// holding a position whose thesis has already been paid out.
|
||||
if (position.Direction > 0 && _z >= EntryZ)
|
||||
{
|
||||
return PairSignal.Exit(_z, string.Create(CultureInfo.InvariantCulture,
|
||||
$"lo spread ha attraversato la media fino a z={_z:F2}: la tesi è già stata incassata"));
|
||||
}
|
||||
|
||||
if (position.Direction < 0 && _z <= -EntryZ)
|
||||
{
|
||||
return PairSignal.Exit(_z, string.Create(CultureInfo.InvariantCulture,
|
||||
$"lo spread ha attraversato la media fino a z={_z:F2}: la tesi è già stata incassata"));
|
||||
}
|
||||
|
||||
if (RequireCointegration && _calibration.PValue > MaxPValue)
|
||||
{
|
||||
return PairSignal.Exit(_z, string.Create(CultureInfo.InvariantCulture,
|
||||
$"l'ultima ricalibrazione ha respinto la coppia (p≈{_calibration.PValue:F3}): chiudo"));
|
||||
}
|
||||
|
||||
if (MaxBarsInTrade > 0 && position.BarsHeld >= MaxBarsInTrade)
|
||||
{
|
||||
return PairSignal.Exit(_z, string.Create(CultureInfo.InvariantCulture,
|
||||
$"stop temporale dopo {position.BarsHeld} barre senza rientro"));
|
||||
}
|
||||
|
||||
return PairSignal.Flat;
|
||||
}
|
||||
|
||||
private PairSignal EvaluateFlat()
|
||||
{
|
||||
if (!TradeableNow(out _))
|
||||
{
|
||||
return PairSignal.Flat;
|
||||
}
|
||||
|
||||
if (_z >= EntryZ)
|
||||
{
|
||||
return PairSignal.ShortSpread(_z, _calibration.Beta, string.Create(CultureInfo.InvariantCulture,
|
||||
$"spread alto: z={_z:F2} oltre +{EntryZ:F1} — vendo A, compro B a β={_calibration.Beta:F4}"));
|
||||
}
|
||||
|
||||
if (_z <= -EntryZ)
|
||||
{
|
||||
return PairSignal.LongSpread(_z, _calibration.Beta, string.Create(CultureInfo.InvariantCulture,
|
||||
$"spread basso: z={_z:F2} oltre −{EntryZ:F1} — compro A, vendo B a β={_calibration.Beta:F4}"));
|
||||
}
|
||||
|
||||
return PairSignal.Flat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a fresh position may be opened at all, and why not when it may not.
|
||||
/// <para>
|
||||
/// This is the single source of truth for that question, read both by the decision
|
||||
/// path and by <see cref="Explain"/>. They used to be written twice, and a strategy
|
||||
/// whose explanation and whose behaviour are computed separately will eventually say
|
||||
/// one thing and do another — which is exactly what "it said it would enter and then
|
||||
/// did nothing" looks like from outside.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool TradeableNow(out string blocker)
|
||||
{
|
||||
if (!_calibration.IsValid)
|
||||
{
|
||||
blocker = "la coppia non è ancora stata calibrata";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_zScore.IsReady)
|
||||
{
|
||||
blocker = string.Create(CultureInfo.InvariantCulture,
|
||||
$"riscaldamento: {_barsSeen} barre su {_window} per la finestra dello z-score");
|
||||
return false;
|
||||
}
|
||||
|
||||
// One gate, not two. The p-value is the whole test: at the default of 0.05 this
|
||||
// is exactly the 5% critical value the ADF table gives, and above it the
|
||||
// threshold is the operator's to loosen — which is a decision worth being able
|
||||
// to make, because on a real basket the strictly-cointegrated fraction of the
|
||||
// time is small enough to leave the bot idle for weeks.
|
||||
if (RequireCointegration && _calibration.PValue > MaxPValue)
|
||||
{
|
||||
blocker = string.Create(CultureInfo.InvariantCulture,
|
||||
$"la coppia non passa il test di cointegrazione: p≈{_calibration.PValue:F3} " +
|
||||
$"sopra la soglia {MaxPValue:F3} (ADF {_calibration.AdfStatistic:F2} contro " +
|
||||
$"{_calibration.CriticalValue5:F2} al 5%)");
|
||||
return false;
|
||||
}
|
||||
|
||||
double halfLife = _calibration.HalfLifeBars;
|
||||
if (double.IsFinite(halfLife))
|
||||
{
|
||||
if (halfLife < MinHalfLifeBars)
|
||||
{
|
||||
blocker = string.Create(CultureInfo.InvariantCulture,
|
||||
$"emivita {halfLife:F1} barre sotto il minimo {MinHalfLifeBars:F0}: " +
|
||||
$"lo spread rientra troppo in fretta per ripagare le commissioni");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (halfLife > MaxHalfLifeBars)
|
||||
{
|
||||
blocker = string.Create(CultureInfo.InvariantCulture,
|
||||
$"emivita {halfLife:F0} barre sopra il massimo {MaxHalfLifeBars:F0}: " +
|
||||
$"il capitale resterebbe impegnato più a lungo di quanto duri la relazione");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (RequireCointegration)
|
||||
{
|
||||
blocker = "lo spread non ha un'emivita finita: non torna verso la media";
|
||||
return false;
|
||||
}
|
||||
|
||||
blocker = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sentence saying what the strategy would do right now and what would have to
|
||||
/// change for it to do something else. Reads state, never changes it.
|
||||
/// </summary>
|
||||
public string Explain(in PairPositionView position)
|
||||
{
|
||||
if (!_calibration.IsValid)
|
||||
{
|
||||
return "in attesa della prima calibrazione della coppia";
|
||||
}
|
||||
|
||||
if (position.IsOpen)
|
||||
{
|
||||
double toTarget = Math.Abs(_z) - ExitZ;
|
||||
double toStop = StopZ - Math.Abs(_z);
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"IN POSIZIONE — {position.Describe()}. z ora {_z:F2}: " +
|
||||
$"chiudo in guadagno sotto ±{ExitZ:F2} (mancano {toTarget:F2}), " +
|
||||
$"chiudo in perdita oltre ±{StopZ:F1} (mancano {toStop:F2}).");
|
||||
}
|
||||
|
||||
if (!TradeableNow(out string blocker))
|
||||
{
|
||||
return $"FERMO — {blocker}.";
|
||||
}
|
||||
|
||||
if (Math.Abs(_z) >= EntryZ)
|
||||
{
|
||||
string direction = _z > 0
|
||||
? "VENDO A e COMPRO B"
|
||||
: "COMPRO A e VENDO B";
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"PRONTO — z={_z:F2} ha superato la soglia di ±{EntryZ:F1}: {direction} " +
|
||||
$"al peso β={_calibration.Beta:F4}. L'ordine parte adesso.");
|
||||
}
|
||||
|
||||
double needed = EntryZ - Math.Abs(_z);
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"FERMO — z={_z:F2}, dentro la soglia di ±{EntryZ:F1}. " +
|
||||
$"Servono {needed:F2} deviazioni standard in più, in un verso o nell'altro.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<StrategyMetric> Diagnostics =>
|
||||
[
|
||||
new("z-score", double.IsFinite(_z) ? _z : 0, "F2"),
|
||||
new("spread", double.IsFinite(_spread) ? _spread : 0, "F4"),
|
||||
new("beta", _calibration.Beta, "F4"),
|
||||
new("p-value", _calibration.PValue, "F4"),
|
||||
new("emivita", double.IsFinite(_calibration.HalfLifeBars) ? _calibration.HalfLifeBars : 0, "F0"),
|
||||
new("prezzo A", _lastCloseA, "F2"),
|
||||
new("prezzo B", _lastCloseB, "F2"),
|
||||
];
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_zScore.Reset();
|
||||
_spread = 0;
|
||||
_z = 0;
|
||||
_barsSeen = 0;
|
||||
_lastCloseA = 0;
|
||||
_lastCloseB = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using Encelado.Core.Indicators;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
/// <summary>
|
||||
/// Shared plumbing for the built-in strategies: ATR tracking, volatility gating and
|
||||
/// ATR-derived bracket levels. Concrete strategies only implement
|
||||
/// <see cref="Evaluate"/>.
|
||||
/// </summary>
|
||||
public abstract class StrategyBase : IStrategy
|
||||
{
|
||||
protected StrategyBase(StrategyParameters p, int defaultAtrPeriod = 14)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(p);
|
||||
Atr = new Atr(p.GetInt("atrPeriod", defaultAtrPeriod));
|
||||
AllowShort = p.GetBool("allowShort", false);
|
||||
AtrStopMultiple = p.Get("atrStopMult", 2.0);
|
||||
RewardRisk = p.Get("rewardRisk", 2.0);
|
||||
MinAtrPct = p.Get("minAtrPct", 0.0);
|
||||
MaxBarsInTrade = p.GetInt("maxBarsInTrade", 0);
|
||||
}
|
||||
|
||||
protected Atr Atr { get; }
|
||||
|
||||
/// <summary>Short entries are opt-in: they need a marginable account and locate availability.</summary>
|
||||
protected bool AllowShort { get; }
|
||||
|
||||
protected double AtrStopMultiple { get; }
|
||||
|
||||
protected double RewardRisk { get; }
|
||||
|
||||
/// <summary>Minimum ATR/price ratio required to trade. Filters out dead, un-tradeable tape.</summary>
|
||||
protected double MinAtrPct { get; }
|
||||
|
||||
/// <summary>Hard time stop in bars. 0 disables it.</summary>
|
||||
protected int MaxBarsInTrade { get; }
|
||||
|
||||
public abstract string Name { get; }
|
||||
|
||||
public abstract int WarmupBars { get; }
|
||||
|
||||
public abstract bool IsReady { get; }
|
||||
|
||||
public Signal OnBar(in Bar bar, in PositionView position)
|
||||
{
|
||||
Atr.Update(bar);
|
||||
|
||||
// Evaluate runs on every bar, including during warm-up: the concrete strategies
|
||||
// advance their indicators inside it, so skipping the call would leave them
|
||||
// permanently un-ready. Only the resulting *signal* is suppressed until ready.
|
||||
Signal signal = Evaluate(bar, position);
|
||||
|
||||
if (MaxBarsInTrade > 0 && !position.IsFlat && position.BarsHeld >= MaxBarsInTrade)
|
||||
{
|
||||
return Signal.Exit($"time stop after {position.BarsHeld} bars");
|
||||
}
|
||||
|
||||
return IsReady ? signal : Signal.Flat;
|
||||
}
|
||||
|
||||
public virtual Signal OnQuote(in Quote quote, in PositionView position) => Signal.Flat;
|
||||
|
||||
/// <summary>Internal readings for the dashboard. Overridden by strategies worth introspecting.</summary>
|
||||
public virtual IReadOnlyList<StrategyMetric> Diagnostics => [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual string Explain(double price, in PositionView position) => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Called once per closed bar. Implementations must advance their own indicators
|
||||
/// here — this is invoked during warm-up too, and the returned signal is discarded
|
||||
/// until <see cref="IsReady"/> is true.
|
||||
/// </summary>
|
||||
protected abstract Signal Evaluate(in Bar bar, in PositionView position);
|
||||
|
||||
/// <summary>ATR-derived protective stop and profit target for a fresh entry.</summary>
|
||||
protected (double Stop, double Target) Brackets(double entryPrice, Side side)
|
||||
{
|
||||
if (!Atr.IsReady || Atr.Value <= 0 || AtrStopMultiple <= 0)
|
||||
{
|
||||
return (double.NaN, double.NaN);
|
||||
}
|
||||
|
||||
double risk = Atr.Value * AtrStopMultiple;
|
||||
double reward = risk * (RewardRisk > 0 ? RewardRisk : 0);
|
||||
|
||||
return side == Side.Buy
|
||||
? (entryPrice - risk, reward > 0 ? entryPrice + reward : double.NaN)
|
||||
: (entryPrice + risk, reward > 0 ? entryPrice - reward : double.NaN);
|
||||
}
|
||||
|
||||
protected bool VolatilityOk(double price) =>
|
||||
MinAtrPct <= 0 || (Atr.IsReady && price > 0 && Atr.Value / price >= MinAtrPct);
|
||||
|
||||
public virtual void Reset() => Atr.Reset();
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the trading strategy named in the configuration.
|
||||
/// <para>
|
||||
/// There is exactly one. Seven others were written and backtested against thirteen
|
||||
/// years of BTC data; every one of them lost money after realistic costs, collapsed out
|
||||
/// of sample, or — in the case of the multi-factor model this one replaced — made money
|
||||
/// while returning a sixth of what simply holding the asset returned. Keeping a losing
|
||||
/// strategy available "just in case" is how it ends up in production, so they were
|
||||
/// deleted rather than disabled.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class StrategyFactory
|
||||
{
|
||||
public const string Default = "trend-filter";
|
||||
|
||||
public static readonly string[] Available = [Default];
|
||||
|
||||
/// <summary>Creates a new, independent strategy instance. One per symbol.</summary>
|
||||
public static IStrategy Create(string name, StrategyParameters? parameters = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
StrategyParameters p = parameters ?? StrategyParameters.Empty;
|
||||
|
||||
return Normalize(name) switch
|
||||
{
|
||||
"trend-filter" or "trendfilter" or "trend" or "filter" => new TrendFilterStrategy(p),
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown strategy '{name}'. The only supported strategy is '{Default}'.", nameof(name)),
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsKnown(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = Create(name);
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalize(string name) =>
|
||||
name.Trim().ToLowerInvariant().Replace('_', '-');
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
/// <summary>A named internal reading a strategy exposes for the dashboard and the logs.</summary>
|
||||
public readonly record struct StrategyMetric(string Name, double Value, string Format = "F2");
|
||||
|
||||
/// <summary>
|
||||
/// Loosely typed parameter bag: config files carry <c>{"entryZ": 2.0, "exitZ": 0.2}</c>
|
||||
/// and the strategy reads what it needs with a fallback. Keeps the config format open
|
||||
/// without reflection-based binding, which also keeps the whole path trim- and AOT-safe.
|
||||
/// </summary>
|
||||
public sealed class StrategyParameters
|
||||
{
|
||||
private readonly Dictionary<string, double> _values;
|
||||
|
||||
public StrategyParameters(IReadOnlyDictionary<string, double>? values = null) =>
|
||||
_values = values is null
|
||||
? new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, double>(values, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static StrategyParameters Empty => new();
|
||||
|
||||
public double Get(string key, double fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? v : fallback;
|
||||
|
||||
public int GetInt(string key, int fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? (int)Math.Round(v) : fallback;
|
||||
|
||||
public bool GetBool(string key, bool fallback) =>
|
||||
_values.TryGetValue(key, out double v) ? v != 0 : fallback;
|
||||
|
||||
public StrategyParameters Set(string key, double value)
|
||||
{
|
||||
_values[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, double> Values => _values;
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Indicators;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
|
||||
namespace Encelado.Core.Strategies;
|
||||
|
||||
/// <summary>
|
||||
/// Long while price holds above a medium-term moving average, flat below it. Nothing
|
||||
/// more.
|
||||
/// <para>
|
||||
/// The simplicity is the point, and it was arrived at by measurement rather than taste.
|
||||
/// Without leverage — Alpaca crypto is spot only — a long/flat rule can never hold more
|
||||
/// than the market holds, so every day spent out is a day of compounding surrendered.
|
||||
/// Against an asset that rose twenty-two thousandfold, such a rule beats buying and
|
||||
/// holding only if the days it sits out are disproportionately the bad ones. It cannot
|
||||
/// out-earn the market; it can only out-avoid it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That reframes what a good signal is. The model this replaced blended trend with mean
|
||||
/// reversion, gated entries on an efficiency ratio and trailed a stop at five ATR: each
|
||||
/// piece defensible, and together they cut time in market to the point of returning
|
||||
/// 20% a year where simply holding returned 115%. Cleverness that shortens the holding
|
||||
/// period is not free on an asset like this — it is the most expensive thing in the
|
||||
/// book.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The band around the average is hysteresis, not a filter: entering 2% above and
|
||||
/// leaving 2% below stops a price sitting on the line from generating a trade every
|
||||
/// other day. On thirteen years of BTC it cuts the number of switches by more than half
|
||||
/// while leaving the return where it was.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TrendFilterStrategy : StrategyBase
|
||||
{
|
||||
private readonly Sma _average;
|
||||
private readonly RealizedVolatility _volatility;
|
||||
private readonly CumulativeVolumeDelta _cvd;
|
||||
|
||||
private readonly double _band;
|
||||
private readonly double _cvdThreshold;
|
||||
private readonly double _stopPct;
|
||||
|
||||
private double _lastClose;
|
||||
private double _distance;
|
||||
|
||||
public TrendFilterStrategy(StrategyParameters p)
|
||||
: base(p, defaultAtrPeriod: 14)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(p);
|
||||
|
||||
int period = p.GetInt("period", 100);
|
||||
if (period < 5)
|
||||
{
|
||||
throw new ArgumentException("period must be at least 5 bars.", nameof(p));
|
||||
}
|
||||
|
||||
_band = p.Get("band", 0.02);
|
||||
if (_band is < 0 or > 0.5)
|
||||
{
|
||||
throw new ArgumentException("band must be a fraction in [0, 0.5].", nameof(p));
|
||||
}
|
||||
|
||||
// Off by default. Adding the order-flow gate to *this* strategy makes it worse,
|
||||
// monotonically: on nine years of Binance data with the aggressor breakdown the
|
||||
// Calmar fell from 0.71 to 0.66 to 0.64 as the threshold rose. The filter earned
|
||||
// its place in the previous model, which traded rarely and could afford to wait
|
||||
// for confirmation; here every bar spent waiting is a bar not compounding. Left
|
||||
// configurable because the reading is still worth logging, and because a future
|
||||
// dataset could say otherwise.
|
||||
_cvdThreshold = p.Get("cvdThreshold", 0);
|
||||
|
||||
// A backstop for a gap, not the risk control. The exit that matters is the
|
||||
// average giving way; a tight stop here would sell the position and then wait
|
||||
// for a fresh crossing to buy it back, which is how the previous model kept
|
||||
// realising drawdowns it would otherwise have ridden through.
|
||||
_stopPct = p.Get("stopPct", 0.35);
|
||||
|
||||
_average = new Sma(period);
|
||||
_volatility = new RealizedVolatility(p.GetInt("volPeriod", 30), p.GetInt("barsPerYear", 365));
|
||||
_cvd = new CumulativeVolumeDelta(p.GetInt("cvdPeriod", 10), p.GetInt("cvdNormPeriod", 60));
|
||||
}
|
||||
|
||||
public override string Name => "trend-filter";
|
||||
|
||||
public override int WarmupBars => _average.Period + 2;
|
||||
|
||||
public override bool IsReady => _average.IsReady;
|
||||
|
||||
/// <summary>Where price sits relative to the average, as a fraction. Positive is above.</summary>
|
||||
public double Distance => _distance;
|
||||
|
||||
public double Average => _average.IsReady ? _average.Value : double.NaN;
|
||||
|
||||
public double Volatility => _volatility.IsReady ? _volatility.Value : double.NaN;
|
||||
|
||||
public double CvdScore => _cvd.IsReady ? _cvd.Value : 0;
|
||||
|
||||
protected override Signal Evaluate(in Bar bar, in PositionView position)
|
||||
{
|
||||
_average.Update(bar.Close);
|
||||
_volatility.Update(bar.Close);
|
||||
_cvd.Update(bar);
|
||||
_lastClose = bar.Close;
|
||||
|
||||
if (!_average.IsReady || _average.Value <= 0)
|
||||
{
|
||||
return Signal.Flat;
|
||||
}
|
||||
|
||||
_distance = (bar.Close - _average.Value) / _average.Value;
|
||||
|
||||
if (!position.IsFlat)
|
||||
{
|
||||
// Only one thing closes this position: price losing the average by more than
|
||||
// the band. No trailing stop, no profit target, no time stop — each of those
|
||||
// ends the trade during the moves the whole strategy exists to capture.
|
||||
return _distance <= -_band
|
||||
? Signal.Exit($"prezzo sotto la media di {-_distance:P1}")
|
||||
: Signal.Flat;
|
||||
}
|
||||
|
||||
if (_distance < _band)
|
||||
{
|
||||
return Signal.Flat;
|
||||
}
|
||||
|
||||
if (_cvdThreshold > 0 && _cvd.HasFlowData && CvdScore < _cvdThreshold)
|
||||
{
|
||||
return Signal.Flat;
|
||||
}
|
||||
|
||||
return Signal.EnterLong(
|
||||
$"prezzo sopra la media di {_distance:P1}",
|
||||
stopPrice: bar.Close * (1 - _stopPct),
|
||||
targetPrice: double.NaN,
|
||||
strength: 1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the model would do at <paramref name="price"/> right now, and how far the
|
||||
/// price is from making it do something else. Reads state, never changes it.
|
||||
/// </summary>
|
||||
public override string Explain(double price, in PositionView position)
|
||||
{
|
||||
if (!_average.IsReady)
|
||||
{
|
||||
return $"warm-up: mancano ancora barre alla media a {_average.Period} giorni";
|
||||
}
|
||||
|
||||
double average = _average.Value;
|
||||
if (average <= 0 || price <= 0)
|
||||
{
|
||||
return "in attesa di un prezzo valido";
|
||||
}
|
||||
|
||||
double distance = (price - average) / average;
|
||||
|
||||
if (!position.IsFlat)
|
||||
{
|
||||
double exitAt = average * (1 - _band);
|
||||
double room = (price - exitAt) / price;
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"IN POSIZIONE — prezzo {price:N0} è {distance:+0.00%;-0.00%} dalla media {average:N0}. " +
|
||||
$"Esco se scende sotto {exitAt:N0} (−{room:P1} da qui). Nessun target: si lascia correre.");
|
||||
}
|
||||
|
||||
if (distance >= _band)
|
||||
{
|
||||
if (_cvdThreshold > 0 && _cvd.HasFlowData && CvdScore < _cvdThreshold)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"FERMO — prezzo sopra la soglia, ma il filtro di order flow è chiuso: " +
|
||||
$"CVD {CvdScore:F2} sotto la soglia {_cvdThreshold:F2}.");
|
||||
}
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"PRONTO A COMPRARE — prezzo {price:N0} è {distance:+0.00%} sopra la media {average:N0}, " +
|
||||
$"oltre la soglia del {_band:P0}. Entro alla prossima barra chiusa.");
|
||||
}
|
||||
|
||||
double entryAt = average * (1 + _band);
|
||||
double needed = (entryAt - price) / price;
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"FERMO — prezzo {price:N0} è {distance:+0.00%;-0.00%} dalla media {average:N0}. " +
|
||||
$"Per comprare serve che superi {entryAt:N0}, cioè {needed:+0.00%} da qui.");
|
||||
}
|
||||
|
||||
public override IReadOnlyList<StrategyMetric> Diagnostics =>
|
||||
[
|
||||
new("distanza", double.IsFinite(_distance) ? _distance : 0, "P1"),
|
||||
new("media", _average.IsReady ? _average.Value : 0, "F0"),
|
||||
new("prezzo", _lastClose, "F0"),
|
||||
new("volatilità", _volatility.IsReady ? _volatility.Value : 0, "P1"),
|
||||
new("cvd", CvdScore, "F2"),
|
||||
];
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
base.Reset();
|
||||
_average.Reset();
|
||||
_volatility.Reset();
|
||||
_cvd.Reset();
|
||||
_distance = 0;
|
||||
_lastClose = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Core.Backtest;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Folding a fine-grained file into coarser bars. This is what makes a 342 MB minute
|
||||
/// file usable, so the arithmetic has to be exactly right: an open taken from the wrong
|
||||
/// row silently shifts every signal derived from it.
|
||||
/// </summary>
|
||||
public class CsvAggregationTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _files = [];
|
||||
|
||||
private string Write(string content)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-agg-{Guid.NewGuid():N}.csv");
|
||||
File.WriteAllText(path, content);
|
||||
_files.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string f in _files)
|
||||
{
|
||||
File.Delete(f);
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>Minute rows starting at a known midnight, so bucket edges are obvious.</summary>
|
||||
private static string Minutes(params (int Minute, double O, double H, double L, double C, double V)[] rows)
|
||||
{
|
||||
// 1704067200 = 2024-01-01 00:00:00 UTC
|
||||
System.Text.StringBuilder sb = new("timestamp,open,high,low,close,volume\n");
|
||||
foreach ((int minute, double o, double h, double l, double c, double v) in rows)
|
||||
{
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"{1704067200 + (minute * 60)},{o},{h},{l},{c},{v}\n");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoldsMinutesIntoOneDailyBar()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 100, 105, 99, 104, 10),
|
||||
(1, 104, 110, 103, 108, 20),
|
||||
(2, 108, 109, 95, 97, 30)));
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.Single(bars);
|
||||
Assert.Equal(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), bars[0].TimeUtc);
|
||||
Assert.Equal(100, bars[0].Open); // first row's open
|
||||
Assert.Equal(110, bars[0].High); // highest high anywhere in the bucket
|
||||
Assert.Equal(95, bars[0].Low); // lowest low
|
||||
Assert.Equal(97, bars[0].Close); // last row's close
|
||||
Assert.Equal(60, bars[0].Volume); // summed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitsAcrossBucketBoundaries()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 100, 100, 100, 100, 1),
|
||||
(1439, 200, 200, 200, 200, 1), // last minute of day one
|
||||
(1440, 300, 300, 300, 300, 1))); // first minute of day two
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.Equal(2, bars.Count);
|
||||
Assert.Equal(100, bars[0].Open);
|
||||
Assert.Equal(200, bars[0].Close);
|
||||
Assert.Equal(300, bars[1].Open);
|
||||
Assert.Equal(new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc), bars[1].TimeUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HonoursHourlyAndFourHourlyWidths()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 100, 100, 100, 100, 1),
|
||||
(59, 110, 110, 110, 110, 1),
|
||||
(60, 120, 120, 120, 120, 1),
|
||||
(239, 130, 130, 130, 130, 1)));
|
||||
|
||||
Assert.Equal(3, CsvBarSource.LoadAggregated(path, TimeSpan.FromHours(1)).Count);
|
||||
Assert.Single(CsvBarSource.LoadAggregated(path, TimeSpan.FromHours(4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfOrderRowsStillProduceTheRightOpenAndClose()
|
||||
{
|
||||
// Keyed by bucket rather than assumed sequential, so a file that jumps backwards
|
||||
// cannot corrupt the bar that happens to be open at the time.
|
||||
string path = Write(Minutes(
|
||||
(2, 108, 109, 95, 97, 30),
|
||||
(0, 100, 105, 99, 104, 10),
|
||||
(1, 104, 110, 103, 108, 20)));
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.Single(bars);
|
||||
Assert.Equal(100, bars[0].Open);
|
||||
Assert.Equal(97, bars[0].Close);
|
||||
Assert.Equal(110, bars[0].High);
|
||||
Assert.Equal(95, bars[0].Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarriesTheTakerBreakdownThroughSoOrderFlowSurvives()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
timestamp,open,high,low,close,volume,taker_buy_base_asset_volume
|
||||
1704067200,100,100,100,100,100,70
|
||||
1704067260,100,100,100,100,100,10
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
Assert.Single(bars);
|
||||
Assert.Equal(200, bars[0].Volume);
|
||||
Assert.Equal(80, bars[0].TakerBuyVolume);
|
||||
Assert.True(bars[0].HasOrderFlow);
|
||||
|
||||
// delta = 2*80 - 200
|
||||
Assert.Equal(-40, bars[0].Delta, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputesAVolumeWeightedPrice()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 10, 10, 10, 10, 100),
|
||||
(1, 30, 30, 30, 30, 300)));
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.LoadAggregated(path, TimeSpan.FromDays(1));
|
||||
|
||||
// (10*100 + 30*300) / 400
|
||||
Assert.Equal(25, bars[0].Vwap, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AggregatingIsEquivalentToLoadingWhenTheBucketMatchesTheSource()
|
||||
{
|
||||
string path = Write(Minutes(
|
||||
(0, 100, 105, 99, 104, 10),
|
||||
(1, 104, 110, 103, 108, 20)));
|
||||
|
||||
IReadOnlyList<Bar> raw = CsvBarSource.Load(path);
|
||||
IReadOnlyList<Bar> folded = CsvBarSource.LoadAggregated(path, TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.Equal(raw.Count, folded.Count);
|
||||
for (int i = 0; i < raw.Count; i++)
|
||||
{
|
||||
Assert.Equal(raw[i].TimeUtc, folded[i].TimeUtc);
|
||||
Assert.Equal(raw[i].Open, folded[i].Open, 9);
|
||||
Assert.Equal(raw[i].High, folded[i].High, 9);
|
||||
Assert.Equal(raw[i].Low, folded[i].Low, 9);
|
||||
Assert.Equal(raw[i].Close, folded[i].Close, 9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsANonPositiveWidth()
|
||||
{
|
||||
string path = Write(Minutes((0, 1, 1, 1, 1, 1)));
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
CsvBarSource.LoadAggregated(path, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsAMissingFileClearly() =>
|
||||
Assert.Throws<FileNotFoundException>(() =>
|
||||
CsvBarSource.LoadAggregated(
|
||||
Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.csv"),
|
||||
TimeSpan.FromDays(1)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Editing the configuration from the settings screen. The point of these is that the
|
||||
/// file survives: it carries the documentation for every tuned number, and a writer
|
||||
/// that reformatted or dropped keys would destroy it.
|
||||
/// </summary>
|
||||
public class ConfigWriterTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _files = [];
|
||||
|
||||
private string Write(string content)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-cfg-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, content);
|
||||
_files.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string f in _files)
|
||||
{
|
||||
File.Delete(f);
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetsTheLogDirectory()
|
||||
{
|
||||
string path = Write("""{"logging":{"directory":"logs","level":"info"}}""");
|
||||
|
||||
ConfigWriter.SetLogDirectory(path, @"D:\encelado-logs");
|
||||
|
||||
Assert.Contains(@"D:\\encelado-logs", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeepsEverythingElseIncludingTheInlineDocumentation()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
{
|
||||
"_comment": "questa riga documenta la configurazione",
|
||||
"alpaca": { "paper": true, "dataFeed": "iex" },
|
||||
"logging": {
|
||||
"_level": "spiegazione del livello",
|
||||
"level": "debug",
|
||||
"directory": "logs",
|
||||
"maxFiles": 10
|
||||
},
|
||||
"symbols": [ { "symbol": "BTC/USD", "parameters": { "fast": 50 } } ]
|
||||
}
|
||||
""");
|
||||
|
||||
ConfigWriter.SetLogDirectory(path, "altrove");
|
||||
|
||||
string after = File.ReadAllText(path);
|
||||
Assert.Contains("_comment", after, StringComparison.Ordinal);
|
||||
Assert.Contains("questa riga documenta", after, StringComparison.Ordinal);
|
||||
Assert.Contains("_level", after, StringComparison.Ordinal);
|
||||
Assert.Contains("spiegazione del livello", after, StringComparison.Ordinal);
|
||||
Assert.Contains("\"debug\"", after, StringComparison.Ordinal);
|
||||
Assert.Contains("\"iex\"", after, StringComparison.Ordinal);
|
||||
Assert.Contains("\"fast\"", after, StringComparison.Ordinal);
|
||||
Assert.Contains("altrove", after, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StillLoadsAfterBeingWritten()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
{
|
||||
"alpaca": { "paper": true },
|
||||
"logging": { "level": "debug", "directory": "logs" },
|
||||
"symbols": [ { "symbol": "BTC/USD", "strategy": "trend-filter", "enabled": true } ]
|
||||
}
|
||||
""");
|
||||
|
||||
ConfigWriter.SetLogDirectory(path, "nuova-cartella");
|
||||
|
||||
BotConfig reloaded = ConfigLoader.Load(path, out _);
|
||||
Assert.Equal("nuova-cartella", reloaded.Logging.Directory);
|
||||
Assert.Equal("debug", reloaded.Logging.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatesTheLoggingSectionWhenItIsAbsent()
|
||||
{
|
||||
string path = Write("""{"alpaca":{"paper":true}}""");
|
||||
|
||||
ConfigWriter.SetLogDirectory(path, "logs");
|
||||
|
||||
Assert.Contains("logging", File.ReadAllText(path), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsAMissingFileRatherThanCreatingOne()
|
||||
{
|
||||
string missing = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.json");
|
||||
|
||||
Assert.Throws<FileNotFoundException>(() => ConfigWriter.SetLogDirectory(missing, "logs"));
|
||||
Assert.False(File.Exists(missing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeavesTheOriginalIntactWhenTheFileIsNotAnObject()
|
||||
{
|
||||
string path = Write("[1, 2, 3]");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => ConfigWriter.SetLogDirectory(path, "logs"));
|
||||
Assert.Equal("[1, 2, 3]", File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Alpaca;
|
||||
using Encelado.Alpaca.Internal;
|
||||
using Encelado.Alpaca.Rest;
|
||||
using Encelado.Alpaca.Streaming;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class Rfc3339Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesNanosecondPrecisionTimestamps()
|
||||
{
|
||||
DateTime parsed = Rfc3339.ParseUtc("2024-05-17T13:04:56.334262119Z"u8);
|
||||
|
||||
Assert.Equal(DateTimeKind.Utc, parsed.Kind);
|
||||
Assert.Equal(new DateTime(2024, 5, 17, 13, 4, 56, DateTimeKind.Utc).AddTicks(3_342_621), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesTimestampsWithoutAFraction()
|
||||
{
|
||||
DateTime parsed = Rfc3339.ParseUtc("2024-05-17T13:04:56Z"u8);
|
||||
Assert.Equal(new DateTime(2024, 5, 17, 13, 4, 56, DateTimeKind.Utc), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesShortFractions()
|
||||
{
|
||||
DateTime parsed = Rfc3339.ParseUtc("2024-05-17T13:04:56.5Z"u8);
|
||||
Assert.Equal(new DateTime(2024, 5, 17, 13, 4, 56, 500, DateTimeKind.Utc), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsMinValueForGarbage()
|
||||
{
|
||||
Assert.Equal(DateTime.MinValue, Rfc3339.ParseUtc("not-a-date"u8));
|
||||
Assert.Equal(DateTime.MinValue, Rfc3339.ParseUtc((string?)null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesTheFrameworkParserOnValidInput()
|
||||
{
|
||||
const string text = "2026-07-29T09:15:00.1234567Z";
|
||||
Assert.Equal(Rfc3339.ParseUtc(text), Rfc3339.ParseUtc(Encoding.UTF8.GetBytes(text)));
|
||||
}
|
||||
}
|
||||
|
||||
public class SymbolTableTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolvesSubscribedSymbolsFromUtf8()
|
||||
{
|
||||
SymbolTable table = new(["AAPL", "MSFT", "BTC/USD"]);
|
||||
|
||||
Assert.Equal(0, table.Resolve("AAPL"u8));
|
||||
Assert.Equal(1, table.Resolve("MSFT"u8));
|
||||
Assert.Equal(2, table.Resolve("BTC/USD"u8));
|
||||
Assert.Equal("BTC/USD", table.Name(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReturnsMinusOneForUnknownSymbols()
|
||||
{
|
||||
SymbolTable table = new(["AAPL"]);
|
||||
|
||||
Assert.Equal(-1, table.Resolve("TSLA"u8));
|
||||
Assert.Equal(-1, table.Resolve(""u8));
|
||||
Assert.Equal(-1, table.Resolve("THIS-SYMBOL-IS-FAR-TOO-LONG-TO-BE-REAL"u8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresDuplicatesAndIsCaseInsensitive()
|
||||
{
|
||||
SymbolTable table = new(["AAPL", "aapl", " MSFT "]);
|
||||
|
||||
Assert.Equal(2, table.Count);
|
||||
Assert.Equal(0, table.Resolve("aapl"u8));
|
||||
Assert.Equal(1, table.Resolve("MSFT"u8));
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void WritesASimpleLimitOrder()
|
||||
{
|
||||
NewOrder order = new()
|
||||
{
|
||||
Symbol = "AAPL",
|
||||
Side = Side.Buy,
|
||||
Quantity = 10,
|
||||
Type = OrderType.Limit,
|
||||
LimitPrice = 123.456,
|
||||
TimeInForce = TimeInForce.Day,
|
||||
ClientOrderId = "enc-1",
|
||||
};
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(AlpacaTradingClient.WriteOrderJson(order));
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
Assert.Equal("AAPL", root.GetProperty("symbol").GetString());
|
||||
Assert.Equal("buy", root.GetProperty("side").GetString());
|
||||
Assert.Equal("limit", root.GetProperty("type").GetString());
|
||||
Assert.Equal("day", root.GetProperty("time_in_force").GetString());
|
||||
Assert.Equal("10", root.GetProperty("qty").GetString());
|
||||
Assert.Equal("123.46", root.GetProperty("limit_price").GetString());
|
||||
Assert.Equal("enc-1", root.GetProperty("client_order_id").GetString());
|
||||
Assert.False(root.TryGetProperty("order_class", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritesABracketOrderWithBothLegs()
|
||||
{
|
||||
NewOrder order = new()
|
||||
{
|
||||
Symbol = "MSFT",
|
||||
Side = Side.Buy,
|
||||
Quantity = 5,
|
||||
Type = OrderType.Market,
|
||||
StopLossStopPrice = 95.5,
|
||||
TakeProfitLimitPrice = 110.25,
|
||||
};
|
||||
|
||||
Assert.Equal("bracket", order.OrderClass);
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(AlpacaTradingClient.WriteOrderJson(order));
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
Assert.Equal("bracket", root.GetProperty("order_class").GetString());
|
||||
Assert.Equal("110.25", root.GetProperty("take_profit").GetProperty("limit_price").GetString());
|
||||
Assert.Equal("95.5", root.GetProperty("stop_loss").GetProperty("stop_price").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneLegProducesAnOtoOrder()
|
||||
{
|
||||
NewOrder order = new()
|
||||
{
|
||||
Symbol = "MSFT",
|
||||
Side = Side.Buy,
|
||||
Quantity = 5,
|
||||
StopLossStopPrice = 95.5,
|
||||
};
|
||||
|
||||
Assert.Equal("oto", order.OrderClass);
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(AlpacaTradingClient.WriteOrderJson(order));
|
||||
Assert.Equal("oto", doc.RootElement.GetProperty("order_class").GetString());
|
||||
Assert.False(doc.RootElement.TryGetProperty("take_profit", out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(123.456, "123.46")]
|
||||
[InlineData(100.0, "100")]
|
||||
[InlineData(0.12345, "0.1235")]
|
||||
[InlineData(0.5, "0.5")]
|
||||
[InlineData(1.006, "1.01")]
|
||||
[InlineData(0.99999, "1")]
|
||||
public void PricesAreRoundedToAValidIncrement(double input, string expected) =>
|
||||
Assert.Equal(expected, AlpacaTradingClient.FormatPrice(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData(10.0, "10")]
|
||||
[InlineData(0.5, "0.5")]
|
||||
[InlineData(1.234567890123, "1.23456789")]
|
||||
public void QuantitiesKeepAtMostNineDecimals(double input, string expected) =>
|
||||
Assert.Equal(expected, AlpacaTradingClient.FormatQuantity(input));
|
||||
}
|
||||
|
||||
public class AlpacaModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesAnAccountWithStringEncodedNumbers()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"id": "abc", "account_number": "PA123", "status": "ACTIVE", "currency": "USD",
|
||||
"cash": "12345.67", "equity": "100000.00", "last_equity": "99000.00",
|
||||
"buying_power": "400000.00", "daytrading_buying_power": "400000.00",
|
||||
"portfolio_value": "100000.00", "multiplier": "4", "daytrade_count": 2,
|
||||
"pattern_day_trader": false, "trading_blocked": false, "account_blocked": false,
|
||||
"transfers_blocked": false, "trade_suspended_by_user": false, "shorting_enabled": true
|
||||
}
|
||||
""";
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
AlpacaAccount account = AlpacaAccount.FromJson(doc.RootElement);
|
||||
|
||||
Assert.Equal(100_000m, account.Equity);
|
||||
Assert.Equal(12_345.67m, account.Cash);
|
||||
Assert.Equal(2, account.DaytradeCount);
|
||||
Assert.True(account.CanTrade);
|
||||
Assert.True(account.ShortingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABlockedAccountCannotTrade()
|
||||
{
|
||||
const string json = """{"status":"ACTIVE","trading_blocked":true}""";
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
|
||||
Assert.False(AlpacaAccount.FromJson(doc.RootElement).CanTrade);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesShortPositionsAsNegativeQuantity()
|
||||
{
|
||||
const string json = """
|
||||
{"symbol":"AAPL","asset_class":"us_equity","qty":"10","side":"short",
|
||||
"avg_entry_price":"150.25","current_price":"148.00","market_value":"-1480",
|
||||
"unrealized_pl":"22.5","unrealized_plpc":"0.015"}
|
||||
""";
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
AlpacaPosition position = AlpacaPosition.FromJson(doc.RootElement);
|
||||
|
||||
Assert.Equal(-10, position.Quantity);
|
||||
Assert.Equal(150.25, position.AverageEntryPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesAnOrderWithNestedBracketLegs()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"id": "parent", "client_order_id": "enc-1", "symbol": "AAPL", "side": "buy",
|
||||
"type": "limit", "order_class": "bracket", "status": "new",
|
||||
"qty": "10", "filled_qty": "0", "filled_avg_price": null,
|
||||
"limit_price": "150.00", "stop_price": null, "submitted_at": "2026-07-29T13:30:00Z",
|
||||
"legs": [
|
||||
{"id":"tp","symbol":"AAPL","side":"sell","type":"limit","status":"held","qty":"10","limit_price":"160.00"},
|
||||
{"id":"sl","symbol":"AAPL","side":"sell","type":"stop","status":"held","qty":"10","stop_price":"145.00"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
AlpacaOrder order = AlpacaOrder.FromJson(doc.RootElement);
|
||||
|
||||
Assert.Equal("parent", order.Id);
|
||||
Assert.Equal(Side.Buy, order.Side);
|
||||
Assert.Equal(OrderStatus.New, order.Status);
|
||||
Assert.True(order.IsWorking);
|
||||
Assert.Equal(2, order.Legs.Count);
|
||||
Assert.Equal("sl", order.Legs[1].Id);
|
||||
Assert.Equal(145.0, order.Legs[1].StopPrice);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("filled", OrderStatus.Filled, true)]
|
||||
[InlineData("canceled", OrderStatus.Canceled, true)]
|
||||
[InlineData("partially_filled", OrderStatus.PartiallyFilled, false)]
|
||||
[InlineData("new", OrderStatus.New, false)]
|
||||
[InlineData("nonsense", OrderStatus.Unknown, false)]
|
||||
public void OrderStatusesMapAndClassify(string wire, OrderStatus expected, bool terminal)
|
||||
{
|
||||
OrderStatus status = OrderStatusParser.Parse(wire);
|
||||
Assert.Equal(expected, status);
|
||||
Assert.Equal(terminal, status.IsTerminal());
|
||||
}
|
||||
}
|
||||
|
||||
public class AlpacaOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DerivesEndpointsFromThePaperFlag()
|
||||
{
|
||||
AlpacaOptions paper = new() { KeyId = "k", SecretKey = "s", Paper = true };
|
||||
Assert.Equal(AlpacaOptions.PaperTradingBase, paper.TradingBaseUrl);
|
||||
Assert.Equal("wss://paper-api.alpaca.markets/stream", paper.TradeUpdatesStreamUri.ToString());
|
||||
|
||||
AlpacaOptions live = new() { KeyId = "k", SecretKey = "s", Paper = false };
|
||||
Assert.Equal(AlpacaOptions.LiveTradingBase, live.TradingBaseUrl);
|
||||
Assert.Equal("wss://api.alpaca.markets/stream", live.TradeUpdatesStreamUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PicksTheRightMarketDataStreamPerAssetClass()
|
||||
{
|
||||
AlpacaOptions options = new() { KeyId = "k", SecretKey = "s", DataFeed = "sip" };
|
||||
|
||||
Assert.Equal("wss://stream.data.alpaca.markets/v2/sip",
|
||||
options.MarketDataStreamUri(AssetClass.UsEquity).ToString());
|
||||
Assert.Equal("wss://stream.data.alpaca.markets/v1beta3/crypto/us",
|
||||
options.MarketDataStreamUri(AssetClass.Crypto).ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationRequiresCredentialsAndAKnownFeed()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => new AlpacaOptions().Validate());
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
new AlpacaOptions { KeyId = "k", SecretKey = "s", DataFeed = "nope" }.Validate());
|
||||
|
||||
AlpacaOptions valid = new() { KeyId = "k", SecretKey = "s" };
|
||||
Assert.Same(valid, valid.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PK\u00A0KEY")] // non-breaking space from a web page
|
||||
[InlineData("PK\u200BKEY")] // zero-width space
|
||||
[InlineData("PKKEY\uFEFF")] // byte-order mark
|
||||
[InlineData("PK\tKEY")] // tab
|
||||
[InlineData("PK\u201CKEY")] // smart quote
|
||||
public void CredentialsWithNonAsciiCharactersAreRejectedUpFront(string keyId)
|
||||
{
|
||||
AlpacaOptions options = new() { KeyId = keyId, SecretKey = "secret" };
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
Assert.Contains("printable ASCII", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANonAsciiSecretIsRejectedToo()
|
||||
{
|
||||
AlpacaOptions options = new() { KeyId = "PKKEY", SecretKey = "secret\u00A0value" };
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
Assert.Contains("secretKey", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public class TimeFrameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(TimeFrame.OneMinute, "1Min", 60)]
|
||||
[InlineData(TimeFrame.FiveMinutes, "5Min", 300)]
|
||||
[InlineData(TimeFrame.FifteenMinutes, "15Min", 900)]
|
||||
[InlineData(TimeFrame.OneHour, "1Hour", 3600)]
|
||||
[InlineData(TimeFrame.OneDay, "1Day", 86_400)]
|
||||
public void MapsToTheAlpacaWireFormat(TimeFrame tf, string wire, int seconds)
|
||||
{
|
||||
Assert.Equal(wire, tf.ToAlpaca());
|
||||
Assert.Equal(seconds, tf.Seconds());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1Min", TimeFrame.OneMinute)]
|
||||
[InlineData("5m", TimeFrame.FiveMinutes)]
|
||||
[InlineData("1HOUR", TimeFrame.OneHour)]
|
||||
public void ParsesCommonSpellings(string text, TimeFrame expected)
|
||||
{
|
||||
Assert.True(TimeFrameExtensions.TryParse(text, out TimeFrame tf));
|
||||
Assert.Equal(expected, tf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsUnsupportedTimeframes() =>
|
||||
Assert.False(TimeFrameExtensions.TryParse("3Min", out _));
|
||||
}
|
||||
|
||||
public class QuoteTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputesMidAndRelativeSpread()
|
||||
{
|
||||
Quote quote = new(DateTime.UtcNow, 99.9, 100, 100.1, 200);
|
||||
|
||||
Assert.Equal(100, quote.Mid, 10);
|
||||
Assert.Equal(0.2, quote.Spread, 10);
|
||||
Assert.Equal(0.002, quote.RelativeSpread, 10);
|
||||
Assert.True(quote.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEmptyOrCrossedBookIsInvalid()
|
||||
{
|
||||
Assert.False(new Quote(DateTime.UtcNow, 0, 0, 100, 1).IsValid);
|
||||
Assert.False(new Quote(DateTime.UtcNow, 101, 1, 100, 1).IsValid);
|
||||
Assert.Equal(double.PositiveInfinity, new Quote(DateTime.UtcNow, 0, 0, 0, 0).RelativeSpread);
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
using Encelado.Core.Backtest;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class ReplayerTests
|
||||
{
|
||||
private static readonly DateTime Start = new(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Short periods so a few hundred synthetic bars produce several round trips. The
|
||||
// shipped values (100 bars, 2% band) would barely trade inside a test fixture.
|
||||
private static StrategyParameters Params() => new StrategyParameters()
|
||||
.Set("period", 12)
|
||||
.Set("band", 0.01)
|
||||
.Set("stopPct", 0.35)
|
||||
.Set("cvdThreshold", 0)
|
||||
.Set("volPeriod", 12)
|
||||
.Set("atrPeriod", 8)
|
||||
.Set("barsPerYear", 365);
|
||||
|
||||
private static BacktestSettings Settings(double feeBps = 0) => new()
|
||||
{
|
||||
// Mirrors config/encelado.json where it matters: full stake, and a stop-distance
|
||||
// ceiling wide enough to accept the strategy's deliberately far backstop. At the
|
||||
// default 15% every entry would be refused as InvalidStop and the replayer would
|
||||
// report a clean run with zero trades.
|
||||
Risk = new RiskLimits
|
||||
{
|
||||
StakePct = 1.0,
|
||||
MaxRiskPerTradePct = 0.01,
|
||||
MaxPositionNotionalPct = 1.0,
|
||||
MaxGrossExposurePct = 1.0,
|
||||
MaxOpenPositions = 1,
|
||||
MaxTradesPerDay = 1000,
|
||||
MaxTradesPerSymbolPerDay = 1000,
|
||||
MinSecondsBetweenEntries = 0,
|
||||
MaxRelativeSpread = 0,
|
||||
MinOrderNotional = 1,
|
||||
DefaultStopPct = 0.35,
|
||||
MaxStopDistancePct = 0.60,
|
||||
},
|
||||
StartingEquity = 100_000,
|
||||
SlippageBps = 5,
|
||||
FeeBps = feeBps,
|
||||
AllowFractional = true,
|
||||
};
|
||||
|
||||
/// <summary>A saw-tooth: long enough legs in both directions to trigger crossings.</summary>
|
||||
private static List<Bar> SawTooth(int cycles, int legLength, double amplitude)
|
||||
{
|
||||
List<Bar> bars = [];
|
||||
double price = 100;
|
||||
int index = 0;
|
||||
|
||||
for (int c = 0; c < cycles; c++)
|
||||
{
|
||||
for (int i = 0; i < legLength; i++)
|
||||
{
|
||||
price += amplitude;
|
||||
bars.Add(Make(index++, price));
|
||||
}
|
||||
|
||||
for (int i = 0; i < legLength; i++)
|
||||
{
|
||||
price -= amplitude;
|
||||
bars.Add(Make(index++, price));
|
||||
}
|
||||
}
|
||||
|
||||
return bars;
|
||||
|
||||
static Bar Make(int i, double close) =>
|
||||
new(Start.AddMinutes(i), close, close + 0.4, close - 0.4, close, 10_000, close, 25);
|
||||
}
|
||||
|
||||
private static BacktestReport Run(
|
||||
IReadOnlyList<Bar> bars, BacktestSettings settings, StrategyParameters? p = null) =>
|
||||
new Replayer(settings).Run(
|
||||
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", p ?? Params()))],
|
||||
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = bars });
|
||||
|
||||
[Fact]
|
||||
public void ProducesTradesAndACoherentEquityCurve()
|
||||
{
|
||||
List<Bar> bars = SawTooth(6, 45, 1.2);
|
||||
BacktestReport report = Run(bars, Settings());
|
||||
|
||||
Assert.True(report.Trades.Count > 0, "the saw-tooth should trigger at least one round trip");
|
||||
Assert.Equal(bars.Count, report.BarsProcessed);
|
||||
|
||||
// Every closed trade must be accounted for exactly once in the final equity.
|
||||
Assert.Equal(report.StartEquity + report.Trades.Sum(t => t.Pnl), report.EndEquity, 6);
|
||||
Assert.Equal(report.Trades.Count, report.Wins + report.Losses);
|
||||
Assert.InRange(report.MaxDrawdownPct, 0, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntriesFillOnTheNextBarNotTheSignalBar()
|
||||
{
|
||||
List<Bar> bars = SawTooth(6, 45, 1.2);
|
||||
BacktestReport report = Run(bars, Settings());
|
||||
|
||||
foreach (ClosedTrade trade in report.Trades)
|
||||
{
|
||||
// The fill must match some bar's open plus slippage, never a close.
|
||||
Assert.Contains(bars, b => Math.Abs((b.Open * 1.0005) - trade.EntryPrice) < 1e-6);
|
||||
Assert.True(trade.ExitUtc >= trade.EntryUtc);
|
||||
Assert.True(trade.Quantity > 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EverythingIsLiquidatedAtTheEndOfTheRun()
|
||||
{
|
||||
// Down then up, ending firmly above the average, so the run finishes holding a
|
||||
// position that the replayer has to liquidate.
|
||||
List<Bar> bars = [];
|
||||
double price = 200;
|
||||
int index = 0;
|
||||
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
price *= 0.995;
|
||||
bars.Add(Make(index++, price));
|
||||
}
|
||||
|
||||
for (int i = 0; i < 150; i++)
|
||||
{
|
||||
price *= 1.006;
|
||||
bars.Add(Make(index++, price));
|
||||
}
|
||||
|
||||
BacktestReport report = Run(bars, Settings(), Params());
|
||||
|
||||
Assert.Contains(report.Trades, t => t.ExitReason == "end of backtest");
|
||||
Assert.Equal(report.StartEquity + report.Trades.Sum(t => t.Pnl), report.EndEquity, 6);
|
||||
|
||||
static Bar Make(int i, double close) =>
|
||||
new(Start.AddMinutes(i), close, close + 0.3, close - 0.3, close, 10_000, close, 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeesAreChargedOnBothSidesAndReduceTheResult()
|
||||
{
|
||||
List<Bar> bars = SawTooth(6, 45, 1.2);
|
||||
|
||||
BacktestReport free = Run(bars, Settings(feeBps: 0));
|
||||
BacktestReport charged = Run(bars, Settings(feeBps: 25));
|
||||
|
||||
Assert.Equal(0, free.TotalFees, 6);
|
||||
Assert.True(charged.TotalFees > 0, "a 25 bps fee must actually cost something");
|
||||
Assert.True(charged.EndEquity < free.EndEquity, "fees must reduce the final equity");
|
||||
|
||||
// Two fills per round trip, so the total is roughly 2 x fee x notional.
|
||||
foreach (ClosedTrade trade in charged.Trades)
|
||||
{
|
||||
Assert.True(trade.Fees > 0);
|
||||
Assert.Equal(trade.GrossPnl - trade.Fees, trade.Pnl, 9);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowsWhenNoSymbolHasHistory()
|
||||
{
|
||||
Replayer replayer = new(Settings());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
replayer.Run(
|
||||
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
|
||||
new Dictionary<string, IReadOnlyList<Bar>>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsProgressAndHonoursCancellation()
|
||||
{
|
||||
List<double> progress = [];
|
||||
Replayer replayer = new(Settings()) { OnProgress = progress.Add };
|
||||
|
||||
replayer.Run(
|
||||
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
|
||||
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = SawTooth(6, 45, 1.2) });
|
||||
|
||||
Assert.NotEmpty(progress);
|
||||
Assert.Equal(1.0, progress[^1], 6);
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
cts.Cancel();
|
||||
|
||||
Assert.Throws<OperationCanceledException>(() =>
|
||||
new Replayer(Settings()).Run(
|
||||
[new BacktestSymbol("TEST", StrategyFactory.Create("trend-filter", Params()))],
|
||||
new Dictionary<string, IReadOnlyList<Bar>> { ["TEST"] = SawTooth(6, 45, 1.2) },
|
||||
cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderProducesASummaryWithoutThrowingOnAnEmptyRun()
|
||||
{
|
||||
BacktestReport empty = new(100_000, 100_000, 0, [], 0, Start, Start.AddDays(1), 0);
|
||||
|
||||
Assert.Equal(0, empty.WinRate);
|
||||
Assert.Equal(0, empty.ProfitFactor);
|
||||
Assert.Contains("trades 0", empty.Render(), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public class CsvBarSourceTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _files = [];
|
||||
|
||||
private string Write(string content)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-csv-{Guid.NewGuid():N}.csv");
|
||||
File.WriteAllText(path, content);
|
||||
_files.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string f in _files)
|
||||
{
|
||||
File.Delete(f);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsBinanceKlinesWithMillisecondTimestamps()
|
||||
{
|
||||
// 1502942400000 = 2017-08-17 04:00:00 UTC
|
||||
string path = Write(
|
||||
"""
|
||||
timestamp,open,high,low,close,volume,close_timestamp,quote_asset_volume,number_of_trades
|
||||
1502942400000,4261.48,4280.56,4261.48,4261.48,2,1502943299999,9333.62,9
|
||||
1502943300000,4261.48,4270.41,4261.32,4261.45,9,1502944199999,38891.1,40
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
|
||||
|
||||
Assert.Equal(2, bars.Count);
|
||||
Assert.Equal(new DateTime(2017, 8, 17, 4, 0, 0, DateTimeKind.Utc), bars[0].TimeUtc);
|
||||
Assert.Equal(4261.48, bars[0].Open);
|
||||
Assert.Equal(4280.56, bars[0].High);
|
||||
Assert.Equal(4261.48, bars[0].Low);
|
||||
Assert.Equal(4261.48, bars[0].Close);
|
||||
Assert.Equal(2, bars[0].Volume);
|
||||
Assert.Equal(9, bars[0].TradeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsIsoDatesAndSecondEpochsToo()
|
||||
{
|
||||
string iso = Write(
|
||||
"""
|
||||
date,open,high,low,close,volume
|
||||
2024-05-17T13:00:00Z,100,110,95,105,1000
|
||||
2024-05-17T14:00:00Z,105,115,100,112,1200
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.Load(iso);
|
||||
Assert.Equal(new DateTime(2024, 5, 17, 13, 0, 0, DateTimeKind.Utc), bars[0].TimeUtc);
|
||||
Assert.Equal(105, bars[0].Close);
|
||||
|
||||
string seconds = Write(
|
||||
"""
|
||||
time,open,high,low,close
|
||||
1715950800,100,110,95,105
|
||||
""");
|
||||
|
||||
Assert.Equal(
|
||||
DateTimeOffset.FromUnixTimeSeconds(1715950800).UtcDateTime,
|
||||
CsvBarSource.Load(seconds)[0].TimeUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipsMalformedRowsInsteadOfFailing()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
timestamp,open,high,low,close,volume
|
||||
1502942400000,4261.48,4280.56,4261.48,4261.48,2
|
||||
not-a-number,1,2,3,4,5
|
||||
1502943300000,abc,4270.41,4261.32,4261.45,9
|
||||
1502944200000,0,0,0,0,0
|
||||
1502945100000,100,110,95,105,7
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
|
||||
|
||||
Assert.Equal(2, bars.Count);
|
||||
Assert.Equal(105, bars[1].Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortsRowsThatArriveOutOfOrder()
|
||||
{
|
||||
string path = Write(
|
||||
"""
|
||||
timestamp,open,high,low,close
|
||||
1502943300000,2,2,2,2
|
||||
1502942400000,1,1,1,1
|
||||
""");
|
||||
|
||||
IReadOnlyList<Bar> bars = CsvBarSource.Load(path);
|
||||
|
||||
Assert.Equal(2, bars.Count);
|
||||
Assert.True(bars[0].TimeUtc < bars[1].TimeUtc);
|
||||
Assert.Equal(1, bars[0].Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAHeaderWithoutTheRequiredColumns()
|
||||
{
|
||||
string path = Write("alpha,beta\n1,2");
|
||||
Assert.Throws<InvalidDataException>(() => CsvBarSource.Load(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAFileWithNoUsableRows()
|
||||
{
|
||||
string path = Write("timestamp,open,high,low,close\nx,x,x,x,x");
|
||||
Assert.Throws<InvalidDataException>(() => CsvBarSource.Load(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsAMissingFileClearly() =>
|
||||
Assert.Throws<FileNotFoundException>(() =>
|
||||
CsvBarSource.Load(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.csv")));
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Text.Json;
|
||||
using Encelado.Binance;
|
||||
using Encelado.Binance.Rest;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>Options, symbol rules and the payload decoders.</summary>
|
||||
public class BinanceOptionsTests
|
||||
{
|
||||
private static BinanceOptions Valid() => new()
|
||||
{
|
||||
ApiKey = new string('k', 64),
|
||||
ApiSecret = new string('s', 64),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void TestnetAndLiveUseDifferentHosts()
|
||||
{
|
||||
BinanceOptions testnet = Valid();
|
||||
BinanceOptions live = Valid();
|
||||
live.Testnet = false;
|
||||
|
||||
Assert.NotEqual(testnet.RestBaseUrl, live.RestBaseUrl);
|
||||
Assert.NotEqual(testnet.StreamBaseUrl, live.StreamBaseUrl);
|
||||
Assert.Contains("testnet", testnet.RestBaseUrl, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesCredentialsCarryingInvisibleCharacters()
|
||||
{
|
||||
BinanceOptions options = Valid();
|
||||
|
||||
// A zero-width space picked up by copy/paste would otherwise surface much later
|
||||
// as a signature that is silently wrong.
|
||||
options.ApiSecret = "abcdef";
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
Assert.Contains("ASCII", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesLeverageThatWouldMakeAHedgedBookLiquidatable()
|
||||
{
|
||||
BinanceOptions options = Valid();
|
||||
options.Leverage = 50;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesAnUnknownMarginType()
|
||||
{
|
||||
BinanceOptions options = Valid();
|
||||
options.MarginType = "HEDGED";
|
||||
|
||||
Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaysWhatIsMissingWhenThereAreNoCredentials()
|
||||
{
|
||||
BinanceOptions options = new();
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
Assert.Contains("BINANCE_API_KEY", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public class SymbolFilterTests
|
||||
{
|
||||
private static SymbolFilters Parse(string json)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
return SymbolFilters.Parse(doc.RootElement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsTheExchangeRulesForASymbol()
|
||||
{
|
||||
SymbolFilters f = Parse("""
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"pricePrecision": 2,
|
||||
"quantityPrecision": 3,
|
||||
"filters": [
|
||||
{ "filterType": "PRICE_FILTER", "tickSize": "0.10" },
|
||||
{ "filterType": "LOT_SIZE", "stepSize": "0.001", "minQty": "0.001", "maxQty": "1000" },
|
||||
{ "filterType": "MIN_NOTIONAL", "notional": "100" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Equal("BTCUSDT", f.Symbol);
|
||||
Assert.Equal(0.10, f.TickSize, 8);
|
||||
Assert.Equal(0.001, f.StepSize, 8);
|
||||
Assert.Equal(100, f.MinNotional, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundsQuantitiesDownOntoTheLotStep()
|
||||
{
|
||||
SymbolFilters f = SymbolFilters.Unknown with { StepSize = 0.001, QuantityPrecision = 3 };
|
||||
|
||||
// Down, never up: rounding up can push the notional past the margin actually
|
||||
// available, and a rejected leg leaves the book directional.
|
||||
Assert.Equal(0.047, f.RoundQuantity(0.0479), 8);
|
||||
Assert.Equal(0.047, f.RoundQuantity(0.04701), 8);
|
||||
Assert.Equal(0, f.RoundQuantity(0.0009), 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeepsTheSignWhenRounding()
|
||||
{
|
||||
SymbolFilters f = SymbolFilters.Unknown with { StepSize = 0.01, QuantityPrecision = 2 };
|
||||
|
||||
Assert.Equal(-1.23, f.RoundQuantity(-1.2345), 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaysWhyAQuantityIsNotSendable()
|
||||
{
|
||||
SymbolFilters f = SymbolFilters.Unknown with
|
||||
{
|
||||
Symbol = "BTCUSDT",
|
||||
StepSize = 0.001,
|
||||
MinQuantity = 0.001,
|
||||
MinNotional = 100,
|
||||
QuantityPrecision = 3,
|
||||
};
|
||||
|
||||
Assert.False(f.IsTradable(0, 60_000, out string zero));
|
||||
Assert.Contains("zero", zero, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// 0.001 × 60 000 = 60, below the 100 minimum.
|
||||
Assert.False(f.IsTradable(0.001, 60_000, out string notional));
|
||||
Assert.Contains("controvalore", notional, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.True(f.IsTradable(0.002, 60_000, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatsWithTheExchangePrecisionRatherThanTheCurrentCulture()
|
||||
{
|
||||
SymbolFilters f = SymbolFilters.Unknown with { QuantityPrecision = 3, PricePrecision = 2 };
|
||||
|
||||
// A comma decimal separator would be rejected by the exchange and is exactly what
|
||||
// an Italian machine would produce without the invariant culture.
|
||||
Assert.Equal("0.047", f.FormatQuantity(0.047));
|
||||
Assert.Equal("60000.00", f.FormatPrice(60_000));
|
||||
}
|
||||
}
|
||||
|
||||
public class KlineParsingTests
|
||||
{
|
||||
[Fact]
|
||||
public void DecodesBinancePositionalKlineLayout()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("""
|
||||
[1700000000000,"100.5","102.0","99.5","101.25","1500",1700000299999,"151875",420,"900","91125","0"]
|
||||
""");
|
||||
|
||||
Bar bar = BinanceFuturesClient.ParseKline(doc.RootElement);
|
||||
|
||||
Assert.Equal(100.5, bar.Open, 8);
|
||||
Assert.Equal(102.0, bar.High, 8);
|
||||
Assert.Equal(99.5, bar.Low, 8);
|
||||
Assert.Equal(101.25, bar.Close, 8);
|
||||
Assert.Equal(1500, bar.Volume, 8);
|
||||
Assert.Equal(420, bar.TradeCount);
|
||||
|
||||
// Index 9 is the taker-buy base volume: the venue hands us the aggressor split
|
||||
// per bar, which is what the previous one had to rebuild from the tape.
|
||||
Assert.Equal(900, bar.TakerBuyVolume, 8);
|
||||
Assert.True(bar.HasOrderFlow);
|
||||
Assert.Equal(300, bar.Delta, 8);
|
||||
|
||||
// VWAP is derived from the quote volume, not quoted directly.
|
||||
Assert.Equal(151875.0 / 1500, bar.Vwap, 8);
|
||||
Assert.Equal(DateTimeKind.Utc, bar.TimeUtc.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderParsingTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadsAnOrderAndItsLifecycleFlags()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("""
|
||||
{
|
||||
"orderId": 12345,
|
||||
"clientOrderId": "enc-ETHUSDT-1",
|
||||
"symbol": "ETHUSDT",
|
||||
"side": "SELL",
|
||||
"type": "LIMIT",
|
||||
"status": "PARTIALLY_FILLED",
|
||||
"origQty": "1.500",
|
||||
"executedQty": "0.500",
|
||||
"avgPrice": "3000.10",
|
||||
"price": "3000.00",
|
||||
"reduceOnly": true,
|
||||
"time": 1700000000000,
|
||||
"updateTime": 1700000001000
|
||||
}
|
||||
""");
|
||||
|
||||
BinanceOrder order = BinanceOrder.Parse(doc.RootElement);
|
||||
|
||||
Assert.Equal(12345, order.Id);
|
||||
Assert.Equal(Side.Sell, order.Side);
|
||||
Assert.Equal(OrderStatus.PartiallyFilled, order.Status);
|
||||
Assert.True(order.ReduceOnly);
|
||||
Assert.True(order.IsWorking);
|
||||
Assert.False(order.IsTerminal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("FILLED", true)]
|
||||
[InlineData("CANCELED", true)]
|
||||
[InlineData("REJECTED", true)]
|
||||
[InlineData("EXPIRED", true)]
|
||||
[InlineData("NEW", false)]
|
||||
[InlineData("PARTIALLY_FILLED", false)]
|
||||
public void KnowsWhichStatusesEndAnOrder(string status, bool terminal)
|
||||
{
|
||||
OrderStatus parsed = BinanceOrder.ParseStatus(status);
|
||||
|
||||
BinanceOrder order = new(1, "c", "ETHUSDT", Side.Buy, "LIMIT", parsed,
|
||||
1, 0, 0, 0, false, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(terminal, order.IsTerminal);
|
||||
}
|
||||
}
|
||||
|
||||
public class FuturesAccountTests
|
||||
{
|
||||
[Fact]
|
||||
public void EquityIsTheMarginBalanceNotTheWallet()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("""
|
||||
{
|
||||
"totalWalletBalance": "1000.0",
|
||||
"totalMarginBalance": "940.0",
|
||||
"availableBalance": "700.0",
|
||||
"totalUnrealizedProfit": "-60.0",
|
||||
"totalMaintMargin": "94.0",
|
||||
"totalInitialMargin": "200.0",
|
||||
"feeTier": 0,
|
||||
"canTrade": true
|
||||
}
|
||||
""");
|
||||
|
||||
FuturesAccount account = FuturesAccount.Parse(doc.RootElement);
|
||||
|
||||
// A liquidation is measured against the margin balance. Sizing off the wallet
|
||||
// would ignore an open position that is currently under water — which is
|
||||
// precisely when the difference matters.
|
||||
Assert.Equal(940m, account.Equity);
|
||||
Assert.Equal(0.1, account.MarginRatio, 6);
|
||||
}
|
||||
}
|
||||
|
||||
public class FundingTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadsTheRateThatWillBeSettledNext()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse("""
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"markPrice": "3000.5",
|
||||
"indexPrice": "3000.1",
|
||||
"lastFundingRate": "0.00012",
|
||||
"nextFundingTime": 1700000000000
|
||||
}
|
||||
""");
|
||||
|
||||
FundingInfo funding = FundingInfo.Parse(doc.RootElement);
|
||||
|
||||
Assert.Equal(0.00012, funding.LastFundingRate, 8);
|
||||
|
||||
// Three settlements a day.
|
||||
Assert.Equal(0.00012 * 3 * 365, funding.AnnualisedRate, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Ui;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The shipped configuration, the loader, and the ability to get back to a known-good
|
||||
/// state.
|
||||
/// </summary>
|
||||
public class ConfigDefaultsTests
|
||||
{
|
||||
/// <summary>Walks up from the test binary to the repository root.</summary>
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Encelado.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return directory?.FullName ?? throw new DirectoryNotFoundException("Encelado.slnx non trovato.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The embedded default and the file that ships beside the executable must be the
|
||||
/// same document.
|
||||
/// <para>
|
||||
/// They are two copies on purpose — the file has to exist for a human to open, and
|
||||
/// the string has to exist so "restore defaults" can rebuild it without needing the
|
||||
/// file it is repairing. Two copies drift, so this is the thing that stops them.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheShippedFileMatchesTheEmbeddedDefault()
|
||||
{
|
||||
string shipped = File.ReadAllText(Path.Combine(RepositoryRoot(), "config", "encelado.json"));
|
||||
|
||||
// Line endings differ between a checkout and a C# raw string literal, and that
|
||||
// difference is not a drift worth failing over.
|
||||
Assert.Equal(
|
||||
Normalize(ConfigDefaults.Json),
|
||||
Normalize(shipped));
|
||||
|
||||
static string Normalize(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheDefaultIsValidJson()
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(ConfigDefaults.Json);
|
||||
Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheDefaultLoadsAndValidatesOnceCredentialsAreSupplied()
|
||||
{
|
||||
BotConfig config = ConfigDefaults.Parse();
|
||||
config.Binance.ApiKey = new string('k', 64);
|
||||
config.Binance.ApiSecret = new string('s', 64);
|
||||
|
||||
config.Validate();
|
||||
|
||||
Assert.NotEmpty(config.EnabledPairs);
|
||||
Assert.All(config.EnabledPairs, static p => Assert.EndsWith("USDT", p.SymbolA, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The backtest could not find a threshold set that survived on data it had not been
|
||||
/// chosen on, so the shipped configuration must not send orders until a human decides
|
||||
/// it should.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheDefaultShipsInDryRun()
|
||||
{
|
||||
Assert.True(ConfigDefaults.Parse().Engine.DryRun,
|
||||
"il default deve partire in dry-run: nessuna combinazione di soglie ha retto " +
|
||||
"sulla fetta di conferma, quindi inviare ordini di fabbrica non è difendibile");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheDefaultShipsOnTestnet()
|
||||
{
|
||||
Assert.True(ConfigDefaults.Parse().Binance.Testnet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreTakesABackupBeforeOverwriting()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), $"encelado-restore-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(directory, "encelado.json");
|
||||
File.WriteAllText(path, "{ \"engine\": { \"dryRun\": false } }");
|
||||
|
||||
string? backup = ConfigDefaults.Restore(path);
|
||||
|
||||
Assert.NotNull(backup);
|
||||
Assert.True(File.Exists(backup));
|
||||
Assert.Contains("dryRun", File.ReadAllText(backup!), StringComparison.Ordinal);
|
||||
Assert.Equal(ConfigDefaults.Json, File.ReadAllText(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreWorksWhenThereIsNoFileToBackUp()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), $"encelado-restore-{Guid.NewGuid():N}");
|
||||
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(directory, "encelado.json");
|
||||
|
||||
Assert.Null(ConfigDefaults.Restore(path));
|
||||
Assert.True(File.Exists(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigLoaderTests
|
||||
{
|
||||
private static BotConfig Load(string json, out List<string> warnings)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return ConfigLoader.Load(path, out warnings);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalisesSymbolsToTheFuturesFormat()
|
||||
{
|
||||
BotConfig config = Load("""
|
||||
{ "pairs": [ { "symbolA": "eth/usdt", "symbolB": "BTC-USDT", "enabled": true } ] }
|
||||
""", out _);
|
||||
|
||||
PairConfig pair = config.Pairs[0];
|
||||
|
||||
Assert.Equal("ETHUSDT", PairConfig.Normalize(pair.SymbolA));
|
||||
Assert.Equal("BTCUSDT", PairConfig.Normalize(pair.SymbolB));
|
||||
Assert.Equal("ETHUSDT/BTCUSDT", pair.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeduplicatesASymbolSharedByTwoPairs()
|
||||
{
|
||||
BotConfig config = Load("""
|
||||
{
|
||||
"pairs": [
|
||||
{ "symbolA": "ETHUSDT", "symbolB": "BTCUSDT" },
|
||||
{ "symbolA": "SOLUSDT", "symbolB": "BTCUSDT" }
|
||||
]
|
||||
}
|
||||
""", out _);
|
||||
|
||||
// Subscribing twice would mean two copies of the same book drifting apart by a
|
||||
// message or two — and a spread computed from both is a signal that is not there.
|
||||
Assert.Equal(["ETHUSDT", "BTCUSDT", "SOLUSDT"], config.TradedSymbols);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsAnAlpacaEraFileAsSomethingToUpgradeRatherThanATypo()
|
||||
{
|
||||
_ = Load("""
|
||||
{ "alpaca": { "paper": true }, "symbols": [ { "symbol": "BTC/USD" } ] }
|
||||
""", out List<string> warnings);
|
||||
|
||||
Assert.Contains(warnings, w => w.Contains("Alpaca", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(warnings, w => w.Contains("Ripristina", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsAnUnknownKeyInsteadOfSwallowingIt()
|
||||
{
|
||||
_ = Load("""{ "engine": { "timeFrame": "15m", "wibble": 3 } }""", out List<string> warnings);
|
||||
|
||||
Assert.Contains(warnings, w => w.Contains("wibble", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresTheUnderscoreDocumentationKeys()
|
||||
{
|
||||
BotConfig config = Load("""
|
||||
{ "_comment": "spiegazione", "engine": { "_note": "perché", "timeFrame": "1h" } }
|
||||
""", out List<string> warnings);
|
||||
|
||||
Assert.Equal("1h", config.Engine.TimeFrame);
|
||||
Assert.DoesNotContain(warnings, w => w.Contains("_note", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesAPairWithTwoIdenticalLegs()
|
||||
{
|
||||
BotConfig config = Load("""
|
||||
{ "pairs": [ { "symbolA": "ETHUSDT", "symbolB": "ETHUSDT" } ] }
|
||||
""", out _);
|
||||
|
||||
config.Binance.ApiKey = new string('k', 64);
|
||||
config.Binance.ApiSecret = new string('s', 64);
|
||||
|
||||
// The spread would be constant at zero, so no z-score could ever exist.
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
Assert.Contains("uguali", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesTheOneMinuteTimeframe()
|
||||
{
|
||||
BotConfig config = Load("""{ "engine": { "timeFrame": "1m" } }""", out _);
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Engine.Validate);
|
||||
Assert.Contains("5m", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public class SettingsFormTests
|
||||
{
|
||||
private static BotConfig Config()
|
||||
{
|
||||
BotConfig config = ConfigDefaults.Parse();
|
||||
config.Binance.ApiKey = new string('k', 64);
|
||||
config.Binance.ApiSecret = new string('s', 64);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SettingField> Fields() =>
|
||||
[.. SettingsCatalogue.Build(Config()).SelectMany(static g => g.Fields)];
|
||||
|
||||
[Fact]
|
||||
public void EveryFieldStartsCleanOnTheFactoryConfiguration()
|
||||
{
|
||||
foreach (SettingField field in Fields())
|
||||
{
|
||||
Assert.False(field.HasError,
|
||||
$"{field.Path} vale '{field.Value}' e la pagina lo segnala come errore: {field.Error}");
|
||||
Assert.False(field.IsDirty, $"{field.Path} risulta modificato appena caricato");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("engine.timeFrame")]
|
||||
[InlineData("logging.level")]
|
||||
[InlineData("engine.entryOrderType")]
|
||||
[InlineData("binance.marginType")]
|
||||
public void ClosedSetsAreChosenFromAListRatherThanTyped(string path)
|
||||
{
|
||||
SettingField field = Fields().First(f => f.Path == path);
|
||||
|
||||
Assert.True(field.IsList, $"{path} deve essere un elenco");
|
||||
Assert.NotEmpty(field.Options);
|
||||
Assert.False(field.IsObsolete,
|
||||
$"{path} vale '{field.Value}' che non è fra {string.Join(", ", field.Options)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BooleansAreAListToo()
|
||||
{
|
||||
SettingField field = Fields().First(static f => f.Path == "engine.dryRun");
|
||||
|
||||
Assert.True(field.IsList);
|
||||
Assert.Equal(["sì", "no"], field.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumbersStayFreeText()
|
||||
{
|
||||
Assert.True(Fields().First(static f => f.Path == "risk.stakePct").IsFreeText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryPairGetsItsOwnThresholds()
|
||||
{
|
||||
BotConfig config = Config();
|
||||
IReadOnlyList<SettingGroup> groups = SettingsCatalogue.Build(config);
|
||||
|
||||
for (int i = 0; i < config.Pairs.Count; i++)
|
||||
{
|
||||
string path = $"pairs[{i}].parameters.entryZ";
|
||||
Assert.Contains(groups.SelectMany(static g => g.Fields), f => f.Path == path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every path the form can write must actually resolve in the shipped file. A typo
|
||||
/// here is invisible until someone saves and the value silently lands in the wrong
|
||||
/// place — or creates a key nothing reads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EveryEditablePathExistsInTheShippedConfiguration()
|
||||
{
|
||||
JsonNode root = JsonNode.Parse(ConfigDefaults.Json)!;
|
||||
|
||||
foreach (SettingField field in Fields().Where(static f => !f.IsReadOnly))
|
||||
{
|
||||
Assert.True(Resolves(root, field.Path), $"il percorso '{field.Path}' non esiste nel file");
|
||||
}
|
||||
|
||||
static bool Resolves(JsonNode root, string path)
|
||||
{
|
||||
JsonNode? current = root;
|
||||
|
||||
foreach (string segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (current is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int bracket = segment.IndexOf('[', StringComparison.Ordinal);
|
||||
string name = bracket < 0 ? segment : segment[..bracket];
|
||||
|
||||
current = current is JsonObject o && o.TryGetPropertyValue(name, out JsonNode? child)
|
||||
? child
|
||||
: null;
|
||||
|
||||
if (bracket >= 0)
|
||||
{
|
||||
int index = int.Parse(
|
||||
segment.AsSpan(bracket + 1, segment.Length - bracket - 2),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
current = current is JsonArray array && index < array.Count ? array[index] : null;
|
||||
}
|
||||
}
|
||||
|
||||
return current is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,79 +35,79 @@ public sealed class CredentialStoreTests : IDisposable
|
||||
public void SaveThenLoadRoundTrips()
|
||||
{
|
||||
Assert.False(CredentialStore.Exists);
|
||||
Assert.Null(CredentialStore.Load(paper: true));
|
||||
Assert.Null(CredentialStore.Load(testnet: true));
|
||||
|
||||
CredentialStore.Save(paper: true, "PKTESTKEY", "supersecret");
|
||||
CredentialStore.Save(testnet: true, "PKTESTKEY", "supersecret");
|
||||
|
||||
Assert.True(CredentialStore.Exists);
|
||||
StoredCredentials? loaded = CredentialStore.Load(paper: true);
|
||||
StoredCredentials? loaded = CredentialStore.Load(testnet: true);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("PKTESTKEY", loaded.Value.KeyId);
|
||||
Assert.Equal("supersecret", loaded.Value.SecretKey);
|
||||
Assert.Equal("PKTESTKEY", loaded.Value.ApiKey);
|
||||
Assert.Equal("supersecret", loaded.Value.ApiSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaperAndLiveAreStoredSeparately()
|
||||
public void TestnetAndLiveAreStoredSeparately()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKPAPER", "paper-secret");
|
||||
CredentialStore.Save(paper: false, "AKLIVE", "live-secret");
|
||||
CredentialStore.Save(testnet: true, "PKPAPER", "testnet-secret");
|
||||
CredentialStore.Save(testnet: false, "AKLIVE", "live-secret");
|
||||
|
||||
Assert.Equal("PKPAPER", CredentialStore.Load(paper: true)!.Value.KeyId);
|
||||
Assert.Equal("AKLIVE", CredentialStore.Load(paper: false)!.Value.KeyId);
|
||||
Assert.Equal("PKPAPER", CredentialStore.Load(testnet: true)!.Value.ApiKey);
|
||||
Assert.Equal("AKLIVE", CredentialStore.Load(testnet: false)!.Value.ApiKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavingTwiceReplacesTheEntry()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKOLD", "old");
|
||||
CredentialStore.Save(paper: true, "PKNEW", "new");
|
||||
CredentialStore.Save(testnet: true, "PKOLD", "old");
|
||||
CredentialStore.Save(testnet: true, "PKNEW", "new");
|
||||
|
||||
StoredCredentials loaded = CredentialStore.Load(paper: true)!.Value;
|
||||
Assert.Equal("PKNEW", loaded.KeyId);
|
||||
Assert.Equal("new", loaded.SecretKey);
|
||||
StoredCredentials loaded = CredentialStore.Load(testnet: true)!.Value;
|
||||
Assert.Equal("PKNEW", loaded.ApiKey);
|
||||
Assert.Equal("new", loaded.ApiSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearRemovesOnlyTheRequestedEnvironment()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKPAPER", "paper-secret");
|
||||
CredentialStore.Save(paper: false, "AKLIVE", "live-secret");
|
||||
CredentialStore.Save(testnet: true, "PKPAPER", "testnet-secret");
|
||||
CredentialStore.Save(testnet: false, "AKLIVE", "live-secret");
|
||||
|
||||
Assert.True(CredentialStore.Clear(paper: true));
|
||||
Assert.True(CredentialStore.Clear(testnet: true));
|
||||
|
||||
Assert.Null(CredentialStore.Load(paper: true));
|
||||
Assert.NotNull(CredentialStore.Load(paper: false));
|
||||
Assert.Null(CredentialStore.Load(testnet: true));
|
||||
Assert.NotNull(CredentialStore.Load(testnet: false));
|
||||
Assert.True(CredentialStore.Exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearingTheLastEntryDeletesTheFile()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKPAPER", "secret");
|
||||
CredentialStore.Save(testnet: true, "PKPAPER", "secret");
|
||||
|
||||
Assert.True(CredentialStore.Clear(paper: true));
|
||||
Assert.True(CredentialStore.Clear(testnet: true));
|
||||
Assert.False(CredentialStore.Exists);
|
||||
Assert.False(CredentialStore.Clear(paper: true));
|
||||
Assert.False(CredentialStore.Clear(testnet: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearAllRemovesEverything()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKPAPER", "a");
|
||||
CredentialStore.Save(paper: false, "AKLIVE", "b");
|
||||
CredentialStore.Save(testnet: true, "PKPAPER", "a");
|
||||
CredentialStore.Save(testnet: false, "AKLIVE", "b");
|
||||
|
||||
Assert.True(CredentialStore.ClearAll());
|
||||
Assert.False(CredentialStore.Exists);
|
||||
Assert.Null(CredentialStore.Load(paper: true));
|
||||
Assert.Null(CredentialStore.Load(paper: false));
|
||||
Assert.Null(CredentialStore.Load(testnet: true));
|
||||
Assert.Null(CredentialStore.Load(testnet: false));
|
||||
Assert.False(CredentialStore.ClearAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecretsAreNotReadableAsPlainTextOnWindows()
|
||||
{
|
||||
CredentialStore.Save(paper: true, "PKTESTKEY", "supersecretvalue");
|
||||
CredentialStore.Save(testnet: true, "PKTESTKEY", "supersecretvalue");
|
||||
string onDisk = File.ReadAllText(CredentialStore.FilePath);
|
||||
|
||||
if (CredentialStore.IsEncrypted)
|
||||
@@ -127,26 +127,26 @@ public sealed class CredentialStoreTests : IDisposable
|
||||
Directory.CreateDirectory(_home);
|
||||
File.WriteAllBytes(CredentialStore.FilePath, [0x00, 0x01, 0x02, 0x03, 0x04]);
|
||||
|
||||
Assert.Null(CredentialStore.Load(paper: true));
|
||||
Assert.Null(CredentialStore.Load(testnet: true));
|
||||
|
||||
// And it must still be recoverable by saving again.
|
||||
CredentialStore.Save(paper: true, "PKNEW", "secret");
|
||||
Assert.Equal("PKNEW", CredentialStore.Load(paper: true)!.Value.KeyId);
|
||||
CredentialStore.Save(testnet: true, "PKNEW", "secret");
|
||||
Assert.Equal("PKNEW", CredentialStore.Load(testnet: true)!.Value.ApiKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveRejectsEmptyValues()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => CredentialStore.Save(paper: true, "", "secret"));
|
||||
Assert.Throws<ArgumentException>(() => CredentialStore.Save(paper: true, "PKKEY", " "));
|
||||
Assert.Throws<ArgumentException>(() => CredentialStore.Save(testnet: true, "", "secret"));
|
||||
Assert.Throws<ArgumentException>(() => CredentialStore.Save(testnet: true, "PKKEY", " "));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PKABCDEFGH1234", "PKAB**********")]
|
||||
[InlineData("PKABCDEFGH1234", "PKABCD********")]
|
||||
[InlineData("PKAB", "****")]
|
||||
[InlineData("ab", "**")]
|
||||
[InlineData("", "(empty)")]
|
||||
[InlineData(null, "(empty)")]
|
||||
[InlineData("", "(vuota)")]
|
||||
[InlineData(null, "(vuota)")]
|
||||
public void MaskKeepsOnlyThePrefix(string? input, string expected) =>
|
||||
Assert.Equal(expected, CredentialStore.Mask(input));
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Encelado.Core\Encelado.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Encelado.Alpaca\Encelado.Alpaca.csproj" />
|
||||
<ProjectReference Include="..\..\src\Encelado.Binance\Encelado.Binance.csproj" />
|
||||
<ProjectReference Include="..\..\src\Encelado.Bot\Encelado.Bot.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Engine;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class BarAggregatorTests
|
||||
{
|
||||
private static readonly DateTime Open = new(2026, 1, 5, 14, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void OneMinuteIsAPassthrough()
|
||||
{
|
||||
BarAggregator aggregator = new(1);
|
||||
Assert.True(aggregator.IsPassthrough);
|
||||
|
||||
Bar input = Minute(0, 100, 101, 99, 100.5, 1000);
|
||||
Assert.True(aggregator.TryAdd(input, out Bar closed));
|
||||
Assert.Equal(input, closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiveMinuteBucketsCloseWhenTheNextBucketStarts()
|
||||
{
|
||||
BarAggregator aggregator = new(5);
|
||||
|
||||
// 14:30..14:34 all fall in the same five-minute bucket.
|
||||
Assert.False(aggregator.TryAdd(Minute(0, 100, 102, 99, 101, 1000), out _));
|
||||
Assert.False(aggregator.TryAdd(Minute(1, 101, 105, 100, 104, 2000), out _));
|
||||
Assert.False(aggregator.TryAdd(Minute(2, 104, 104, 97, 98, 3000), out _));
|
||||
Assert.False(aggregator.TryAdd(Minute(3, 98, 99, 98, 99, 1000), out _));
|
||||
Assert.False(aggregator.TryAdd(Minute(4, 99, 100, 98, 100, 1000), out _));
|
||||
|
||||
// 14:35 belongs to the next bucket and closes the previous one.
|
||||
Assert.True(aggregator.TryAdd(Minute(5, 100, 101, 100, 101, 500), out Bar closed));
|
||||
|
||||
Assert.Equal(Open, closed.TimeUtc);
|
||||
Assert.Equal(100, closed.Open);
|
||||
Assert.Equal(105, closed.High);
|
||||
Assert.Equal(97, closed.Low);
|
||||
Assert.Equal(100, closed.Close);
|
||||
Assert.Equal(8000, closed.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetDiscardsThePartialBucket()
|
||||
{
|
||||
BarAggregator aggregator = new(5);
|
||||
aggregator.TryAdd(Minute(0, 100, 100, 100, 100, 10), out _);
|
||||
aggregator.Reset();
|
||||
|
||||
// After a reset the next bar starts a fresh bucket instead of closing the old one.
|
||||
Assert.False(aggregator.TryAdd(Minute(5, 200, 200, 200, 200, 10), out _));
|
||||
}
|
||||
|
||||
private static Bar Minute(int index, double o, double h, double l, double c, double v) =>
|
||||
new(Open.AddMinutes(index), o, h, l, c, v, c, 10);
|
||||
}
|
||||
|
||||
public class SymbolPipelineTests
|
||||
{
|
||||
private static SymbolPipeline NewPipeline() =>
|
||||
new(0, "AAPL", StrategyFactory.Create("trend-filter"), 1);
|
||||
|
||||
[Fact]
|
||||
public void TheEntryLatchIsExclusive()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
|
||||
Assert.True(pipe.TryClaimEntry());
|
||||
Assert.False(pipe.TryClaimEntry());
|
||||
Assert.True(pipe.EntryInFlight);
|
||||
|
||||
pipe.ReleaseEntry();
|
||||
Assert.False(pipe.EntryInFlight);
|
||||
Assert.True(pipe.TryClaimEntry());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheExitLatchIsIndependentOfTheEntryLatch()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
|
||||
Assert.True(pipe.TryClaimEntry());
|
||||
Assert.True(pipe.TryClaimExit());
|
||||
Assert.False(pipe.TryClaimExit());
|
||||
|
||||
pipe.ReleaseExit();
|
||||
Assert.True(pipe.EntryInFlight);
|
||||
Assert.False(pipe.ExitInFlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntryReferenceUsesTheFarTouch()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.OnQuote(new Quote(DateTime.UtcNow, 99.5, 100, 100.5, 100));
|
||||
|
||||
Assert.Equal(100.5, pipe.EntryReferencePrice(Side.Buy));
|
||||
Assert.Equal(99.5, pipe.EntryReferencePrice(Side.Sell));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntryReferenceFallsBackToTheLastPriceWithoutABook()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.OnTrade(new Tick(DateTime.UtcNow, 42, 100), takerBought: true, aggressorKnown: true);
|
||||
|
||||
Assert.Equal(42, pipe.EntryReferencePrice(Side.Buy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalStopFiresOnTheCorrectSide()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.LocalStop = 95;
|
||||
pipe.LocalTarget = 110;
|
||||
|
||||
Assert.False(pipe.ShouldExitLocally(100, 10, out _));
|
||||
Assert.True(pipe.ShouldExitLocally(94.5, 10, out string stopReason));
|
||||
Assert.Contains("local stop", stopReason, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.True(pipe.ShouldExitLocally(111, 10, out string targetReason));
|
||||
Assert.Contains("local target", targetReason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalStopIsMirroredForShorts()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.LocalStop = 105;
|
||||
|
||||
Assert.False(pipe.ShouldExitLocally(100, -10, out _));
|
||||
Assert.True(pipe.ShouldExitLocally(106, -10, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoProtectionMeansNoLocalExit()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
pipe.ClearProtection();
|
||||
|
||||
Assert.False(pipe.ShouldExitLocally(1, 10, out _));
|
||||
Assert.False(pipe.ShouldExitLocally(100, 0, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteAgeIsUnboundedUntilTheFirstQuote()
|
||||
{
|
||||
SymbolPipeline pipe = NewPipeline();
|
||||
Assert.Equal(TimeSpan.MaxValue, pipe.QuoteAge);
|
||||
|
||||
pipe.OnQuote(new Quote(DateTime.UtcNow, 99, 1, 101, 1));
|
||||
Assert.True(pipe.QuoteAge < TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigValidationTests
|
||||
{
|
||||
private static BotConfig MinimalConfig() => new()
|
||||
{
|
||||
Alpaca = { KeyId = "key", SecretKey = "secret" },
|
||||
Symbols = [new SymbolConfig { Symbol = "AAPL", Strategy = "trend-filter" }],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AcceptsAMinimalValidConfiguration()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
Assert.Same(config, config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsDuplicateSymbols()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
config.Symbols.Add(new SymbolConfig { Symbol = "aapl", Strategy = "trend-filter" });
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
Assert.Contains("more than once", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsUnknownStrategies()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
config.Symbols[0].Strategy = "moon-phase";
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
|
||||
// Il messaggio deve nominare il colpevole, le alternative e la via d'uscita:
|
||||
// questo errore lo incontra chi aggiorna, non chi sviluppa.
|
||||
Assert.Contains("moon-phase", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("trend-filter", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("Impostazioni", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAConfigurationWithNoEnabledSymbols()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
config.Symbols[0].Enabled = false;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CryptoRequiresFractionalShares()
|
||||
{
|
||||
BotConfig config = MinimalConfig();
|
||||
config.Engine.AssetClass = "crypto";
|
||||
config.Engine.AllowFractionalShares = false;
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(config.Validate);
|
||||
Assert.Contains("allowFractionalShares", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesAssetClassAndTimeframe()
|
||||
{
|
||||
EngineOptions options = new() { AssetClass = "crypto", TimeFrame = "5Min", AllowFractionalShares = true };
|
||||
|
||||
Assert.Equal(AssetClass.Crypto, options.ResolvedAssetClass);
|
||||
Assert.Equal(TimeFrame.FiveMinutes, options.ResolvedTimeFrame);
|
||||
Assert.True(options.UseLimitEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAnUnsupportedEntryOrderType()
|
||||
{
|
||||
EngineOptions options = new() { EntryOrderType = "iceberg" };
|
||||
Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadsSectionsSymbolsAndParameters()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-test-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path,
|
||||
"""
|
||||
{
|
||||
"alpaca": { "keyId": "abc", "secretKey": "def", "paper": true, "dataFeed": "sip" },
|
||||
"engine": { "timeFrame": "5Min", "warmupBars": 120, "dryRun": true },
|
||||
"risk": { "maxOpenPositions": 3, "maxRiskPerTradePct": 0.002 },
|
||||
"symbols": [
|
||||
{ "symbol": "AAPL", "strategy": "ema-cross", "parameters": { "fast": 9, "slow": 21 } },
|
||||
"MSFT"
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
BotConfig config = ConfigLoader.Load(path, out List<string> warnings);
|
||||
|
||||
Assert.Empty(warnings);
|
||||
Assert.Equal("abc", config.Alpaca.KeyId);
|
||||
Assert.Equal("sip", config.Alpaca.DataFeed);
|
||||
Assert.Equal(TimeFrame.FiveMinutes, config.Engine.ResolvedTimeFrame);
|
||||
Assert.Equal(120, config.Engine.WarmupBars);
|
||||
Assert.True(config.Engine.DryRun);
|
||||
Assert.Equal(3, config.Risk.MaxOpenPositions);
|
||||
Assert.Equal(0.002, config.Risk.MaxRiskPerTradePct);
|
||||
|
||||
Assert.Equal(2, config.Symbols.Count);
|
||||
Assert.Equal(9, config.Symbols[0].Parameters["fast"]);
|
||||
Assert.Equal("MSFT", config.Symbols[1].Symbol);
|
||||
Assert.Equal("ema-cross", config.Symbols[1].Strategy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportsUnknownKeysInsteadOfSilentlyIgnoringThem()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-test-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, """{ "engine": { "warmupBars": 10, "wramupBars": 10 } }""");
|
||||
|
||||
try
|
||||
{
|
||||
ConfigLoader.Load(path, out List<string> warnings);
|
||||
Assert.Contains(warnings, w => w.Contains("wramupBars", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMissingFileFallsBackToDefaultsWithAWarning()
|
||||
{
|
||||
BotConfig config = ConfigLoader.Load(
|
||||
Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.json"),
|
||||
out List<string> warnings);
|
||||
|
||||
Assert.Contains(warnings, w => w.Contains("not found", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(config.Alpaca.Paper);
|
||||
Assert.Empty(config.Symbols);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Encelado.Bot.Engine;
|
||||
using Encelado.Core.Backtest;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Portfolio;
|
||||
using Encelado.Core.Strategies;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bar alignment and the latches — the two pieces of engine plumbing that decide whether
|
||||
/// a signal is real and whether it can fire twice.
|
||||
/// </summary>
|
||||
public class PairPipelineTests
|
||||
{
|
||||
private static PairPipeline Build()
|
||||
{
|
||||
LegState a = new(0, "ETHUSDT");
|
||||
LegState b = new(1, "BTCUSDT");
|
||||
|
||||
StrategyParameters p = new StrategyParameters()
|
||||
.Set("entryZ", 2).Set("exitZ", 0.2).Set("stopZ", 3.5).Set("zWindow", 30);
|
||||
|
||||
return new PairPipeline(0, "ETHUSDT/BTCUSDT", a, b, new StatArbStrategy(p));
|
||||
}
|
||||
|
||||
private static Bar BarAt(DateTime time, double close) =>
|
||||
new(time, close, close, close, close, 1, close, 1);
|
||||
|
||||
[Fact]
|
||||
public void WillNotAlignTwoBarsFromDifferentInstants()
|
||||
{
|
||||
PairPipeline pipe = Build();
|
||||
DateTime t = new(2026, 8, 28, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
pipe.LegA.OnBarClosed(BarAt(t, 3000));
|
||||
pipe.LegB.OnBarClosed(BarAt(t.AddMinutes(-15), 60_000));
|
||||
|
||||
// Pairing 10:00 on one leg with 09:45 on the other measures fifteen minutes of
|
||||
// ordinary drift as a divergence: a signal manufactured out of message ordering.
|
||||
Assert.False(pipe.TryAlign(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlignsOnceBothLegsHaveClosedTheSameBar()
|
||||
{
|
||||
PairPipeline pipe = Build();
|
||||
DateTime t = new(2026, 8, 28, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
pipe.LegA.OnBarClosed(BarAt(t, 3000));
|
||||
Assert.False(pipe.TryAlign(out _, out _));
|
||||
|
||||
pipe.LegB.OnBarClosed(BarAt(t, 60_000));
|
||||
Assert.True(pipe.TryAlign(out Bar a, out Bar b));
|
||||
|
||||
Assert.Equal(3000, a.Close);
|
||||
Assert.Equal(60_000, b.Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecidesEachBarExactlyOnce()
|
||||
{
|
||||
PairPipeline pipe = Build();
|
||||
DateTime t = new(2026, 8, 28, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
pipe.LegA.OnBarClosed(BarAt(t, 3000));
|
||||
pipe.LegB.OnBarClosed(BarAt(t, 60_000));
|
||||
|
||||
Assert.True(pipe.TryAlign(out _, out _));
|
||||
pipe.MarkDecided(t);
|
||||
|
||||
// The second leg's event arrives moments after the first; without the latch it
|
||||
// would evaluate the same bar again.
|
||||
Assert.False(pipe.TryAlign(out _, out _));
|
||||
|
||||
pipe.LegA.OnBarClosed(BarAt(t.AddMinutes(15), 3010));
|
||||
pipe.LegB.OnBarClosed(BarAt(t.AddMinutes(15), 60_100));
|
||||
Assert.True(pipe.TryAlign(out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyOneEntryCanBeInFlightAtATime()
|
||||
{
|
||||
PairPipeline pipe = Build();
|
||||
|
||||
Assert.True(pipe.TryClaimEntry());
|
||||
Assert.False(pipe.TryClaimEntry());
|
||||
|
||||
pipe.ReleaseEntry();
|
||||
Assert.True(pipe.TryClaimEntry());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotsAPairWithOnlyOneLegOpen()
|
||||
{
|
||||
PairPipeline pipe = Build();
|
||||
PortfolioBook book = new();
|
||||
|
||||
book.Reconcile("ETHUSDT", 1.0, 3000, 3000);
|
||||
|
||||
// One leg open and the other flat is an unhedged, leveraged, directional
|
||||
// position nobody chose. It has to be detectable without waiting for a bar.
|
||||
Assert.True(pipe.IsHalfOpen(book));
|
||||
|
||||
book.Reconcile("BTCUSDT", -0.05, 60_000, 60_000);
|
||||
Assert.False(pipe.IsHalfOpen(book));
|
||||
Assert.True(pipe.IsOpen(book));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NetFundingIsCollectedOnTheShortLegAndPaidOnTheLong()
|
||||
{
|
||||
PairPipeline pipe = Build();
|
||||
|
||||
// Both rates positive: longs pay shorts.
|
||||
pipe.LegA.OnFunding(3000, 0.0004, DateTime.UtcNow);
|
||||
pipe.LegB.OnFunding(60_000, 0.0001, DateTime.UtcNow);
|
||||
|
||||
// Short A, long B: we collect A's rate and pay B's, weighted by notional.
|
||||
double shortA = pipe.NetFundingRate(Side.Sell, beta: 1.0);
|
||||
Assert.True(shortA > 0);
|
||||
|
||||
// The other way round is the mirror image.
|
||||
Assert.Equal(-shortA, pipe.NetFundingRate(Side.Buy, beta: 1.0), 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FundingWeightsFollowTheHedgeRatio()
|
||||
{
|
||||
PairPipeline pipe = Build();
|
||||
|
||||
pipe.LegA.OnFunding(3000, 0.001, DateTime.UtcNow);
|
||||
pipe.LegB.OnFunding(60_000, 0, DateTime.UtcNow);
|
||||
|
||||
// With β = 3 leg A carries a quarter of the notional, so its rate contributes a
|
||||
// quarter of what it would at β = 1.
|
||||
Assert.Equal(0.001 * 0.25, pipe.NetFundingRate(Side.Sell, beta: 3.0), 12);
|
||||
}
|
||||
}
|
||||
|
||||
public class PairAlignmentTests
|
||||
{
|
||||
private static Bar At(DateTime t, double close) => new(t, close, close, close, close, 1, close, 1);
|
||||
|
||||
[Fact]
|
||||
public void DropsBarsThatExistOnOneSideOnly()
|
||||
{
|
||||
DateTime start = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
List<Bar> a = [At(start, 1), At(start.AddMinutes(15), 2), At(start.AddMinutes(30), 3)];
|
||||
List<Bar> b = [At(start, 10), At(start.AddMinutes(30), 30)];
|
||||
|
||||
(List<Bar> outA, List<Bar> outB) = PairBacktest.Align(a, b);
|
||||
|
||||
// Pairing by index across a gap shifts one leg against the other for the rest of
|
||||
// the file, turning the whole spread series into noise without a single
|
||||
// obviously wrong number.
|
||||
Assert.Equal(2, outA.Count);
|
||||
Assert.Equal(outA.Count, outB.Count);
|
||||
Assert.All(outA.Zip(outB), pair => Assert.Equal(pair.First.TimeUtc, pair.Second.TimeUtc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropsBarsWithNoPrice()
|
||||
{
|
||||
DateTime start = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
List<Bar> a = [At(start, 1), At(start.AddMinutes(15), 0)];
|
||||
List<Bar> b = [At(start, 10), At(start.AddMinutes(15), 20)];
|
||||
|
||||
(List<Bar> outA, _) = PairBacktest.Align(a, b);
|
||||
|
||||
Assert.Single(outA);
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,7 @@ public class PowerButtonStateTests
|
||||
{
|
||||
private static MainViewModel Vm() => new() { Log = new LogViewModel(100) };
|
||||
|
||||
private static BotSnapshot Snapshot(BotState state) => new()
|
||||
{
|
||||
State = state,
|
||||
Mode = "PAPER",
|
||||
AssetClass = "crypto",
|
||||
TimeFrame = "1Day",
|
||||
Endpoint = "https://paper-api.alpaca.markets",
|
||||
SessionStatus = "aperto",
|
||||
MarketDataState = "connected",
|
||||
TradeStreamState = "connected",
|
||||
BarToSignal = "—",
|
||||
SignalToOrder = "—",
|
||||
};
|
||||
private static BotSnapshot Snapshot(BotState state) => TestSnapshots.Minimal(state);
|
||||
|
||||
[Theory]
|
||||
[InlineData(BotState.Stopped, true)]
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Encelado.Bot.Ui;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The chart is drawn by hand into a <see cref="DrawingContext"/>, so nothing about it
|
||||
/// is checked by the compiler or by the binding tests: a mistake in the scaling maths
|
||||
/// produces a blank panel, not an error. These render it to a bitmap and look at the
|
||||
/// pixels.
|
||||
/// </summary>
|
||||
[Collection("wpf")]
|
||||
public class PriceChartTests
|
||||
{
|
||||
private const int Width = 400;
|
||||
private const int Height = 200;
|
||||
|
||||
/// <summary>Renders the control offscreen and counts the pixels that are not transparent.</summary>
|
||||
private static int PaintedPixels(Action<PriceChart> configure)
|
||||
{
|
||||
int painted = 0;
|
||||
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
PriceChart chart = new();
|
||||
configure(chart);
|
||||
|
||||
chart.Measure(new Size(Width, Height));
|
||||
chart.Arrange(new Rect(0, 0, Width, Height));
|
||||
chart.UpdateLayout();
|
||||
|
||||
RenderTargetBitmap target = new(Width, Height, 96, 96, PixelFormats.Pbgra32);
|
||||
target.Render(chart);
|
||||
|
||||
byte[] pixels = new byte[Width * Height * 4];
|
||||
target.CopyPixels(pixels, Width * 4, 0);
|
||||
|
||||
for (int i = 3; i < pixels.Length; i += 4)
|
||||
{
|
||||
if (pixels[i] != 0)
|
||||
{
|
||||
painted++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return painted;
|
||||
}
|
||||
|
||||
private static double[] Ramp(int n, double start, double step)
|
||||
{
|
||||
double[] values = new double[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
values[i] = start + (i * step);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawsCandlesFromOhlcSeries()
|
||||
{
|
||||
int painted = PaintedPixels(chart =>
|
||||
{
|
||||
chart.Mode = PriceChartMode.Candles;
|
||||
chart.Opens = Ramp(30, 100, 1);
|
||||
chart.Highs = Ramp(30, 103, 1);
|
||||
chart.Lows = Ramp(30, 98, 1);
|
||||
chart.Closes = Ramp(30, 102, 1);
|
||||
});
|
||||
|
||||
Assert.True(painted > 500, $"il grafico a candele ha disegnato solo {painted} pixel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawsTheLiveLine()
|
||||
{
|
||||
int painted = PaintedPixels(chart =>
|
||||
{
|
||||
chart.Mode = PriceChartMode.Live;
|
||||
chart.LivePrices = Ramp(120, 100, 0.5);
|
||||
});
|
||||
|
||||
Assert.True(painted > 500, $"la linea in diretta ha disegnato solo {painted} pixel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallsBackToALineWhenTheCandlesWouldBeThinnerThanAPixel()
|
||||
{
|
||||
// 4000 candles across 400 pixels is a tenth of a pixel each: drawing bodies
|
||||
// would produce nothing visible, so the control switches to a close-only line.
|
||||
int painted = PaintedPixels(chart =>
|
||||
{
|
||||
chart.Mode = PriceChartMode.Candles;
|
||||
chart.Opens = Ramp(4000, 100, 0.01);
|
||||
chart.Highs = Ramp(4000, 101, 0.01);
|
||||
chart.Lows = Ramp(4000, 99, 0.01);
|
||||
chart.Closes = Ramp(4000, 100.5, 0.01);
|
||||
});
|
||||
|
||||
Assert.True(painted > 300, $"la ricaduta a linea ha disegnato solo {painted} pixel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowsTheEmptyMessageInsteadOfNothingWhenThereIsNoData()
|
||||
{
|
||||
int painted = PaintedPixels(chart =>
|
||||
{
|
||||
chart.Mode = PriceChartMode.Candles;
|
||||
chart.EmptyText = "in attesa di dati";
|
||||
});
|
||||
|
||||
// Some pixels: the message. Not many: no chart.
|
||||
Assert.InRange(painted, 50, 4_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFlatSeriesDoesNotDivideByZero()
|
||||
{
|
||||
double[] flat = new double[50];
|
||||
Array.Fill(flat, 42.0);
|
||||
|
||||
int painted = PaintedPixels(chart =>
|
||||
{
|
||||
chart.Mode = PriceChartMode.Live;
|
||||
chart.LivePrices = flat;
|
||||
});
|
||||
|
||||
Assert.True(painted > 200, "una serie piatta deve comunque disegnare una linea");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SurvivesRaggedSeriesRatherThanThrowing()
|
||||
{
|
||||
// Closes longer than the other three. Real snapshots are consistent, but a
|
||||
// half-updated binding must not be able to crash the UI thread.
|
||||
int painted = PaintedPixels(chart =>
|
||||
{
|
||||
chart.Mode = PriceChartMode.Candles;
|
||||
chart.Opens = Ramp(5, 100, 1);
|
||||
chart.Highs = Ramp(5, 103, 1);
|
||||
chart.Lows = Ramp(5, 98, 1);
|
||||
chart.Closes = Ramp(40, 102, 1);
|
||||
});
|
||||
|
||||
// Refuses to draw and says so, instead of indexing past the end.
|
||||
Assert.True(painted >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoresNonFiniteValuesInTheBounds()
|
||||
{
|
||||
double[] withHoles = [100, double.NaN, 102, double.PositiveInfinity, 104];
|
||||
|
||||
int painted = PaintedPixels(chart =>
|
||||
{
|
||||
chart.Mode = PriceChartMode.Live;
|
||||
chart.LivePrices = withHoles;
|
||||
});
|
||||
|
||||
Assert.True(painted > 0);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Reflection;
|
||||
using Encelado.Alpaca.Streaming;
|
||||
using Encelado.Binance.Streaming;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
@@ -93,7 +93,7 @@ public class ReconnectBackoffTests
|
||||
channel.Refuse("limite di connessioni superato");
|
||||
TimeSpan afterRefusal = channel.Backoff(1);
|
||||
|
||||
// Alpaca closes an unauthenticated socket after ten seconds. Retrying sooner
|
||||
// The server keeps a refused socket counted against us for ten seconds. Retrying sooner
|
||||
// means the next attempt starts while the refused one still holds the only slot
|
||||
// the account gets — which is precisely how the loop sustained itself.
|
||||
Assert.True(beforeRefusal < TimeSpan.FromSeconds(10),
|
||||
|
||||
@@ -1,304 +1,288 @@
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Risk;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
public class RiskEngineTests
|
||||
/// <summary>The gate every order has to pass, and the arithmetic that sizes a pair.</summary>
|
||||
public class PairRiskTests
|
||||
{
|
||||
private static RiskLimits PermissiveLimits() => new()
|
||||
private static RiskLimits Limits() => new()
|
||||
{
|
||||
MaxRiskPerTradePct = 0.01,
|
||||
MaxPositionNotionalPct = 1.0,
|
||||
MaxGrossExposurePct = 1.0,
|
||||
MaxOpenPositions = 10,
|
||||
MaxTradesPerDay = 100,
|
||||
MaxTradesPerSymbolPerDay = 10,
|
||||
MaxDailyLossPct = 0.05,
|
||||
MinSecondsBetweenEntries = 0,
|
||||
MaxRelativeSpread = 0,
|
||||
MinPrice = 1,
|
||||
MaxPrice = 10_000,
|
||||
MinOrderNotional = 1,
|
||||
DefaultStopPct = 0.02,
|
||||
MaxStopDistancePct = 0.5,
|
||||
StakePct = 0.20,
|
||||
StakeAmount = 0,
|
||||
FundingTiltPct = 0.10,
|
||||
FundingTiltThreshold = 0.0001,
|
||||
MaxGrossExposurePct = 4.0,
|
||||
MaxOpenPairs = 4,
|
||||
MaxTradesPerDay = 40,
|
||||
MaxTradesPerPairPerDay = 8,
|
||||
MinSecondsBetweenEntries = 60,
|
||||
MaxDailyLossPct = 0.06,
|
||||
MaxRelativeSpread = 0.001,
|
||||
MinOrderNotional = 10,
|
||||
MaxMarginRatio = 0.5,
|
||||
MaxHedgeRatio = 5,
|
||||
};
|
||||
|
||||
private static EntryRequest Request(
|
||||
RiskLimits _,
|
||||
double price = 100,
|
||||
double stop = 98,
|
||||
double equity = 100_000,
|
||||
double buyingPower = 1_000_000,
|
||||
double grossExposure = 0,
|
||||
int openPositions = 0,
|
||||
double existingQuantity = 0,
|
||||
double spread = 0,
|
||||
Side side = Side.Buy) =>
|
||||
new("TEST", side, price, stop, 1.0, equity, buyingPower, grossExposure, openPositions,
|
||||
existingQuantity, spread, false, DateTime.UtcNow);
|
||||
private static PairEntryRequest Request(
|
||||
double beta = 1.0,
|
||||
double equity = 10_000,
|
||||
double available = 10_000,
|
||||
double exposure = 0,
|
||||
int openPairs = 0,
|
||||
double spreadA = 0.0001,
|
||||
double spreadB = 0.0001,
|
||||
double funding = 0,
|
||||
double marginRatio = 0,
|
||||
int leverage = 2,
|
||||
bool alreadyOpen = false) =>
|
||||
new("ETHUSDT/BTCUSDT", 3000, 60_000, beta, equity, available, exposure, openPairs,
|
||||
spreadA, spreadB, funding, marginRatio, leverage, alreadyOpen, DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void SizesFromRiskPerShare()
|
||||
private static RiskEngine Engine(RiskLimits? limits = null)
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
// 1% of 100k = 1000 risk budget; 2 per share of risk => 500 shares.
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits));
|
||||
|
||||
Assert.True(verdict.Approved, verdict.Detail);
|
||||
Assert.Equal(500, verdict.Quantity);
|
||||
Assert.Equal(98, verdict.StopPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapsSizeByPositionNotional()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxPositionNotionalPct = 0.10;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
// 10% of 100k = 10 000 notional at 100 => 100 shares, well under the risk-based 500.
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits));
|
||||
|
||||
Assert.True(verdict.Approved, verdict.Detail);
|
||||
Assert.Equal(100, verdict.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapsSizeByRemainingGrossExposure()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxGrossExposurePct = 0.5;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
// Exposure budget is 50 000 and 45 000 is already used: 5 000 left => 50 shares.
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, grossExposure: 45_000));
|
||||
|
||||
Assert.True(verdict.Approved, verdict.Detail);
|
||||
Assert.Equal(50, verdict.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundsDownToWholeSharesUnlessFractionalIsAllowed()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
RiskEngine engine = new(limits);
|
||||
RiskEngine engine = new(limits ?? Limits());
|
||||
engine.StartSession(10_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
// 1% of 10 000 = 100 risk; 3 per share => 33.33 shares.
|
||||
RiskVerdict whole = engine.ApproveEntry(Request(limits, price: 100, stop: 97, equity: 10_000));
|
||||
Assert.Equal(33, whole.Quantity);
|
||||
|
||||
EntryRequest fractional = Request(limits, price: 100, stop: 97, equity: 10_000) with
|
||||
{
|
||||
AllowFractional = true,
|
||||
Symbol = "FRAC",
|
||||
};
|
||||
RiskVerdict partial = engine.ApproveEntry(fractional);
|
||||
Assert.True(partial.Quantity > 33 && partial.Quantity < 34, $"was {partial.Quantity}");
|
||||
return engine;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallsBackToThePercentageStopWhenTheStrategyGivesNone()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
// -----------------------------------------------------------------------
|
||||
// Sizing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, stop: double.NaN));
|
||||
[Fact]
|
||||
public void SplitsTheNotionalSoTheTwoLegsCancel()
|
||||
{
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(beta: 1.5));
|
||||
|
||||
Assert.True(verdict.Approved, verdict.Detail);
|
||||
Assert.Equal(98, verdict.StopPrice, 10);
|
||||
|
||||
// The spread is ln(A) − β·ln(B), so a 1% move in B moves it by β%. Leg B must
|
||||
// therefore carry β times leg A's notional, or the position keeps a direction.
|
||||
Assert.Equal(1.5, verdict.NotionalB / verdict.NotionalA, 6);
|
||||
|
||||
// 20% of 10 000 as margin, at 2x leverage.
|
||||
Assert.Equal(4000, verdict.TotalNotional, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsAStopOnTheWrongSideOfTheEntry()
|
||||
public void EqualLegsWhenTheHedgeRatioIsOne()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
// A long stop above the entry is nonsense; the engine falls back to the default stop.
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, price: 100, stop: 105));
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(beta: 1.0));
|
||||
|
||||
Assert.True(verdict.Approved);
|
||||
Assert.Equal(98, verdict.StopPrice, 10);
|
||||
Assert.Equal(verdict.NotionalA, verdict.NotionalB, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsStopsFurtherThanTheDistanceLimit()
|
||||
public void AFixedStakeOverridesThePercentage()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxStopDistancePct = 0.05;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakeAmount = 500;
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, price: 100, stop: 80));
|
||||
PairVerdict verdict = Engine(limits).ApprovePair(Request(leverage: 3));
|
||||
|
||||
Assert.True(verdict.Approved);
|
||||
Assert.Equal(1500, verdict.TotalNotional, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FundingWeCollectMakesThePositionLarger()
|
||||
{
|
||||
RiskEngine engine = Engine();
|
||||
|
||||
double flat = engine.ApprovePair(Request(funding: 0)).TotalNotional;
|
||||
double paid = engine.ApprovePair(Request(funding: -0.0005)).TotalNotional;
|
||||
double earned = engine.ApprovePair(Request(funding: 0.0005)).TotalNotional;
|
||||
|
||||
Assert.Equal(flat * 1.10, earned, 6);
|
||||
Assert.Equal(flat * 0.90, paid, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FundingNoiseDoesNotMoveTheSize()
|
||||
{
|
||||
RiskEngine engine = Engine();
|
||||
|
||||
double flat = engine.ApprovePair(Request(funding: 0)).TotalNotional;
|
||||
|
||||
// Rates hover around ±0.01% most of the time. Tilting on that is churn.
|
||||
Assert.Equal(flat, engine.ApprovePair(Request(funding: 0.00005)).TotalNotional, 6);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Refusals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RefusesAHedgeRatioThatIsNotAHedge()
|
||||
{
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(beta: 12));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.InvalidStop, verdict.Reason);
|
||||
Assert.Equal(RiskReject.HedgeRatioUnusable, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsWhenAlreadyHoldingTheSymbol()
|
||||
public void RefusesANegativeHedgeRatio()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
// A negative β means the fit says the legs move opposite ways: "hedging" one
|
||||
// with the other would double the exposure rather than cancel it.
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(beta: -1.0));
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, existingQuantity: 10));
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.HedgeRatioUnusable, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesAWideBook()
|
||||
{
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(spreadB: 0.01));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.SpreadTooWide, verdict.Reason);
|
||||
|
||||
// The message has to name the number and the limit, or the operator cannot act.
|
||||
Assert.Contains("quattro volte", verdict.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesWhenTheAccountIsCloseToLiquidation()
|
||||
{
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(marginRatio: 0.8));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.MarginRatioTooHigh, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesOnceTheExposureCapIsReached()
|
||||
{
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(exposure: 40_000));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.ExposureLimit, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapsRatherThanRefusingWhenExposureIsMerelyTight()
|
||||
{
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(exposure: 38_000));
|
||||
|
||||
Assert.True(verdict.Approved);
|
||||
Assert.Equal(2000, verdict.TotalNotional, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesWhenTheCapAndTheOpenPairsSayEnough()
|
||||
{
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(openPairs: 4));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.MaxOpenPairs, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesAPairThatIsAlreadyOpen()
|
||||
{
|
||||
PairVerdict verdict = Engine().ApprovePair(Request(alreadyOpen: true));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.AlreadyInPosition, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsShortsWhenShortingIsDisabled()
|
||||
public void RefusesLegsTooSmallToBeWorthSending()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.AllowShorting = false;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
RiskLimits limits = Limits();
|
||||
limits.MinOrderNotional = 500;
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, price: 100, stop: 102, side: Side.Sell));
|
||||
PairVerdict verdict = Engine(limits).ApprovePair(Request(equity: 200, available: 200));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.ShortingDisabled, verdict.Reason);
|
||||
Assert.Equal(RiskReject.SizeTooSmall, verdict.Reason);
|
||||
Assert.Contains("alza lo stake", verdict.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsWideSpreads()
|
||||
public void HonoursTheCooldownBetweenEntriesOnTheSamePair()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxRelativeSpread = 0.001;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
RiskEngine engine = Engine();
|
||||
engine.RecordEntry("ETHUSDT/BTCUSDT", DateTime.UtcNow);
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, spread: 0.01));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.SpreadTooWide, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforcesThePerSymbolCooldown()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MinSecondsBetweenEntries = 60;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
engine.RecordEntry("TEST", DateTime.UtcNow);
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits));
|
||||
PairVerdict verdict = engine.ApprovePair(Request());
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.Cooldown, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforcesTheDailyTradeCap()
|
||||
public void CountsTradesPerPairSeparately()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxTradesPerDay = 2;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
RiskLimits limits = Limits();
|
||||
limits.MaxTradesPerPairPerDay = 2;
|
||||
limits.MinSecondsBetweenEntries = 0;
|
||||
|
||||
engine.RecordEntry("A", DateTime.UtcNow);
|
||||
engine.RecordEntry("B", DateTime.UtcNow);
|
||||
RiskEngine engine = Engine(limits);
|
||||
engine.RecordEntry("ETHUSDT/BTCUSDT", DateTime.UtcNow);
|
||||
engine.RecordEntry("ETHUSDT/BTCUSDT", DateTime.UtcNow);
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits));
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.MaxDailyTrades, verdict.Reason);
|
||||
Assert.Equal(RiskReject.MaxPairTrades, engine.ApprovePair(Request()).Reason);
|
||||
|
||||
// A different pair is unaffected.
|
||||
PairEntryRequest other = Request() with { PairName = "SOLUSDT/AVAXUSDT" };
|
||||
Assert.True(engine.ApprovePair(other).Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforcesTheOpenPositionCap()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxOpenPositions = 2;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, openPositions: 2));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.MaxOpenPositions, verdict.Reason);
|
||||
}
|
||||
// -----------------------------------------------------------------------
|
||||
// Kill switch
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void KillSwitchTripsOnTheDailyLossLimit()
|
||||
public void HaltsOnTheDailyLossLimitAndRefusesEverythingAfter()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxDailyLossPct = 0.03;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
RiskEngine engine = Engine();
|
||||
|
||||
Assert.False(engine.UpdateEquity(98_000));
|
||||
Assert.False(engine.IsHalted);
|
||||
|
||||
Assert.True(engine.UpdateEquity(96_900));
|
||||
Assert.False(engine.UpdateEquity(9_800));
|
||||
Assert.True(engine.UpdateEquity(9_300));
|
||||
Assert.True(engine.IsHalted);
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits));
|
||||
PairVerdict verdict = engine.ApprovePair(Request());
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.TradingHalted, verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KillSwitchTripsOnTheProfitTargetWhenConfigured()
|
||||
public void ANewSessionClearsTheHalt()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxDailyProfitPct = 0.02;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
Assert.True(engine.UpdateEquity(102_500));
|
||||
RiskEngine engine = Engine();
|
||||
engine.UpdateEquity(9_000);
|
||||
Assert.True(engine.IsHalted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartingANewSessionClearsCountersAndTheHalt()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
engine.RecordEntry("TEST", DateTime.UtcNow);
|
||||
engine.Halt("manual");
|
||||
Assert.True(engine.IsHalted);
|
||||
Assert.Equal(1, engine.TradesToday);
|
||||
|
||||
engine.StartSession(99_000, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)));
|
||||
engine.StartSession(9_000, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)));
|
||||
|
||||
Assert.False(engine.IsHalted);
|
||||
Assert.Equal(0, engine.TradesToday);
|
||||
Assert.Equal(99_000, engine.SessionStartEquity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsOrdersUnderTheMinimumNotional()
|
||||
public void TheDailyLossLimitCannotBeSwitchedOff()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MinOrderNotional = 5_000;
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(10_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
RiskLimits limits = Limits();
|
||||
limits.MaxDailyLossPct = 0;
|
||||
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request(limits, price: 100, stop: 90, equity: 10_000));
|
||||
|
||||
Assert.False(verdict.Approved);
|
||||
Assert.Equal(RiskReject.SizeTooSmall, verdict.Reason);
|
||||
// Zero would mean "no limit" for every other cap in this file. Here it has to be
|
||||
// a validation failure: on leveraged futures it is the last stop before a
|
||||
// liquidation, and a configuration that quietly removed it would look fine.
|
||||
Assert.Throws<InvalidOperationException>(limits.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidLimitsAreRejectedUpFront()
|
||||
public void RequiresAStakeToSizeAgainst()
|
||||
{
|
||||
RiskLimits limits = PermissiveLimits();
|
||||
limits.MaxRiskPerTradePct = 0.9;
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakePct = 0;
|
||||
limits.StakeAmount = 0;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => new RiskEngine(limits));
|
||||
Assert.Throws<InvalidOperationException>(limits.Validate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Ui;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>Editing the configuration by dotted path, as the settings form does.</summary>
|
||||
public class ConfigPathWriterTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _files = [];
|
||||
|
||||
private string Write(string content)
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), $"encelado-path-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, content);
|
||||
_files.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (string f in _files)
|
||||
{
|
||||
File.Delete(f);
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private const string Sample =
|
||||
"""
|
||||
{
|
||||
"_comment": "documentazione da conservare",
|
||||
"engine": { "timeFrame": "1Day", "warmupBars": 220 },
|
||||
"risk": { "_note": "spiegazione", "stakePct": 1.0, "maxOpenPositions": 0 },
|
||||
"logging": { "level": "debug" },
|
||||
"symbols": [
|
||||
{ "symbol": "BTC/USD", "strategy": "trend-filter",
|
||||
"parameters": { "_p": "commento", "period": 100, "band": 0.02 } }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void SetsANestedValue()
|
||||
{
|
||||
string path = Write(Sample);
|
||||
|
||||
ConfigWriter.Apply(path, new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["risk.stakePct"] = JsonValue.Create(0.5),
|
||||
});
|
||||
|
||||
Assert.Equal(0.5, ConfigLoader.Load(path, out _).Risk.StakePct, 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetsAValueInsideAnArrayElement()
|
||||
{
|
||||
string path = Write(Sample);
|
||||
|
||||
ConfigWriter.Apply(path, new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["symbols[0].parameters.period"] = JsonValue.Create(150),
|
||||
});
|
||||
|
||||
BotConfig reloaded = ConfigLoader.Load(path, out _);
|
||||
Assert.Equal(150, reloaded.Symbols[0].Parameters["period"], 9);
|
||||
|
||||
// The sibling parameter is untouched.
|
||||
Assert.Equal(0.02, reloaded.Symbols[0].Parameters["band"], 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppliesSeveralChangesAtOnce()
|
||||
{
|
||||
string path = Write(Sample);
|
||||
|
||||
ConfigWriter.Apply(path, new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["risk.stakePct"] = JsonValue.Create(0.4),
|
||||
["engine.warmupBars"] = JsonValue.Create(300),
|
||||
["symbols[0].parameters.band"] = JsonValue.Create(0.05),
|
||||
["logging.level"] = JsonValue.Create("info"),
|
||||
});
|
||||
|
||||
BotConfig c = ConfigLoader.Load(path, out _);
|
||||
Assert.Equal(0.4, c.Risk.StakePct, 9);
|
||||
Assert.Equal(300, c.Engine.WarmupBars);
|
||||
Assert.Equal(0.05, c.Symbols[0].Parameters["band"], 9);
|
||||
Assert.Equal("info", c.Logging.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeepsEveryCommentAndUntouchedKey()
|
||||
{
|
||||
string path = Write(Sample);
|
||||
|
||||
ConfigWriter.Apply(path, new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["risk.stakePct"] = JsonValue.Create(0.25),
|
||||
});
|
||||
|
||||
string after = File.ReadAllText(path);
|
||||
Assert.Contains("_comment", after, StringComparison.Ordinal);
|
||||
Assert.Contains("documentazione da conservare", after, StringComparison.Ordinal);
|
||||
Assert.Contains("_note", after, StringComparison.Ordinal);
|
||||
Assert.Contains("_p", after, StringComparison.Ordinal);
|
||||
Assert.Contains("trend-filter", after, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatesAMissingIntermediateObject()
|
||||
{
|
||||
string path = Write("""{"engine":{"timeFrame":"1Day"}}""");
|
||||
|
||||
ConfigWriter.Apply(path, new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["logging.level"] = JsonValue.Create("warn"),
|
||||
});
|
||||
|
||||
Assert.Equal("warn", ConfigLoader.Load(path, out _).Logging.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefusesAnArrayIndexThatDoesNotExist()
|
||||
{
|
||||
string path = Write(Sample);
|
||||
string before = File.ReadAllText(path);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => ConfigWriter.Apply(path,
|
||||
new Dictionary<string, JsonNode?> { ["symbols[7].symbol"] = JsonValue.Create("ETH/USD") }));
|
||||
|
||||
// Nothing written: inventing a symbol out of a typo would be worse than refusing.
|
||||
Assert.Equal(before, File.ReadAllText(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFailedChangeInABatchLeavesTheFileUntouched()
|
||||
{
|
||||
string path = Write(Sample);
|
||||
string before = File.ReadAllText(path);
|
||||
|
||||
Assert.ThrowsAny<Exception>(() => ConfigWriter.Apply(path, new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["risk.stakePct"] = JsonValue.Create(0.4),
|
||||
["symbols[9].parameters.period"] = JsonValue.Create(50),
|
||||
}));
|
||||
|
||||
Assert.Equal(before, File.ReadAllText(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEmptyBatchDoesNothing()
|
||||
{
|
||||
string path = Write(Sample);
|
||||
string before = File.ReadAllText(path);
|
||||
|
||||
ConfigWriter.Apply(path, new Dictionary<string, JsonNode?>());
|
||||
|
||||
Assert.Equal(before, File.ReadAllText(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoStessoLottoSiPuoApplicareDueVolte()
|
||||
{
|
||||
// La pagina delle impostazioni lo fa sempre: una volta su una copia temporanea
|
||||
// per validare, una sul file vero. Senza clonare, la seconda falliva con
|
||||
// «The node already has a parent» — un JsonNode appartiene a un albero solo.
|
||||
string primo = Write(Sample);
|
||||
string secondo = Write(Sample);
|
||||
|
||||
Dictionary<string, JsonNode?> modifiche = new()
|
||||
{
|
||||
["symbols[0].strategy"] = JsonValue.Create("trend-filter"),
|
||||
["risk.stakePct"] = JsonValue.Create(0.5),
|
||||
};
|
||||
|
||||
ConfigWriter.Apply(primo, modifiche);
|
||||
ConfigWriter.Apply(secondo, modifiche);
|
||||
|
||||
foreach (string percorso in new[] { primo, secondo })
|
||||
{
|
||||
BotConfig c = ConfigLoader.Load(percorso, out _);
|
||||
Assert.Equal("trend-filter", c.Symbols[0].Strategy);
|
||||
Assert.Equal(0.5, c.Risk.StakePct, 9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsing, validation and formatting of one form field. The percentage conversion in
|
||||
/// particular is where a silent factor of 100 would hide.
|
||||
/// </summary>
|
||||
public class SettingFieldTests
|
||||
{
|
||||
private static SettingField Field(SettingKind kind, string value, double min = double.NegativeInfinity,
|
||||
double max = double.PositiveInfinity) => new()
|
||||
{
|
||||
Path = "test.value",
|
||||
Label = "prova",
|
||||
Tooltip = "spiegazione",
|
||||
Initial = value,
|
||||
Kind = kind,
|
||||
Minimum = min,
|
||||
Maximum = max,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void APercentIsShownOutOfAHundredAndStoredAsAFraction()
|
||||
{
|
||||
SettingField field = Field(SettingKind.Percent, "2");
|
||||
|
||||
Assert.Equal(0.02, field.ToJson()!.GetValue<double>(), 9);
|
||||
Assert.Equal("2", SettingField.Format(0.02, SettingKind.Percent));
|
||||
Assert.Equal("100", SettingField.Format(1.0, SettingKind.Percent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APercentRoundTripsWithoutFloatingPointDebris()
|
||||
{
|
||||
// 20/100 in binary is 0.200000000000000011. Writing that into a file a human
|
||||
// edits by hand is unkind, and it makes every diff look like a change.
|
||||
SettingField field = Field(SettingKind.Percent, "20");
|
||||
|
||||
Assert.Equal("0.2", field.ToJson()!.ToJsonString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFieldStartsClean()
|
||||
{
|
||||
SettingField field = Field(SettingKind.Integer, "100");
|
||||
|
||||
Assert.False(field.IsDirty);
|
||||
Assert.False(field.HasError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditingMarksItDirtyAndRevertingUndoesIt()
|
||||
{
|
||||
SettingField field = Field(SettingKind.Integer, "100");
|
||||
|
||||
field.Value = "150";
|
||||
Assert.True(field.IsDirty);
|
||||
|
||||
field.Revert();
|
||||
Assert.False(field.IsDirty);
|
||||
Assert.Equal("100", field.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SettingKind.Integer, "abc")]
|
||||
[InlineData(SettingKind.Integer, "1.5")]
|
||||
[InlineData(SettingKind.Number, "non un numero")]
|
||||
[InlineData(SettingKind.Text, "")]
|
||||
public void RejectsAValueOfTheWrongShape(SettingKind kind, string value)
|
||||
{
|
||||
SettingField field = Field(kind, "1");
|
||||
field.Value = value;
|
||||
|
||||
Assert.True(field.HasError, $"'{value}' doveva essere rifiutato");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforcesTheRange()
|
||||
{
|
||||
SettingField field = Field(SettingKind.Integer, "100", min: 5, max: 400);
|
||||
|
||||
field.Value = "4";
|
||||
Assert.True(field.HasError);
|
||||
|
||||
field.Value = "401";
|
||||
Assert.True(field.HasError);
|
||||
|
||||
field.Value = "200";
|
||||
Assert.False(field.HasError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AReadOnlyFieldIsNeverDirtyAndNeverInvalid()
|
||||
{
|
||||
SettingField field = new()
|
||||
{
|
||||
Path = "symbols[0].symbol",
|
||||
Label = "Asset",
|
||||
Tooltip = "spiegazione",
|
||||
Initial = "BTC/USD",
|
||||
Kind = SettingKind.Text,
|
||||
IsReadOnly = true,
|
||||
ReadOnlyReason = "tarata su BTC",
|
||||
};
|
||||
|
||||
field.Value = "";
|
||||
|
||||
Assert.False(field.IsDirty);
|
||||
Assert.False(field.HasError);
|
||||
Assert.Contains("NON MODIFICABILE", field.FullTooltip, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AChoiceOnlyAcceptsItsChoices()
|
||||
{
|
||||
SettingField field = new()
|
||||
{
|
||||
Path = "logging.level",
|
||||
Label = "Dettaglio",
|
||||
Tooltip = "spiegazione",
|
||||
Initial = "debug",
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["trace", "debug", "info"],
|
||||
};
|
||||
|
||||
field.Value = "verbose";
|
||||
Assert.True(field.HasError);
|
||||
|
||||
field.Value = "info";
|
||||
Assert.False(field.HasError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BooleansAcceptItalian()
|
||||
{
|
||||
SettingField field = Field(SettingKind.Boolean, "no");
|
||||
|
||||
field.Value = "sì";
|
||||
Assert.False(field.HasError);
|
||||
Assert.True(field.ToJson()!.GetValue<bool>());
|
||||
|
||||
field.Value = "no";
|
||||
Assert.False(field.ToJson()!.GetValue<bool>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The catalogue itself: every field must be addressable and explained.</summary>
|
||||
public class SettingsCatalogueTests
|
||||
{
|
||||
private static BotConfig Config()
|
||||
{
|
||||
BotConfig c = new();
|
||||
c.Symbols.Add(new SymbolConfig
|
||||
{
|
||||
Symbol = "BTC/USD",
|
||||
Strategy = "trend-filter",
|
||||
Enabled = true,
|
||||
Parameters = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["period"] = 100,
|
||||
["band"] = 0.02,
|
||||
["stopPct"] = 0.35,
|
||||
},
|
||||
});
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryFieldHasASubstantialExplanation()
|
||||
{
|
||||
foreach (SettingGroup group in SettingsCatalogue.Build(Config(), "trend-filter"))
|
||||
{
|
||||
foreach (SettingField field in group.Fields)
|
||||
{
|
||||
// The whole point of the page. A one-liner is not an explanation.
|
||||
Assert.True(field.Tooltip.Length > 80,
|
||||
$"{field.Path} ha una spiegazione di soli {field.Tooltip.Length} caratteri");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryReadOnlyFieldSaysWhy()
|
||||
{
|
||||
foreach (SettingGroup group in SettingsCatalogue.Build(Config(), "trend-filter"))
|
||||
{
|
||||
foreach (SettingField field in group.Fields.Where(static f => f.IsReadOnly))
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(field.ReadOnlyReason),
|
||||
$"{field.Path} è bloccato senza dire perché");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathsAreUniqueAndWellFormed()
|
||||
{
|
||||
List<SettingField> fields =
|
||||
[.. SettingsCatalogue.Build(Config(), "trend-filter").SelectMany(static g => g.Fields)];
|
||||
|
||||
Assert.Equal(fields.Count, fields.Select(static f => f.Path).Distinct(StringComparer.Ordinal).Count());
|
||||
Assert.All(fields, static f => Assert.Contains('.', f.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheEditableFieldsCoverTheValuesThatMatter()
|
||||
{
|
||||
List<string> paths =
|
||||
[.. SettingsCatalogue.Build(Config(), "trend-filter")
|
||||
.SelectMany(static g => g.Fields)
|
||||
.Where(static f => !f.IsReadOnly)
|
||||
.Select(static f => f.Path)];
|
||||
|
||||
foreach (string expected in new[]
|
||||
{
|
||||
"symbols[0].parameters.period",
|
||||
"symbols[0].parameters.band",
|
||||
"symbols[0].parameters.stopPct",
|
||||
"risk.stakePct",
|
||||
"risk.maxDailyLossPct",
|
||||
"risk.maxOpenPositions",
|
||||
"risk.maxTradesPerDay",
|
||||
"logging.level",
|
||||
})
|
||||
{
|
||||
Assert.Contains(expected, paths, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldsLoadTheValuesActuallyInTheConfiguration()
|
||||
{
|
||||
BotConfig config = Config();
|
||||
config.Risk.StakePct = 0.6;
|
||||
|
||||
SettingField stake = SettingsCatalogue.Build(config, "trend-filter")
|
||||
.SelectMany(static g => g.Fields)
|
||||
.First(static f => f.Path == "risk.stakePct");
|
||||
|
||||
Assert.Equal("60", stake.Value);
|
||||
Assert.False(stake.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APercentFieldWrittenBackYieldsTheOriginalFraction()
|
||||
{
|
||||
BotConfig config = Config();
|
||||
config.Risk.MaxDailyLossPct = 0.25;
|
||||
|
||||
SettingField loss = SettingsCatalogue.Build(config, "trend-filter")
|
||||
.SelectMany(static g => g.Fields)
|
||||
.First(static f => f.Path == "risk.maxDailyLossPct");
|
||||
|
||||
Assert.Equal("25", loss.Value);
|
||||
Assert.Equal(0.25, loss.ToJson()!.GetValue<double>(), 9);
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Ui;
|
||||
using Encelado.Core.Strategies;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Chiudere una finestra mentre uno spegnimento asincrono è in corso.
|
||||
/// <para>
|
||||
/// Il difetto: il gestore annullava la chiusura, aspettava lo spegnimento e poi
|
||||
/// richiamava <c>Close()</c>. Un secondo clic sulla X durante l'attesa usciva dal
|
||||
/// gestore <b>senza</b> annullare, la finestra entrava nella propria sequenza di
|
||||
/// chiusura, e la <c>Close()</c> del primo tentativo ci finiva dentro:
|
||||
/// <c>«Non è possibile […] chiamare Close durante la chiusura di un oggetto Window»</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection("wpf")]
|
||||
public class WindowShutdownTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Il meccanismo esatto del difetto: <c>Close()</c> chiamata mentre la finestra è
|
||||
/// dentro la propria sequenza di chiusura non è ammessa, e non lo è nemmeno dopo
|
||||
/// aver annullato — l'annullamento vale per l'uscita dal gestore, non per il tempo
|
||||
/// in cui il gestore sta girando.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ChiudereDentroIlGestoreDiChiusuraSollevaEccezione()
|
||||
{
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
Window finestra = new();
|
||||
Exception? errore = null;
|
||||
|
||||
finestra.Closing += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
|
||||
try
|
||||
{
|
||||
// È qui che finiva la Close() del primo tentativo quando un secondo
|
||||
// clic la faceva riprendere troppo presto.
|
||||
finestra.Close();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errore = ex;
|
||||
}
|
||||
};
|
||||
|
||||
finestra.Close();
|
||||
|
||||
Assert.NotNull(errore);
|
||||
Assert.Contains("Close", errore!.Message, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La correzione: la chiusura vera si rimanda a un frame nuovo del dispatcher, così
|
||||
/// non può mai eseguire dentro il gestore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RimandarlaAUnFrameNuovoNonSollevaNiente()
|
||||
{
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
Window finestra = new();
|
||||
Exception? errore = null;
|
||||
bool chiusa = false;
|
||||
|
||||
finestra.Closing += (_, e) =>
|
||||
{
|
||||
if (chiusa)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = true;
|
||||
|
||||
_ = finestra.Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
chiusa = true;
|
||||
finestra.Close();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errore = ex;
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
finestra.Close();
|
||||
|
||||
// Fa girare la coda del dispatcher fino a quando la chiusura rimandata è
|
||||
// stata eseguita.
|
||||
finestra.Dispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
|
||||
|
||||
Assert.Null(errore);
|
||||
Assert.True(chiusa);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La distinzione che serve fra i due tipi di chiusura: quelle dell'utente durante
|
||||
/// lo spegnimento vanno annullate, quella finale no.
|
||||
/// <para>
|
||||
/// Annullarle tutte è il modo ovvio di correggere il difetto originale, ed è
|
||||
/// sbagliato: la chiusura finale ripassa dallo stesso gestore, viene annullata
|
||||
/// anche lei, e la finestra non si chiude più. Il bot resta aperto per sempre —
|
||||
/// un difetto peggiore di quello che si voleva correggere.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LaChiusuraFinalePassaMentreQuelleDellUtenteNo()
|
||||
{
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
Window finestra = new();
|
||||
bool inChiusura = false;
|
||||
bool finale = false;
|
||||
int annullate = 0;
|
||||
|
||||
finestra.Closing += (_, e) =>
|
||||
{
|
||||
if (inChiusura)
|
||||
{
|
||||
if (!finale)
|
||||
{
|
||||
annullate++;
|
||||
e.Cancel = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
inChiusura = true;
|
||||
e.Cancel = true;
|
||||
};
|
||||
|
||||
finestra.Close(); // primo tentativo: prende in carico
|
||||
finestra.Close(); // l'utente insiste
|
||||
finestra.Close(); // e ancora
|
||||
Assert.Equal(2, annullate);
|
||||
|
||||
finale = true;
|
||||
finestra.Close(); // la chiusura finale dello spegnimento
|
||||
|
||||
Assert.Equal(2, annullate);
|
||||
Assert.False(finestra.IsVisible);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnaChiusuraAnnullataLasciaLaFinestraUtilizzabile()
|
||||
{
|
||||
WpfRunner.Run(() =>
|
||||
{
|
||||
Window finestra = new();
|
||||
int tentativi = 0;
|
||||
|
||||
finestra.Closing += (_, e) =>
|
||||
{
|
||||
tentativi++;
|
||||
e.Cancel = true;
|
||||
};
|
||||
|
||||
finestra.Close();
|
||||
finestra.Close();
|
||||
finestra.Close();
|
||||
|
||||
Assert.Equal(3, tentativi);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Una strategia rimasta in configurazione dopo che un aggiornamento l'ha rimossa.
|
||||
/// <para>
|
||||
/// Capita perché l'installazione conserva l'<c>encelado.json</c> dell'utente — che è
|
||||
/// giusto, le tarature sono sue — quindi un nome tolto dal programma sopravvive nel
|
||||
/// file. Deve essere una cosa che si vede e si corregge, non un vicolo cieco.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class StrategiaObsoletaTests
|
||||
{
|
||||
private static BotConfig ConfigCon(string strategia)
|
||||
{
|
||||
BotConfig c = new();
|
||||
|
||||
// Validate() controlla le credenziali per prime: senza, il test si fermerebbe
|
||||
// lì invece di arrivare al controllo sulla strategia.
|
||||
c.Alpaca.KeyId = "PKTESTTESTTESTTESTTE";
|
||||
c.Alpaca.SecretKey = "segretosegretosegretosegretosegretosegre";
|
||||
|
||||
c.Symbols.Add(new SymbolConfig
|
||||
{
|
||||
Symbol = "BTC/USD",
|
||||
Strategy = strategia,
|
||||
Enabled = true,
|
||||
Parameters = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["period"] = 100,
|
||||
["band"] = 0.02,
|
||||
},
|
||||
});
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
private static SettingField CampoStrategia(BotConfig config) =>
|
||||
SettingsCatalogue.Build(config, config.Symbols[0].Strategy)
|
||||
.SelectMany(static g => g.Fields)
|
||||
.First(static f => f.Path == "symbols[0].strategy");
|
||||
|
||||
[Fact]
|
||||
public void IlCampoStrategiaSiSceglieDaUnElenco()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
|
||||
|
||||
Assert.True(campo.IsList, "la strategia deve essere un elenco, non un testo libero");
|
||||
Assert.False(campo.IsFreeText);
|
||||
Assert.Equal(StrategyFactory.Available, campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LElencoProponeSoloCioCheIlProgrammaSaCostruire()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
|
||||
|
||||
Assert.All(campo.Options, static nome =>
|
||||
Assert.True(StrategyFactory.IsKnown(nome), $"'{nome}' è nell'elenco ma non è costruibile"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IlCampoStrategiaEModificabile()
|
||||
{
|
||||
// Bloccarlo perché la strategia è una sola è esattamente ciò che rendeva
|
||||
// impossibile correggere un valore obsoleto senza aprire il JSON.
|
||||
SettingField campo = CampoStrategia(ConfigCon("trend-filter"));
|
||||
|
||||
Assert.False(campo.IsReadOnly);
|
||||
Assert.True(campo.IsEditable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnaStrategiaRimossaSiSegnalaDaSolaAllApertura()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
|
||||
|
||||
Assert.True(campo.HasError, "il campo doveva segnalare l'errore senza essere toccato");
|
||||
Assert.True(campo.IsObsolete);
|
||||
Assert.Contains("adaptive-regime", campo.Error!, StringComparison.Ordinal);
|
||||
Assert.Contains("trend-filter", campo.Error!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LElencoNonRipropoleUnaStrategiaRimossa()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
|
||||
|
||||
Assert.DoesNotContain("adaptive-regime", campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SceglierneUnaValidaRisolveEDiventaSalvabile()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon("adaptive-regime"));
|
||||
Assert.True(campo.HasError);
|
||||
|
||||
campo.Value = StrategyFactory.Default;
|
||||
|
||||
Assert.False(campo.HasError);
|
||||
Assert.False(campo.IsObsolete);
|
||||
Assert.True(campo.IsDirty, "la scelta va salvata, quindi deve risultare modificata");
|
||||
Assert.Equal(StrategyFactory.Default, campo.ToJson()!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaConfigurazioneSpiegaComeUscirneInveceDiDireSoloCheEInvalida()
|
||||
{
|
||||
InvalidOperationException ex =
|
||||
Assert.Throws<InvalidOperationException>(() => ConfigCon("adaptive-regime").Validate());
|
||||
|
||||
Assert.Contains("adaptive-regime", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("Impostazioni", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnaConfigurazioneValidaNonSiLamenta()
|
||||
{
|
||||
SettingField campo = CampoStrategia(ConfigCon(StrategyFactory.Default));
|
||||
|
||||
Assert.False(campo.HasError);
|
||||
Assert.False(campo.IsObsolete);
|
||||
Assert.False(campo.IsDirty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>I campi con un insieme chiuso di valori non devono essere scrivibili.</summary>
|
||||
public class CampiAScelaTests
|
||||
{
|
||||
private static IReadOnlyList<SettingField> Campi()
|
||||
{
|
||||
BotConfig c = new();
|
||||
c.Symbols.Add(new SymbolConfig { Symbol = "BTC/USD", Strategy = "trend-filter", Enabled = true });
|
||||
return [.. SettingsCatalogue.Build(c, "trend-filter").SelectMany(static g => g.Fields)];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("symbols[0].strategy")]
|
||||
[InlineData("engine.timeFrame")]
|
||||
[InlineData("logging.level")]
|
||||
[InlineData("engine.entryOrderType")]
|
||||
public void SiCompilanoDaUnElenco(string percorso)
|
||||
{
|
||||
SettingField campo = Campi().First(f => f.Path == percorso);
|
||||
|
||||
Assert.True(campo.IsList, $"{percorso} deve essere un elenco");
|
||||
Assert.NotEmpty(campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AncheIBooleaniSonoUnElenco()
|
||||
{
|
||||
// "sì" e "no" scritti a mano sono due modi per sbagliare.
|
||||
SettingField campo = Campi().First(static f => f.Path == "engine.dryRun");
|
||||
|
||||
Assert.True(campo.IsList);
|
||||
Assert.Equal(["sì", "no"], campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void INumeriRestanoDaScrivere()
|
||||
{
|
||||
SettingField campo = Campi().First(static f => f.Path == "risk.stakePct");
|
||||
|
||||
Assert.True(campo.IsFreeText);
|
||||
Assert.Empty(campo.Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OgniValoreInizialeDiUnElencoEFraLeOpzioni()
|
||||
{
|
||||
// Con la configurazione di fabbrica nessun campo a scelta deve partire in
|
||||
// errore: se succede, catalogo e valori consegnati sono fuori sincrono.
|
||||
foreach (SettingField campo in Campi().Where(static f => f.IsList && !f.IsReadOnly))
|
||||
{
|
||||
Assert.False(campo.IsObsolete,
|
||||
$"{campo.Path} vale '{campo.Value}' che non è fra {string.Join(", ", campo.Options)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Risk;
|
||||
|
||||
namespace Encelado.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The sizing rule the operator chooses. Three configurations are possible and they
|
||||
/// behave differently on purpose, so each is pinned here.
|
||||
/// </summary>
|
||||
public class StakeSizingTests
|
||||
{
|
||||
private static RiskLimits Limits() => new()
|
||||
{
|
||||
MaxRiskPerTradePct = 0.01,
|
||||
MaxPositionNotionalPct = 1.0,
|
||||
MaxGrossExposurePct = 1.0,
|
||||
MaxOpenPositions = 10,
|
||||
MaxTradesPerDay = 100,
|
||||
MaxTradesPerSymbolPerDay = 10,
|
||||
MaxDailyLossPct = 0.05,
|
||||
MinSecondsBetweenEntries = 0,
|
||||
MaxRelativeSpread = 0,
|
||||
MinPrice = 1,
|
||||
MaxPrice = 1_000_000,
|
||||
MinOrderNotional = 1,
|
||||
DefaultStopPct = 0.02,
|
||||
MaxStopDistancePct = 0.5,
|
||||
};
|
||||
|
||||
private static EntryRequest Request(
|
||||
double price = 100, double stop = 90, double equity = 100_000, double strength = 1.0) =>
|
||||
new("TEST", Side.Buy, price, stop, strength, equity, 10_000_000, 0, 0, 0, 0, true,
|
||||
DateTime.UtcNow);
|
||||
|
||||
private static RiskEngine Engine(RiskLimits limits)
|
||||
{
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
return engine;
|
||||
}
|
||||
|
||||
// ---- the rule itself ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void NothingConfiguredMeansTheBotDecides()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
|
||||
Assert.False(limits.HasExplicitStake);
|
||||
Assert.Contains("deciso dal bot", limits.DescribeSizing(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APercentageAloneIsAFractionOfEquity()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakePct = 0.20;
|
||||
|
||||
Assert.True(limits.HasExplicitStake);
|
||||
Assert.Equal(20_000, limits.ResolveStake(100_000), 6);
|
||||
Assert.Equal(10_000, limits.ResolveStake(50_000), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnAmountAloneIsFixedRegardlessOfEquity()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakeAmount = 2_500;
|
||||
|
||||
Assert.Equal(2_500, limits.ResolveStake(100_000), 6);
|
||||
Assert.Equal(2_500, limits.ResolveStake(1_000_000), 6);
|
||||
Assert.Contains("fisso", limits.DescribeSizing(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TogetherTheAmountIsACeilingOnThePercentage()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakePct = 0.20;
|
||||
limits.StakeAmount = 5_000;
|
||||
|
||||
// 20% of 100k is 20k, which the ceiling cuts to 5k.
|
||||
Assert.Equal(5_000, limits.ResolveStake(100_000), 6);
|
||||
|
||||
// 20% of 10k is 2k, which is under the ceiling and therefore used as is.
|
||||
Assert.Equal(2_000, limits.ResolveStake(10_000), 6);
|
||||
}
|
||||
|
||||
// ---- what the engine actually sends ------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AnExplicitStakeSizesByNotionalNotByRisk()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakePct = 0.25;
|
||||
|
||||
RiskVerdict verdict = Engine(limits).ApproveEntry(
|
||||
Request(price: 200, stop: 180, equity: 100_000));
|
||||
|
||||
// 25% of 100k is 25 000, at 200 a unit: 125 units. The stop distance plays no
|
||||
// part — that is the whole difference from risk-based sizing.
|
||||
Assert.True(verdict.Approved, verdict.Detail);
|
||||
Assert.Equal(125, verdict.Quantity, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheStopStillProtectsEvenThoughItNoLongerSizes()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakePct = 0.10;
|
||||
|
||||
RiskVerdict near = Engine(limits).ApproveEntry(Request(price: 100, stop: 99));
|
||||
RiskVerdict far = Engine(limits).ApproveEntry(Request(price: 100, stop: 60));
|
||||
|
||||
// Same size either way...
|
||||
Assert.Equal(near.Quantity, far.Quantity, 6);
|
||||
|
||||
// ...but the stop is carried through, so the far one really can lose more.
|
||||
Assert.Equal(99, near.StopPrice, 6);
|
||||
Assert.Equal(60, far.StopPrice, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvictionDoesNotShrinkAnExplicitStake()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakeAmount = 10_000;
|
||||
|
||||
RiskVerdict strong = Engine(limits).ApproveEntry(Request(strength: 1.0));
|
||||
RiskVerdict weak = Engine(limits).ApproveEntry(Request(strength: 0.2));
|
||||
|
||||
// Asking for a fixed amount and getting a fifth of it on a weaker signal would
|
||||
// not be the instruction that was given.
|
||||
Assert.Equal(strong.Quantity, weak.Quantity, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvictionStillScalesTheDefaultRiskBasedSizing()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
|
||||
RiskVerdict strong = Engine(limits).ApproveEntry(Request(strength: 1.0));
|
||||
RiskVerdict weak = Engine(limits).ApproveEntry(Request(strength: 0.2));
|
||||
|
||||
Assert.True(weak.Quantity < strong.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardCapsStillApplyOnTopOfAnExplicitStake()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.MaxPositionNotionalPct = 0.30;
|
||||
limits.StakePct = 0.30;
|
||||
|
||||
RiskVerdict verdict = Engine(limits).ApproveEntry(
|
||||
Request(price: 100, equity: 100_000) with { BuyingPower = 12_000 });
|
||||
|
||||
// Buying power is the binding constraint here, not the stake.
|
||||
Assert.True(verdict.Approved, verdict.Detail);
|
||||
Assert.True(verdict.Quantity * 100 <= 12_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheVerdictSaysWhichRuleProducedTheSize()
|
||||
{
|
||||
RiskLimits staked = Limits();
|
||||
staked.StakePct = 0.10;
|
||||
|
||||
Assert.Contains("stake", Engine(staked).ApproveEntry(Request()).Detail, StringComparison.Ordinal);
|
||||
Assert.Contains("risk", Engine(Limits()).ApproveEntry(Request()).Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// ---- validation --------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AStakeAboveThePositionCapIsRejectedRatherThanClipped()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.MaxPositionNotionalPct = 0.50;
|
||||
limits.StakePct = 0.60;
|
||||
|
||||
// Silently trading 50% when 60% was asked for would leave the operator believing
|
||||
// a number that never happens.
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(limits.Validate);
|
||||
Assert.Contains("maxPositionNotionalPct", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.1)]
|
||||
[InlineData(1.5)]
|
||||
public void ANonsensicalPercentageIsRejected(double pct)
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakePct = pct;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(limits.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANegativeAmountIsRejected()
|
||||
{
|
||||
RiskLimits limits = Limits();
|
||||
limits.StakeAmount = -1;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(limits.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheDefaultConfigurationStillValidates() => Limits().Validate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zero means "no limit" on the three frequency caps, matching the convention
|
||||
/// <c>maxOrderNotional</c> and <c>maxDailyProfitPct</c> already use.
|
||||
/// </summary>
|
||||
public class UncappedTradingTests
|
||||
{
|
||||
private static RiskLimits Uncapped() => new()
|
||||
{
|
||||
MaxRiskPerTradePct = 0.01,
|
||||
MaxPositionNotionalPct = 1.0,
|
||||
MaxGrossExposurePct = 1.0,
|
||||
MaxOpenPositions = 0,
|
||||
MaxTradesPerDay = 0,
|
||||
MaxTradesPerSymbolPerDay = 0,
|
||||
MinSecondsBetweenEntries = 0,
|
||||
MaxDailyLossPct = 0.5,
|
||||
MaxRelativeSpread = 0,
|
||||
MinPrice = 1,
|
||||
MaxPrice = 1_000_000,
|
||||
MinOrderNotional = 1,
|
||||
DefaultStopPct = 0.35,
|
||||
MaxStopDistancePct = 0.60,
|
||||
};
|
||||
|
||||
private static EntryRequest Request(string symbol = "TEST", int openPositions = 0) =>
|
||||
new(symbol, Side.Buy, 100, 90, 1.0, 100_000, 10_000_000, 0, openPositions, 0, 0, true,
|
||||
DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void ZeroLimitsValidate() => Uncapped().Validate();
|
||||
|
||||
[Fact]
|
||||
public void NegativeLimitsAreStillRejected()
|
||||
{
|
||||
RiskLimits limits = Uncapped();
|
||||
limits.MaxTradesPerDay = -1;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(limits.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyTradesInOneSessionAreAllAccepted()
|
||||
{
|
||||
RiskEngine engine = new(Uncapped());
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
RiskVerdict verdict = engine.ApproveEntry(Request($"SYM{i}"));
|
||||
Assert.True(verdict.Approved, $"operazione {i} rifiutata: {verdict.Detail}");
|
||||
engine.RecordEntry($"SYM{i}", DateTime.UtcNow);
|
||||
}
|
||||
|
||||
Assert.Equal(500, engine.TradesToday);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManySimultaneousPositionsAreAccepted()
|
||||
{
|
||||
RiskEngine engine = new(Uncapped());
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
Assert.True(engine.ApproveEntry(Request(openPositions: 250)).Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedEntriesOnOneSymbolAreAccepted()
|
||||
{
|
||||
RiskEngine engine = new(Uncapped());
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
Assert.True(engine.ApproveEntry(Request("BTC/USD")).Approved);
|
||||
engine.RecordEntry("BTC/USD", DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APositiveLimitStillCaps()
|
||||
{
|
||||
RiskLimits limits = Uncapped();
|
||||
limits.MaxTradesPerDay = 3;
|
||||
|
||||
RiskEngine engine = new(limits);
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.True(engine.ApproveEntry(Request($"SYM{i}")).Approved);
|
||||
engine.RecordEntry($"SYM{i}", DateTime.UtcNow);
|
||||
}
|
||||
|
||||
RiskVerdict fourth = engine.ApproveEntry(Request("SYM3"));
|
||||
Assert.False(fourth.Approved);
|
||||
Assert.Equal(RiskReject.MaxDailyTrades, fourth.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheKillSwitchIsNotAffectedByUncappingTheCounters()
|
||||
{
|
||||
// Removing the frequency caps must not remove the loss limit with them.
|
||||
RiskEngine engine = new(Uncapped());
|
||||
engine.StartSession(100_000, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
Assert.True(engine.UpdateEquity(40_000));
|
||||
Assert.True(engine.IsHalted);
|
||||
Assert.False(engine.ApproveEntry(Request()).Approved);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user