Passa ai Correlation Baskets su eToro e rimuove i motori precedenti (4.0.0)
Perché: l'utente ha chiesto un bot che operi cinque basket di coppie forex correlate su eToro, autonomo, con ledger, feed gratuiti e apprendimento costruito da zero, e ha deciso di eliminare tutto ciò che restava delle gestioni precedenti (Binance, cTrader/proba, ricerca con SQLite, GBDT, RL, TA-Lib) e di non avere approvazioni manuali sui singoli ordini. Cosa cambia: - nuovo Core dei basket (cross sintetici, decisore, cost gate, sizing, esecutore leg-risk, backtest con PSR/DSR/PBO, livelli 0-3 di apprendimento), adattatore eToro Public API, motore autonomo con equity stop, kill-switch, riconciliazione, ledger append-only, calendario e notizie con sentiment; - modalità Paper / Demo / Live (Live con flag e frase CONFERMO LIVE); - interfaccia rifatta: barra in alto con tre schede, dashboard con i soli numeri principali, fuso orario selezionabile, test di rendering in PNG; - corretto il parser dei costi eToro (campo "value"): markup e overnight non venivano letti; - strumento di ricerca ridotto a ticks / baskets / falsify con due scenari di costo; risultati in results/ e reports/: nessuna configurazione è profittevole al netto dei costi (docs/STRATEGY.md lo dice con i numeri); - documentazione completa (STRATEGY, ML_AND_LEARNING, RUNBOOK, GLOSSARY, KNOWN_ISSUES, ADR-0004, ADR-0005) e catena di rilascio aggiornata. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -16,3 +16,6 @@ logs/
|
||||
# build/gitea.example.json; questo file contiene una credenziale e non entra
|
||||
# mai nel repository.
|
||||
build/gitea.json
|
||||
|
||||
# Output dei test (coverlet)
|
||||
TestResults/
|
||||
|
||||
Vendored
+5
-5
@@ -39,7 +39,7 @@
|
||||
},
|
||||
{
|
||||
"label": "backtest",
|
||||
"detail": "Rigioca una serie storica di prezzi: taratura contro verifica, con il confronto sul comprare e tenere.",
|
||||
"detail": "Ricerca sui basket: ticks (tick MT5 → barre), baskets (griglia, PSR/DSR, PBO, walk-forward), falsify (test di falsificazione).",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
@@ -128,15 +128,15 @@
|
||||
{
|
||||
"id": "dati",
|
||||
"type": "promptString",
|
||||
"description": "File CSV con la serie storica dei prezzi",
|
||||
"default": "C:\\Users\\alber\\Downloads\\BTC\\btcusd_bitstamp_1min_2012-2025.csv"
|
||||
"description": "Cartella dei dati: data/market (barre M15) per baskets e falsify, la cartella dei tick MT5 per ticks",
|
||||
"default": "C:\\Users\\alber\\Documents\\Encelado\\data\\market"
|
||||
},
|
||||
{
|
||||
"id": "comando",
|
||||
"type": "pickString",
|
||||
"description": "Cosa misurare",
|
||||
"options": ["split", "walk", "frequency", "costs", "sweep", "explore"],
|
||||
"default": "split"
|
||||
"options": ["baskets", "falsify", "ticks"],
|
||||
"default": "baskets"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Cronologia
|
||||
|
||||
Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto.
|
||||
|
||||
## 2026-09-16 (pomeriggio) — 4.0.0: solo Correlation Baskets su eToro, bot autonomo, interfaccia nuova
|
||||
|
||||
- **Rimossi** i motori precedenti: Binance, Alpaca, cTrader/proba, SQLite, GBDT, RL, TA-Lib, indicatori, backtest a coppie, pagine e test relativi (ADR-0004). Nessun pacchetto NuGet nell'applicazione.
|
||||
- **Modalità** ridotte a `Paper`, `Demo` (default), `Live`: nessuna approvazione manuale dei singoli ordini (decisione dell'utente, ADR-0005). I nomi precedenti vengono letti con un avviso.
|
||||
- **Interfaccia** rifatta: barra in alto con tre schede, stato, ambiente, ora e AVVIA; dashboard con equity, P&L di oggi, P&L aperto, drawdown, basket aperti, tabella dei basket, contesto e attività. Tema nuovo. Test di rendering in PNG.
|
||||
- **Fuso orario** della finestra selezionabile (`ui.timeZone`); il log porta l'offset, il ledger resta UTC.
|
||||
- **Corretto** il parser dei costi di eToro (campo `value`): markup e overnight non erano letti.
|
||||
- **Apprendimento** collegato al motore: modello in ombra, challenger, bandit, previsione di volatilità, ciclo settimanale, `knowledge/`. Standardizzatore dell'MLP adattato all'insieme di addestramento.
|
||||
- **Backtest**: test di falsificazione 5 (segnale invertito), scenario di costi `api`, `docs/STRATEGY.md` con il verdetto negativo e i numeri.
|
||||
- Feed: dopo due errori consecutivi una fonte logga solo a debug e ritenta con attese crescenti.
|
||||
- Documenti nuovi: `STRATEGY.md`, `ML_AND_LEARNING.md`, `RUNBOOK.md`, `GLOSSARY.md`, `KNOWN_ISSUES.md`, ADR-0004, ADR-0005. Catena di rilascio aggiornata ai tre comandi dello strumento.
|
||||
- Versione 4.0.0.
|
||||
|
||||
## 2026-09-16 (mattina) — Correlation Baskets su eToro, Fasi 0-7
|
||||
|
||||
- Ricognizione del repository; verifica dell'API eToro (rotte, quote, schemi, limiti), degli strumenti, della valuta del conto, dei feed, del formato dei tick.
|
||||
- Broker eToro (`Encelado.Etoro`), `PaperBroker`, chiavi DPAPI, `--headless`; cross sintetici, indicatori, decisore, cost gate, sizing, esecutore con protocollo leg-risk, backtest event-driven e griglia con PSR/DSR/PBO/walk-forward; calendario, RSS, sentiment, ledger; livelli di apprendimento 0-3 nel Core.
|
||||
- Documenti: `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/QUESTIONS.md`, `docs/STATE.md`, `DATA_SOURCES.md`, `LEDGER_SCHEMA.md`, `RISK_RULES.md`, ADR-0001 (eToro), ADR-0002 (storage su file), ADR-0003 (motore cTrader mantenuto selezionabile; superata da ADR-0004).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Encelado — guida per chi lavora sul repository (umano o AI)
|
||||
|
||||
**Leggi prima `docs/STATE.md`.** È la memoria di lavoro fra una sessione e l'altra: dice a che fase siamo, cosa è stato fatto per ultimo e cosa manca.
|
||||
|
||||
## Scopo
|
||||
|
||||
Bot di trading in C# (.NET 10, WPF) su **eToro** con la strategia "Correlation Baskets": cinque basket di due coppie forex correlate, ingresso quando il cross sintetico diverge (z-score), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale, ledger completo, feed gratuiti di calendario e notizie, livelli di apprendimento 0-3 costruiti da zero. È l'unica strategia del repository: i motori precedenti (Binance, cTrader/proba, ricerca) sono stati rimossi il 2026-09-16 (ADR-0004) e vivono solo nella storia git. Il bot opera da solo in ogni modalità (ADR-0005): `Paper`, `Demo` (default), `Live`.
|
||||
|
||||
## Mappa dei documenti
|
||||
|
||||
| File | Contenuto |
|
||||
|---|---|
|
||||
| `docs/STATE.md` | stato corrente, fase, ultima sessione, prossimi passi, problemi aperti |
|
||||
| `docs/ARCHITECTURE.md` | progetti, flusso dati, macchine a stati, interfacce |
|
||||
| `docs/STRATEGY.md` | logica dei basket, cross sintetici, formule, preset, aspettative oneste, numeri |
|
||||
| `docs/ML_AND_LEARNING.md` | livelli 0-3, feature, label, addestramento, attivazione, esclusioni |
|
||||
| `docs/DATA_SOURCES.md` | ogni fonte (URL, formato, limiti), schema dei file in `data/` |
|
||||
| `docs/LEDGER_SCHEMA.md` | schema di `decisions.jsonl`, `baskets.csv`, `trials.csv`, `calibration.csv`, `preregistrazione.csv`, `proposals.csv` |
|
||||
| `docs/RISK_RULES.md` | regole di sicurezza con i default e chi può cambiarle |
|
||||
| `docs/RUNBOOK.md` | avvio, arresto, kill-switch, reset, riconciliazione, chiavi, errori API, checklist |
|
||||
| `docs/QUESTIONS.md` | domande poste per fase, risposte o default applicati, con data |
|
||||
| `docs/GLOSSARY.md`, `docs/KNOWN_ISSUES.md`, `CHANGELOG.md`, `docs/adr/` | glossario, problemi noti, cronologia, decisioni architetturali |
|
||||
| `build/README.md` | catena di verifica, pacchetto e rilascio |
|
||||
|
||||
## Convenzioni
|
||||
|
||||
- **C#**, `Nullable` e `TreatWarningsAsErrors` attivi. Identificatori e commenti tecnici in inglese; documentazione, report e colonna `motivazione` in italiano.
|
||||
- **Nessun pacchetto NuGet.** Solo BCL e WPF nei progetti dell'applicazione; xunit nei test.
|
||||
- **Tabelle**: CSV con separatore `;`, header, ultima colonna `motivazione`; JSONL append-only per ledger e notizie; JSON per modelli e stato. Scritture atomiche (`.tmp` + `File.Move`), rotazione mensile. **Nessuna riga del ledger viene mai modificata**: le correzioni sono righe nuove con `evento = correzione`.
|
||||
- **Tempo**: UTC ovunque; conversione solo in UI. `CultureInfo.InvariantCulture` per ogni parsing e formattazione su file.
|
||||
- **Concorrenza**: un solo thread di decisione; I/O asincrono; `Channel<T>` fra ingestion, strategia, esecuzione e UI.
|
||||
- **Riproducibilità**: seed fisso 42 per ogni componente stocastica; ogni run scrive `run_id`, hash della configurazione e versione del codice nel ledger.
|
||||
- Cartelle a runtime sotto `Documenti\Encelado\`: `data/`, `knowledge/`, `reports/`, `results/`, `logs/`. Credenziali solo in `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o variabili d'ambiente `ETORO_API_KEY`, `ETORO_USER_KEY`.
|
||||
- **Interfaccia**: una barra in alto (schede Dashboard / Log / Impostazioni, stato, ambiente, ora nel fuso scelto, AVVIA), pagine sotto. Nella dashboard solo le informazioni principali; i dettagli nei tooltip e nel log. Gli orari a schermo passano da `UiClock` (`ui.timeZone`); il log porta l'offset, il ledger è UTC.
|
||||
- **Verifica visiva**: `ENCELADO_RENDER_DIR=<cartella> dotnet test tests/Encelado.Tests --filter UiRenderTests` scrive `dashboard.png`, `log.png`, `settings.png`, `window.png`.
|
||||
|
||||
## Comandi
|
||||
|
||||
```powershell
|
||||
dotnet build Encelado.slnx # compilazione
|
||||
dotnet test tests/Encelado.Tests --no-restore # test (xunit)
|
||||
dotnet msbuild build/Release.proj -t:Verifica # compilazione + test nella cartella di verifica
|
||||
dotnet run --project src/Encelado.Bot -- --headless [--minutes 240] # bot senza finestra (VPS, test lunghi)
|
||||
dotnet run --project tools/Encelado.Backtest -- ticks --data "A:\Download\Trading" --out "%USERPROFILE%\Documents\Encelado\data\market"
|
||||
dotnet run --project tools/Encelado.Backtest -- baskets --data "%USERPROFILE%\Documents\Encelado\data\market" --out results
|
||||
dotnet run --project tools/Encelado.Backtest -- falsify --data "%USERPROFILE%\Documents\Encelado\data\market" --out reports [--costs api]
|
||||
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=4.0.0 # installatore + zip + tag + release su Gitea (dopo il commit e il push del ramo)
|
||||
```
|
||||
|
||||
## Regole
|
||||
|
||||
1. **Chiedi se hai un dubbio.** Le domande vanno in `docs/QUESTIONS.md`, numerate, con il default che applicheresti; in assenza di risposta applica il default più prudente e annotalo.
|
||||
2. **Mai un ordine reale senza flag e conferma.** `run.executionMode` predefinito `Demo`; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` all'avvio. Nessuna approvazione per singolo ordine (D-20): il bot opera da solo.
|
||||
3. **Mai una riga del ledger modificata.**
|
||||
4. **Mai un risultato abbellito.** Se la strategia non regge i costi di eToro, il report lo dice con i numeri. "Nessuna configurazione profittevole" è un esito ammesso.
|
||||
5. **Non toccare la catena di rilascio** (`build/`) se non richiesto; è condivisa con Mimante/AutoBidder.
|
||||
6. **Un commit a fine sessione**, dopo che la verifica passa, con un messaggio che dice cosa cambia e perché. Il push lo decide l'utente.
|
||||
7. Aggiorna `docs/STATE.md` e `CHANGELOG.md` a fine sessione; un ADR per ogni scelta non ovvia.
|
||||
|
||||
## Cose da non fare
|
||||
|
||||
- Non scrivere chiavi in chat, nel log, nel repo o in `Documenti`.
|
||||
- Non usare spread fissi nel cost gate: sempre lo spread reale letto dall'API in quel momento più il markup dell'endpoint dei costi.
|
||||
- Non ricostruire feature a posteriori: il dataset di addestramento è il ledger scritto al momento della decisione.
|
||||
- Non cambiare parametri live in automatico: le proposte passano da `knowledge/proposals.csv` e dal forward test.
|
||||
- Non spostare l'installazione in Program Files (vedi `docs/adr/`): l'app scrive accanto alla configurazione.
|
||||
- Non usare heredoc lunghi o con backslash nel Bash tool: usare `Write`/`Edit` (vedi memoria `strumenti-heredoc-backslash`).
|
||||
@@ -18,7 +18,7 @@
|
||||
build/Release.proj — e questo serve solo da seme quando non esiste ancora
|
||||
nessun tag. Tenerlo allineato all'ultimo rilascio evita di leggere in
|
||||
finestra un numero che non corrisponde a niente. -->
|
||||
<Version>3.5.0</Version>
|
||||
<Version>4.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Encelado.Binance/Encelado.Binance.csproj" />
|
||||
<Project Path="src/Encelado.Bot/Encelado.Bot.csproj" />
|
||||
<Project Path="src/Encelado.Core/Encelado.Core.csproj" />
|
||||
<Project Path="src/Encelado.Storage/Encelado.Storage.csproj" />
|
||||
<Project Path="src/Encelado.Etoro/Encelado.Etoro.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Encelado.Tests/Encelado.Tests.csproj" />
|
||||
|
||||
+2
-313
@@ -1,313 +1,2 @@
|
||||
# PROMPT PER CLAUDE CODE — Progetto "QuantBot": bot di trading crypto con ML e statistical arbitrage, paper-trading-first
|
||||
|
||||
> **Come usare questo documento:** incollalo in Claude Code come specifica master del progetto. È scritto in italiano; codice, identificatori, nomi di librerie, formule e termini tecnici consolidati restano in inglese. Procedi per fasi, nell'ordine, e non passare alla fase successiva finché i criteri di accettazione della fase corrente non sono soddisfatti e documentati.
|
||||
|
||||
---
|
||||
|
||||
## 0. Ruolo, obiettivo e vincoli (leggi prima di scrivere codice)
|
||||
|
||||
Sei l'ingegnere quant/ML che costruisce un bot di trading crypto **research-grade**, modulare, in Python. L'utente (Alberto) è uno sviluppatore C++/Python esperto, usa Supabase (Postgres) e già gestisce un sistema news-sentiment su eToro tramite eToro Public API. Implementa il sistema descritto sotto, per fasi, con criteri di accettazione espliciti.
|
||||
|
||||
**Aspettative oneste, dichiarate in cima (NIENTE hype):**
|
||||
- L'evidenza accademica sistematica mostra che l'edge di un backtest si erode fortemente su dati mai visti. McLean & Pontiff (2016, *Journal of Finance* 71(1):5–32), su 97 predittori pubblicati: *"Portfolio returns are 26% lower out-of-sample and 58% lower post-publication"* — il calo out-of-sample (26%) è un upper bound dell'effetto di data mining, mentre il ~58% è il decadimento post-pubblicazione dovuto anche all'arbitraggio del segnale una volta reso noto.
|
||||
- Per dichiarare significativo un fattore, Harvey, Liu & Zhu (2016, *Review of Financial Studies* 29(1):5–68), su 316 fattori catalogati, raccomandano: *"A new factor needs to clear a much higher hurdle, with a t-statistic greater than 3.0"* (t=3.0 ≈ p-value 0,27%), proprio a causa del multiple testing. Applica questa soglia.
|
||||
- La previsione pura del prezzo con LSTM/Transformer raramente sopravvive ai costi out-of-sample; il problema centrale (López de Prado) NON è la previsione ma la **validazione**.
|
||||
- Il deep RL (FinRL) soffre di simulation-to-reality gap, instabilità delle policy e overfitting del backtest: è **opzionale/sperimentale**, non core.
|
||||
- L'arbitraggio di latenza cross-exchange contro HFT è **fuori portata** per un retail (finestre di 30-50 ms contro latenza retail di 100-500 ms). NON è core.
|
||||
- Un edge realistico per un solo sviluppatore con capitale modesto è: (a) relative-value/statistical arbitrage a bassa frequenza (minuti/ore) su asset cointegrati; (b) funding-rate / cash-and-carry basis; (c) meta-labeling su segnali semplici per ridurre falsi positivi e costi. Tutti con Sharpe modesti e capacità limitata.
|
||||
|
||||
**Vincoli di progetto (hard rules):**
|
||||
1. **Paper-trading-first.** Nessun trading live finché non si superano gate di accettazione espliciti (Fasi 5-6).
|
||||
2. **Nessun ordine live senza un flag esplicito** `LIVE_TRADING=true` + conferma interattiva; default = dry-run.
|
||||
3. **Tutti gli output tabellari** (log, journal, report, trade blotter) devono essere **CSV con separatore `;`** e includere una colonna `motivazione`/`reasoning` che spiega in linguaggio naturale ogni decisione. Doppia scrittura: CSV su disco + tabella Supabase.
|
||||
4. **Costi realistici sempre.** Nessun backtest senza fee, spread, slippage e latenza modellati.
|
||||
5. **No look-ahead bias.** Ogni feature/label deve essere calcolabile solo con l'informazione disponibile a quell'istante.
|
||||
6. Segreti (API key) solo da variabili d'ambiente / secrets manager, mai hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## 1. Sintesi delle evidenze dalla ricerca (cosa funziona, cosa no)
|
||||
|
||||
**Cosa ha evidenza ragionevole (dopo i costi):**
|
||||
- **Statistical arbitrage / pairs trading su crypto cointegrate.** Fil & Kristoufek, *"Pairs Trading in Cryptocurrency Markets"* (IEEE Access, 2020) applicano i metodi distance e cointegration a **26 crypto liquide su Binance a frequenza 5-min, 1h e daily**, trovando mean-reversion intraday assente nei dati daily. Fischer, Krauss & Deinert (2019, *Journal of Risk and Financial Management* 12(1):31), con random forest su 40 coin (long/short dei top-3/flop-3, orizzonte 120 min), riportano testualmente: *"ranging from 18 June 2018 to 17 September 2018, and after more than 100,000 trades, we find statistically and economically significant returns of 7.1 bps per day, after transaction costs of 15 bps per half-turn."* La mean-reversion è più forte a frequenza intraday che daily.
|
||||
- **Funding-rate / cash-and-carry basis** su perpetual: delta-neutral (long spot + short perp quando il funding è positivo). Rendimenti realistici ~8-18% APR in condizioni normali per le top coin, spike >100% APR in fasi euforiche ma di breve durata; i rendimenti si comprimono verso zero all'aumentare del capitale che affluisce nella strategia.
|
||||
- **Meta-labeling** (López de Prado): un secondo modello che decide "scommetti o no" sul segnale primario riduce i falsi positivi e i costi di transazione. Ottimo rapporto sforzo/beneficio.
|
||||
- **Order flow / order book imbalance** ha relazione quasi-lineare con le variazioni di prezzo a brevissimo orizzonte (Cont-Kukanov-Stoikov). Utile come feature, ma l'edge vive a orizzonti di secondi.
|
||||
- **Sentiment (FinBERT/CryptoBERT)** aggiunge potere predittivo modesto ma reale in alcuni studi. Attenzione a non sovrastimarlo: Shobayo et al. (2024, arXiv:2412.06837 / *Big Data and Cognitive Computing* 8(11):143), su dataset NGX All-Share, misurano per FinBERT **accuracy 63,33%, precision 63,76%, ROC AUC 65,59%** — battuto da una logistic regression tunata (81,83% accuracy, 89,76% ROC AUC). Il sentiment è una feature, non una strategia.
|
||||
|
||||
**Cosa NON funziona / dove il retail non compete:**
|
||||
- Previsione pura del prezzo con LSTM/Transformer come sorgente di alpha.
|
||||
- Latency arbitrage cross-exchange vs HFT (finestre 30-50 ms; vedi Alexander, *"Latency Arbitrage in Cryptocurrency Markets"*, SSRN 5143158: la latenza retail di 100-500 ms può sfruttare solo discrepanze che durano minuti, non secondi).
|
||||
- Deep RL come scatola nera "che impara a fare soldi": instabile, overfittato.
|
||||
- Qualsiasi backtest senza purged CV, deflated Sharpe e modello di costi.
|
||||
|
||||
**Failure modes da prevenire attivamente:** look-ahead bias, backtest overfitting (testare N configurazioni e scegliere la migliore), non-stationarity/regime change, survivorship bias, ignorare costi e latenza, p-hacking (contare TUTTI i test fatti, non solo quelli riusciti).
|
||||
|
||||
---
|
||||
|
||||
## 2. Architettura del sistema
|
||||
|
||||
Monorepo Python. Layout proposto:
|
||||
|
||||
```
|
||||
quantbot/
|
||||
config/ # yaml config, profili exchange, soglie di rischio
|
||||
data/
|
||||
ingestion/ # CCXT REST/WS, Binance Vision, eToro adapter, news/sentiment
|
||||
storage/ # Parquet + DuckDB (locale) + Supabase (Postgres) writer
|
||||
quality/ # validazione schema, gap, duplicati, sanity check
|
||||
features/
|
||||
bars.py # time/tick/volume/dollar/imbalance bars
|
||||
fracdiff.py # differenziazione frazionaria (FFD)
|
||||
microstructure.py# OFI, order book imbalance, microprice
|
||||
technical.py # returns, realized vol, indicatori, encoding ciclici tempo
|
||||
sentiment.py # FinBERT/CryptoBERT, LLM scoring, event detection
|
||||
timefeatures.py # sessioni, orari funding, scadenze, macro releases
|
||||
labeling/
|
||||
triple_barrier.py
|
||||
meta_labeling.py
|
||||
sample_weights.py# unicità campioni, pesi
|
||||
models/
|
||||
gbm.py # LightGBM/XGBoost/CatBoost
|
||||
sequence.py # LSTM/GRU/TCN/Transformer (opzionale)
|
||||
rl/ # FinRL/stable-baselines3 (opzionale, Fase 4)
|
||||
validation/
|
||||
cv.py # purged k-fold, embargo, CPCV
|
||||
metrics.py # Sharpe/Sortino/Calmar, PSR, DSR, PBO
|
||||
strategies/
|
||||
statarb.py # cointegrazione, OU, Kalman hedge ratio, z-score
|
||||
basis.py # funding-rate / cash-and-carry
|
||||
ml_signal.py # segnale ML -> probabilita -> meta-label
|
||||
sizing/
|
||||
kelly.py # Kelly e fractional Kelly
|
||||
vol_target.py # volatility targeting
|
||||
execution/
|
||||
broker_base.py # interfaccia astratta
|
||||
ccxt_adapter.py # exchange crypto
|
||||
etoro_adapter.py # eToro Public API
|
||||
cost_model.py # fee, spread, slippage, latenza
|
||||
risk/
|
||||
engine.py # limiti esposizione, max loss/day, kill-switch, VaR/CVaR
|
||||
journal/
|
||||
csv_writer.py # CSV ';' con colonna motivazione
|
||||
supabase_writer.py
|
||||
monitoring/
|
||||
drift.py # concept drift, champion/challenger
|
||||
alerts.py # Telegram/email
|
||||
backtest/
|
||||
engine.py # walk-forward, costi realistici
|
||||
orchestration/
|
||||
scheduler.py # retraining, loop
|
||||
tests/
|
||||
```
|
||||
|
||||
**Flusso dati:** ingestion → quality → storage (Parquet/DuckDB + Supabase) → feature store → labeling → training → validation → signal → sizing → risk engine → execution (dry-run/paper/live) → journaling → monitoring → (retraining loop).
|
||||
|
||||
**Stack raccomandato:**
|
||||
- Dati: `pandas`/`polars`, `numpy`, `duckdb`, Parquet, Supabase Postgres.
|
||||
- Modelli: `scikit-learn`, `LightGBM` (core), `statsmodels`, `arch` (GARCH/vol), `pykalman`, `PyTorch` (solo se servono sequence models).
|
||||
- Backtest: **vectorbt** per lo screening rapido di molte configurazioni; **NautilusTrader** per la validazione event-driven realistica con parità backtest/live (stesso codice in backtest, paper e live). Il pattern raccomandato è "vectorbt → NautilusTrader": scoperta veloce del segnale, poi validazione microstruttura/esecuzione.
|
||||
- Framework bot alternativo integrato: **freqtrade + FreqAI** offre retraining adattivo con finestre scorrevoli, backtest che emula il retraining periodico e supporto a LightGBM/PyTorch/RL; utile come baseline pronta e come riferimento per il loop di retraining.
|
||||
- Exchange abstraction: **CCXT** (unifica Binance, Kraken, Bybit, Coinbase, OKX; supporta `fetch_ohlcv`, `fetch_order_book`, `fetch_funding_rate`, `fetch_funding_rate_history`, `fetchOpenInterestHistory`, `fetchLiquidations`). Ricorda `enableRateLimit: true`; `rateLimit` è in millisecondi e `pausa = rateLimit × cost_endpoint`.
|
||||
- Container: Docker; scheduling: `APScheduler`/cron; monitoring/alert: Telegram.
|
||||
|
||||
**Scelta del venue di esecuzione (analisi onesta):**
|
||||
- **eToro Public API**: comodo perché l'utente lo usa già. Autenticazione con header `x-api-key` + `x-user-key` (chiavi long-lived), ambienti demo e live **separati** (chiavi distinte). Candele storiche via `GET /market-data/instruments/history/candles` (max 1000 barre, parametri `direction`/`interval`/`limit`). Rate limit ufficiali: **60 req/min** per i GET di dati (market data, portfolio, watchlist read) e **20 req/min** per endpoint che eseguono trade o query pesanti; su 429 rispetta `Retry-After` con backoff esponenziale. eToro ha lanciato "Agent Portfolios" con API key scoped, budget minimo $200, esplicitamente compatibili con agenti come Claude Code. **Limiti per il nostro scopo:** eToro NON espone order book depth, non consente short spot crypto né perp/funding, incorpora spread/mark-up e non dà dati di microstruttura → **inadatto ad arbitrage/relative-value e a strategie microstruttura**. Va bene per esecuzione direzionale sentiment-based e per copy/social.
|
||||
- **Exchange crypto via CCXT** (Binance/Kraken/Bybit/OKX/Coinbase): fee maker/taker ~0,1% o meno, order book completo, perp + funding, WebSocket a bassa latenza, dati storici gratuiti (Binance Vision, con il noto limite di ~1000 candele per richiesta REST da aggirare paginando via `since`). **Adatto** a statistical arbitrage, basis trade e feature di microstruttura. Kraken applica un contatore di rate limit incrementale (+1/+4 per classe di endpoint).
|
||||
- **Raccomandazione:** due adapter dietro l'interfaccia `broker_base`. Strategie statarb/basis → CCXT (valuta Kraken o Bybit per l'operatività italiana post-MiCA). Strategie sentiment direzionali → eToro. Il risk engine è comune a entrambi.
|
||||
|
||||
---
|
||||
|
||||
## 3. Piano di lavoro a fasi
|
||||
|
||||
### FASE 0 — Ispezione e validazione dei CSV caricati (OBBLIGATORIA, prima di tutto)
|
||||
|
||||
I file sono in `/mnt/user-data/uploads/`: `BTCUSD.csv, BTCEUR.csv, BTCUSDT.csv, ETHUSDT.csv, SOLUSDT.csv, AVAXUSDT.csv`. Sono verosimilmente candele OHLCV a 1 minuto, ma **schema, unità/timezone del timestamp, ordine e completezza sono IGNOTI**. NON assumere nulla.
|
||||
|
||||
Task:
|
||||
1. Carica ogni file rilevando automaticamente il separatore (prova `sep=';'`, fallback `sep=','`). Stampa `df.head()`, `df.dtypes`, numero righe.
|
||||
2. Identifica le colonne timestamp/OHLCV. Determina l'unità del timestamp (s / ms / ISO) e il timezone (assumi UTC ma **verificalo** confrontando i range dei prezzi con eventi noti).
|
||||
3. Ordina per timestamp; verifica monotonicità; conta e logga **duplicati** e **gap** (differenze ≠ 60 s).
|
||||
4. Sanity check prezzi: nessun valore ≤ 0, nessun high<low, nessun salto impossibile (es. |return| oltre soglia), volumi non negativi.
|
||||
5. **Allineamento cross-file:** calcola l'intersezione dei timestamp comuni tra i 6 file; produci un report di copertura.
|
||||
6. Output: `reports/fase0_data_quality.csv` (separatore `;`, colonna `motivazione` per ogni anomalia) + tabella riassuntiva in Supabase.
|
||||
|
||||
**Criterio di accettazione Fase 0:** report generato; almeno una finestra temporale comune ai file usati in ogni strategia; % di gap documentata; nessun prezzo insano non gestito.
|
||||
|
||||
### FASE 1 — Data pipeline + backtest baseline con costi realistici
|
||||
|
||||
Task:
|
||||
1. Standardizza i CSV in un formato canonico (UTC; colonne `timestamp, open, high, low, close, volume`); salva in Parquet + DuckDB.
|
||||
2. Implementa il **cost model** (`execution/cost_model.py`): fee maker/taker configurabili per exchange, spread bid-ask stimato, slippage funzione della size vs volume, latenza simulata.
|
||||
3. Backtest baseline **non-ML**: buy&hold e una regola banale (es. SMA crossover) su BTCUSDT, **con costi**, per validare l'engine.
|
||||
4. Implementa le metriche in `validation/metrics.py`: log-return, realized vol, Sharpe, Sortino, Calmar, max drawdown, PSR, DSR.
|
||||
|
||||
**Criterio di accettazione Fase 1:** engine di backtest riproducibile; la baseline con costi mostra performance realistica (tipicamente Sharpe basso/negativo netto costi — è il punto: dimostrare che l'engine non "regala" alpha).
|
||||
|
||||
### FASE 2 — Strategie statistiche cross-asset sui file forniti (il cuore per il retail)
|
||||
|
||||
Qui si realizza la richiesta esplicita dell'utente di "sfruttare piccole differenze naturali di prezzo". Usa i 6 file.
|
||||
|
||||
Idee da testare, ciascuna con verifica empirica delle leggi:
|
||||
1. **BTCUSD vs BTCUSDT** — basis USD/USDT ed episodi di depeg della stablecoin. Testa la stazionarietà dello spread.
|
||||
2. **BTCUSD vs BTCEUR** — EURUSD implicito vs EURUSD reale (se disponibile) → deviazioni triangolari.
|
||||
3. **ETH/SOL/AVAX vs BTC** — lead-lag a 1 minuto, beta hedging, cointegrazione, pairs/basket.
|
||||
|
||||
Procedura obbligatoria per ogni coppia/basket:
|
||||
1. Verifica che ogni serie sia I(1) (ADF/KPSS sui livelli e sulle differenze).
|
||||
2. **Test di cointegrazione Engle-Granger** e **Johansen** (formule in §5).
|
||||
3. Stima l'**hedge ratio** (OLS statico e **Kalman dinamico**).
|
||||
4. Modella lo spread come **Ornstein-Uhlenbeck**, stima la **half-life** (§5). Scarta le coppie con half-life troppo lunga (mean-reversion troppo lenta) o troppo corta (rumore/costi).
|
||||
5. Regola **z-score** entry/exit (§5), con soglie ottimizzate in walk-forward, MAI in-sample.
|
||||
6. Backtest **con costi realistici** e stima della **soglia di break-even costi** (quanti bps di costo azzerano il profitto — confronta con i 15 bps/half-turn di Fischer-Krauss-Deinert come ordine di grandezza).
|
||||
|
||||
**Criterio di accettazione Fase 2:** per almeno una coppia — spread stazionario (cointegrazione p<0.05), half-life ragionevole (minuti-ore), Sharpe out-of-sample dopo costi > 1.0, break-even cost superiore ai costi reali stimati, ≥100 trade.
|
||||
|
||||
### FASE 3 — Modelli ML con triple-barrier, meta-labeling e purged/combinatorial CV
|
||||
|
||||
Task:
|
||||
1. **Financial data structures** (López de Prado): oltre alle time bars, costruisci **dollar bars**/**volume bars**/**imbalance bars** (proprietà statistiche migliori).
|
||||
2. **Fractional differentiation (FFD):** rendi le serie stazionarie preservando memoria; trova il **d\*** minimo che passa l'ADF (§5).
|
||||
3. **Triple-barrier labeling** (§5): barriere profit-take/stop-loss scalate sulla volatilità locale + barriera verticale (tempo).
|
||||
4. **Meta-labeling:** modello primario per il lato (long/short), modello secondario binario per size/bet-or-not.
|
||||
5. **Sample weights** per l'unicità dei campioni (label sovrapposte).
|
||||
6. Modello: **LightGBM** su feature ingegnerizzate (§6). Sequence models solo se giustificati dai dati.
|
||||
7. **Validazione:** **purged k-fold con embargo** e **Combinatorial Purged CV (CPCV)** (§5). Calcola **PBO** e **Deflated Sharpe Ratio** contando TUTTE le configurazioni testate come N.
|
||||
8. **Bet sizing** dalle probabilità predette.
|
||||
|
||||
**Criterio di accettazione Fase 3:** DSR > 0.95 sul modello selezionato; PBO < 0.5; Sharpe out-of-sample dopo costi > 1.0; feature importance (MDI/MDA/SFI) sensata; nessun degrado catastrofico con piccole variazioni dei parametri (test di robustezza — un edge genuino sopravvive a un lookback 19/21 se il valore pubblicato è 20).
|
||||
|
||||
### FASE 4 — Reinforcement Learning (OPZIONALE, sperimentale)
|
||||
|
||||
Solo dopo che le Fasi 2-3 hanno prodotto qualcosa di solido. Usa FinRL/stable-baselines3 (PPO/DQN/SAC) in un env gym-style con **costi, liquidità e frizioni** incluse. Valuta con **molti training run / molti seed**: l'evidenza mostra che i risultati single-run sono inaffidabili e le policy sono instabili. Se non batte in modo stabile la baseline statarb dopo costi, **abbandona**.
|
||||
|
||||
**Criterio di accettazione Fase 4:** performance stabile su ≥15 seed, Sharpe netto costi > baseline Fase 2/3; altrimenti documenta il fallimento e fermati.
|
||||
|
||||
### FASE 5 — Paper trading con dati live
|
||||
|
||||
Task:
|
||||
1. Collega CCXT WebSocket (dati live) e l'adapter eToro demo.
|
||||
2. Esegui la/le strategie promosse in **modalità paper** (dry-run) per un periodo minimo definito.
|
||||
3. Journaling completo (§7) di ogni segnale, ordine simulato, fill, PnL, con colonna `motivazione`.
|
||||
4. Monitora il **drift** (le statistiche live divergono dal training?).
|
||||
|
||||
**Criterio di accettazione Fase 5:** ≥X settimane di paper trading; Sharpe live-paper coerente col backtest (degrado sotto soglia); nessun bug di esecuzione; drift sotto controllo.
|
||||
|
||||
### FASE 6 — Live con guardrail
|
||||
|
||||
Solo con `LIVE_TRADING=true` + conferma. Capitale iniziale minimo. Tutti i guardrail §8 attivi. Champion/challenger: il modello live è il champion; i challenger girano in paper; promozione automatica solo se battono il champion su metriche predefinite; **demozione automatica** e **kill-switch** se si superano le soglie di perdita.
|
||||
|
||||
---
|
||||
|
||||
## 4. Loop di apprendimento autonomo (come il bot "impara da solo" in sicurezza)
|
||||
|
||||
- **Retraining schedulato** (walk-forward): riaddestra su finestra scorrevole a cadenza fissa (modello FreqAI).
|
||||
- **Champion/challenger:** un nuovo candidato deve battere il champion in CPCV + paper prima della promozione.
|
||||
- **Drift monitor:** test statistici (es. KS / PSI su feature e residui); se drift → allerta ed eventuale retraining/demozione.
|
||||
- **Demozione automatica + kill-switch:** se il DSR live crolla o si supera la max-loss/day → stop.
|
||||
- **Estensibilità feature:** il feature store deve permettere di aggiungere nuovi parametri (nuova fonte news, nuovo on-chain metric) senza riscrivere il pipeline — vedi §6.
|
||||
|
||||
---
|
||||
|
||||
## 5. Formule e leggi da implementare (esplicite)
|
||||
|
||||
**Log-return:** `r_t = ln(P_t / P_{t-1})`.
|
||||
|
||||
**Realized volatility** (finestra n): `σ = sqrt(Σ r_t²)`; annualizza con `sqrt(periodi_per_anno)`.
|
||||
|
||||
**Z-score dello spread:** `z_t = (spread_t − media_mobile) / std_mobile`. Entry tipica `|z|>2`, exit vicino a `z=0`. Nota: l'esempio canonico QuantStart esce a `|z|=1`; implementazioni crypto pratiche escono spesso a `|z|≈0.5` con stop a `|z|>3-4`.
|
||||
|
||||
**Ornstein-Uhlenbeck / half-life** (Ernie Chan, *Algorithmic Trading*): regredisci via OLS `Δy_t = α + λ·y_{t-1} + ε_t`; `λ` è la velocità di mean-reversion (deve essere <0). `half-life = −ln(2)/λ`. Attenzione al segno: se parametrizzi `dy = θ(μ−y)dt` con θ>0, allora `half-life = ln(2)/θ` con θ = −λ (stesso risultato positivo). È la stessa regressione dell'ADF.
|
||||
|
||||
**Cointegrazione Engle-Granger (2 step):** (1) verifica I(1); (2) OLS `Y_t = α + β·X_t + u_t` → β = hedge ratio; (3) ADF sui residui `û_t`: `Δû_t = ρ·û_{t-1} + Σγ_p·Δû_{t-p} + ε_t`. H0 = no cointegrazione (unit root); rifiuta se ADF < valore critico residual-based (≈ −3.34 al 5%, NON le tabelle ADF standard). In Python: `statsmodels.tsa.stattools.coint(y,x)` (applica già i valori critici corretti) e `adfuller(spread, autolag="AIC")`.
|
||||
|
||||
**Johansen** (multivariato, permette più vettori di cointegrazione): VECM `Δy_t = Π·y_{t-1} + ΣΓ_i·Δy_{t-i} + ε_t`; rango di Π = n° relazioni di cointegrazione. Trace: `λ_trace(r) = −T·Σ_{i=r+1}^{K} ln(1−λ̂_i)`. Max-eigenvalue: `λ_max(r) = −T·ln(1−λ̂_{r+1})`. In Python: `statsmodels.tsa.vector_ar.vecm.coint_johansen` → `.lr1` (trace), `.lr2` (max-eig), `.cvt`/`.cvm` (valori critici), `.evec` (vettori di cointegrazione = hedge ratio).
|
||||
|
||||
**Kalman filter per hedge ratio dinamico:** stato `[β_t, α_t]` con random walk `β_t = β_{t-1} + w_t`. Predizione covarianza `R = P + Vw`. Predizione osservazione `y_est = x_t·β`. Varianza `Q = x_t·R·x_tᵀ + Ve`. Errore `e = y − y_est`. Kalman gain `K = R·x_tᵀ / Q`. Update: `β_t = β_t + K·e`, `P = R − K·x_t·R`. Usa `pykalman` o implementazione custom; `delta = Vw/(1−Vw)·I` controlla la velocità di variazione di β (valori tipici 1e-4 … 1e-5).
|
||||
|
||||
**Triple-barrier labeling:** per ogni evento definisci barriera superiore (take-profit, es. +k·σ), inferiore (stop-loss, −k·σ) e verticale (max holding time). Label = +1 se tocca prima la superiore, −1 se l'inferiore, 0 (o segno del return) alla scadenza.
|
||||
|
||||
**Fractional differentiation:** pesi con ricorrenza `ω_0=1`, `ω_k = −ω_{k-1}·(d−k+1)/k`; serie `(1−B)^d X_t = Σ ω_k·X_{t-k}`. Usa FFD a finestra fissa; scegli `d* = min{d : la serie passa l'ADF}` per preservare massima memoria (López de Prado riporta d* spesso ben < 1). È solo preprocessing: non crea alpha e non previene l'overfitting.
|
||||
|
||||
**Kelly criterion:** `f* = (b·p − q)/b`, con p prob. di vincita, q=1−p, b = rapporto vincita/perdita medi. Forma equivalente per traders: `Kelly% = W − (1−W)/R` (W win rate, R = avg win/avg loss). Forma continua: `f* = (μ − r)/σ²`. **Fractional Kelly:** usa 0.25-0.5·f\* (half-Kelly cattura ~75% della crescita con circa metà del drawdown; full Kelly ha ~1/3 di probabilità di un drawdown del 50%). Servono ≥50-100 trade prima che gli input siano affidabili.
|
||||
|
||||
**Volatility targeting:** `size = (σ_target / σ_realizzata)·capitale`, con cap di leva.
|
||||
|
||||
**Sharpe / Sortino / Calmar:** `Sharpe = (media_return − rf)/std`; Sortino usa solo la downside deviation; `Calmar = CAGR / |max_drawdown|`.
|
||||
|
||||
**Probabilistic Sharpe Ratio (PSR):** `PSR(SR*) = Φ( (SR_hat − SR*)·sqrt(T−1) / sqrt(1 − g3·SR_hat + ((g4−1)/4)·SR_hat²) )`, con g3 skewness, g4 kurtosis, T n° osservazioni.
|
||||
|
||||
**Deflated Sharpe Ratio (DSR):** `DSR = PSR(SR*)` dove il benchmark è lo Sharpe massimo atteso su N trial senza skill: `SR* = sqrt(V)·[ (1−γ)·Φ⁻¹(1−1/N) + γ·Φ⁻¹(1−1/(N·e)) ]`, con V varianza degli SR dei trial e γ ≈ 0.5772 (Eulero-Mascheroni). DSR>0.95 = evidenza forte contro il caso. Riduce al PSR quando N=1.
|
||||
|
||||
**Combinatorial Purged CV (CPCV):** partiziona la serie in N gruppi, scegli k gruppi come test per split. N° split = `C(N,k)`. N° di backtest path `φ = (k/N)·C(N,k)`. Esempio López de Prado: N=6, k=2 → 15 split, 5 path. **Purging:** rimuovi dal train le osservazioni le cui label si sovrappongono nel tempo a quelle del test. **Embargo:** elimina un buffer di osservazioni subito dopo ogni blocco di test (~1%). Implementazione: `skfolio.model_selection.CombinatorialPurgedCV(n_folds=N, n_test_folds=k, purged_size, embargo_size)`.
|
||||
|
||||
**Cost model / EV netto:** `EV_netto = p·avg_win − (1−p)·avg_loss − costi` (fee + spread + slippage + funding). Non entrare se `EV_netto ≤ 0`.
|
||||
|
||||
**Order book imbalance (queue imbalance):** `imbalance = (V_bid − V_ask)/(V_bid + V_ask) ∈ [−1,+1]`, con V ai best levels.
|
||||
|
||||
**Order Flow Imbalance (OFI, Cont-Kukanov-Stoikov 2014, *Journal of Financial Econometrics* 12(1):47-88):** `OFI = L^b − C^b − M^s − L^s + C^s + M^b` (net signed order flow ai best quotes: L=limit, C=cancellation, M=market; b=bid/buy, s=ask/sell). Impatto lineare: `ΔP_k = β·OFI_k + ε_k`, con `β ≈ 1/(2D)`, D = profondità media (empiricamente R² ≈ 67% su 50 titoli NYSE). **Distingui i due:** la ratio `(V_bid−V_ask)/(V_bid+V_ask)` è "order book imbalance", NON l'OFI canonico (che è il flusso netto firmato).
|
||||
|
||||
**Avellaneda-Stoikov (market making, 2008):** reservation price `r(s,q,t) = s − q·γ·σ²·(T−t)` (s mid, q inventory, γ risk aversion, σ vol, (T−t) tempo residuo). Spread ottimale totale `≈ γ·σ²·(T−t) + (2/γ)·ln(1 + γ/κ)`, con κ parametro di liquidità del book. Bid = `r − spread/2`, Ask = `r + spread/2`. Nota: il market making retail è difficile per adverse selection e latenza — trattalo come sperimentale.
|
||||
|
||||
**Funding-rate / cash-and-carry:** `APR ≈ funding_rate_per_periodo × periodi_anno` (es. 0.01%/8h × 1095 ≈ 11% APR). Delta-neutral: long spot + short perp quando funding>0. `Profitto = Σ funding − fee_entry − fee_exit − costo_capitale`. Rischi: rate flip (il funding diventa negativo), basis risk, custodial risk, compressione dei rate con l'afflusso di capitale.
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature set (copre i parametri dell'utente)
|
||||
|
||||
L'utente ha citato "articolo, orario dell'asta, prezzo attuale, puntate degli utenti, latenza rete". Interpretazione dichiarata come feature set estensibile per un bot di trading:
|
||||
- **"articolo" → news/sentiment:** FinBERT/CryptoBERT su titoli/articoli; LLM scoring; event detection. Fonti: RSS, CryptoPanic, GDELT, Reddit/X API. Feature: sentiment score, volume di news, sorpresa vs baseline. (Ricorda l'accuratezza modesta del sentiment — vedi §1: usalo come feature, non come segnale principale.)
|
||||
- **"orario dell'asta" → tempo/sessione:** encoding ciclici (sin/cos di ora e giorno-settimana), sessioni (Asia/EU/US), **orari di settlement funding (00:00/08:00/16:00 UTC)**, scadenze opzioni, release macro schedulate (CPI, FOMC).
|
||||
- **"prezzo attuale" → prezzo e derivati:** log-return multi-orizzonte, realized vol, indicatori tecnici come input (non come regole), microprice.
|
||||
- **"puntate degli utenti" → order flow / crowd positioning:** order book depth & imbalance, trade flow / CVD, **open interest**, **funding rate**, long/short ratio, **liquidazioni**, exchange net flows. Via CCXT (`fetchOpenInterestHistory`, `fetchFundingRateHistory`, `fetchLiquidations`).
|
||||
- **"latenza rete" → latenza/API:** misura il round-trip time verso l'exchange, l'order-ack time e il timestamp sync (NTP); usala per decidere la **feasibility** della strategia (se la finestra di arbitraggio < latenza → non entrare). Loggala sia come feature sia come gate di esecuzione.
|
||||
|
||||
Il feature store deve essere **estensibile**: aggiungere una feature = aggiungere una funzione che ritorna una serie allineata al timestamp, registrata in un registry; nessuna riscrittura del pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 7. Journaling (CSV `;` con colonna motivazione)
|
||||
|
||||
Ogni tabella prodotta dal bot è CSV con `sep=';'` e almeno queste colonne dove pertinente: `timestamp;asset;evento;azione;prezzo;size;prob_predetta;z_score;funding;costi_stimati;pnl;motivazione`. La colonna `motivazione` spiega in italiano perché l'azione è stata presa (es. *"z=2.3 > soglia 2.0 su coppia ETH/BTC cointegrata p=0.02, half-life 45min, EV netto costi positivo"*). Doppia scrittura: file in `journal/` + tabella Supabase per query/reporting. Report di backtest, risultati CV, log di drift e trade blotter seguono tutti questa convenzione.
|
||||
|
||||
---
|
||||
|
||||
## 8. Guardrail e sicurezza
|
||||
|
||||
- API key SOLO da env / secrets manager; chiavi separate demo/live; mai in git.
|
||||
- `LIVE_TRADING` default false; ordine live richiede flag + conferma interattiva.
|
||||
- **Max loss per day** (hard stop), **max drawdown** limite, **kill-switch** globale.
|
||||
- Limiti di **esposizione** per asset e per correlazione; limiti di leva.
|
||||
- **VaR/CVaR** monitorati; risk of ruin controllato dal fractional Kelly.
|
||||
- Rate limiting rispettato (backoff esponenziale su 429; eToro 60/20 req-min; CCXT `enableRateLimit: true`).
|
||||
- Logging completo di ogni ordine ed errore; idempotenza sugli ordini; riconciliazione posizioni all'avvio.
|
||||
- Test: unit test sulle formule (half-life, Kelly, z-score, cost model, OU); integration test sugli adapter in demo.
|
||||
|
||||
---
|
||||
|
||||
## 9. Riferimenti e fonti
|
||||
|
||||
- López de Prado, *Advances in Financial Machine Learning* (Wiley, 2018) — triple-barrier, meta-labeling, fracdiff, purged/combinatorial CV, sample weights, DSR, PBO, bet sizing.
|
||||
- Bailey & López de Prado, "The Deflated Sharpe Ratio" (*Journal of Portfolio Management*, 2014); "The Probability of Backtest Overfitting" (*Journal of Computational Finance*, 2017); "Pseudo-mathematics and financial charlatanism" (*Notices of the AMS*, 2014).
|
||||
- McLean & Pontiff, "Does Academic Research Destroy Stock Return Predictability?" (*Journal of Finance* 71(1):5-32, 2016).
|
||||
- Harvey, Liu & Zhu, "…and the Cross-Section of Expected Returns" (*Review of Financial Studies* 29(1):5-68, 2016).
|
||||
- Fil & Kristoufek, "Pairs Trading in Cryptocurrency Markets" (*IEEE Access*, 2020); Fischer, Krauss & Deinert, "Statistical Arbitrage in Cryptocurrency Markets" (*Journal of Risk and Financial Management* 12(1):31, 2019).
|
||||
- Cont, Kukanov & Stoikov, "The Price Impact of Order Book Events" (*Journal of Financial Econometrics* 12(1):47-88, 2014; arXiv:1011.6402).
|
||||
- Avellaneda & Stoikov, "High-frequency trading in a limit order book" (2008).
|
||||
- Ernie Chan, *Algorithmic Trading: Winning Strategies and Their Rationale*.
|
||||
- Shobayo et al., "Innovative Sentiment Analysis… FinBERT, GPT-4 and Logistic Regression" (arXiv:2412.06837 / *Big Data and Cognitive Computing* 8(11):143, 2024).
|
||||
- Alexander, "Latency Arbitrage in Cryptocurrency Markets" (SSRN 5143158).
|
||||
- FinRL / FinRL-Meta (Liu et al., arXiv:2011.09607, 2111.09395, 2112.06753) e le critiche su simulation-to-reality gap e instabilità (multiplicity-aware evaluation).
|
||||
- Docs: CCXT (docs.ccxt.com), Freqtrade/FreqAI (docs.freqtrade.io), eToro API (api-portal.etoro.com, builders.etoro.com), Binance Vision, statsmodels, arbitragelab (Hudson & Thames), skfolio.
|
||||
|
||||
---
|
||||
|
||||
## 10. Note EU/Italia (non consulenza)
|
||||
|
||||
- **Tassazione crypto Italia 2026:** l'imposta sostitutiva sulle plusvalenze da cripto-attività (redditi diversi ex art. 67 c.1 lett. c-sexies TUIR) sale al **33% dal 1° gennaio 2026** (era 26% fino al 2025), come fissato dalla **Legge 30 dicembre 2024, n. 207**; senza franchigia, si applica al momento del realizzo indipendentemente dalla data di acquisto. Eccezione al **26%** solo per gli **E-Money Token (EMT) in euro conformi MiCAR**, introdotta dalla **Legge 30 dicembre 2025, n. 199, art. 1, comma 28**. Il journaling deve registrare ogni realizzo ai fini del quadro RT e del monitoraggio RW.
|
||||
- **MiCA/KYC:** usa exchange conformi; verifica l'operatività retail in Italia al momento (alcuni servizi retail italiani di Binance sono stati ridotti/cessati). Scegli l'exchange dell'adapter CCXT di conseguenza.
|
||||
- Il bot NON fornisce consulenza finanziaria; è uno strumento di ricerca personale.
|
||||
|
||||
---
|
||||
|
||||
**Inizia dalla FASE 0. Non passare alla fase successiva finché i criteri di accettazione della fase corrente non sono soddisfatti e documentati in un report CSV `;` con colonna `motivazione`. Ricorda i tre pilastri onesti: il problema è la validazione, non la previsione; conta TUTTI i test che fai; nessun euro reale a rischio prima dei gate di paper trading.**
|
||||
Ottimo! Ora effettua le seguenti modifiche:
|
||||
-
|
||||
@@ -36,7 +36,7 @@
|
||||
#define AppName "Encelado"
|
||||
#define AppPublisher "Alberto Balbo"
|
||||
#define AppExeName "Encelado.exe"
|
||||
#define AppDescription "Arbitraggio statistico su coppie, Binance Futures"
|
||||
#define AppDescription "Correlation Baskets su eToro (CFD forex)"
|
||||
|
||||
[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 Binance vivono in %LocalAppData%\Encelado, fuori dalla cartella
|
||||
{ Le chiavi eToro 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 Binance salvate?' + #13#10#13#10 +
|
||||
'Vuoi eliminare anche le chiavi eToro 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.',
|
||||
|
||||
+14
-18
@@ -12,7 +12,7 @@ posto in `Release.proj`:
|
||||
| Dove sta la versione | `AutoBidder.csproj` | `Directory.Build.props`, ereditato da tutti i progetti |
|
||||
| Cosa produce `dotnet publish` | un eseguibile unico | una cartella: l'app legge `encelado.json` accanto a sé |
|
||||
| Copia portabile allegata | il solo `.exe` | uno zip della cartella |
|
||||
| Cosa rigioca `Backtest` | i dossier delle aste | serie storiche di prezzi |
|
||||
| Cosa rigioca `Backtest` | i dossier delle aste | barre M15 bid/ask dei basket |
|
||||
|
||||
| File | Cos'è |
|
||||
|---|---|
|
||||
@@ -27,7 +27,7 @@ posto in `Release.proj`:
|
||||
| Attività | Cosa fa |
|
||||
|---|---|
|
||||
| `verifica` | Compila e lancia i test. |
|
||||
| `backtest` | Rigioca una serie storica di prezzi. |
|
||||
| `backtest` | Ricerca sui basket: `ticks`, `baskets`, `falsify`. |
|
||||
| `crea installatore` | Chiede la versione, verifica, pubblica, esegue Inno Setup. |
|
||||
| `crea installatore (senza rieseguire i test)` | Solo pubblicazione e installatore. |
|
||||
| `rilascia su Gitea` | Tutto quanto sopra, più tag e release con i file allegati. |
|
||||
@@ -39,11 +39,10 @@ dotnet msbuild build/Release.proj -t:Verifica
|
||||
dotnet msbuild build/Release.proj -t:Pacchetto
|
||||
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=3.3.0 -p:Note="Cosa cambia"
|
||||
|
||||
# Il backtest vuole la cartella con un CSV a un minuto per simbolo
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="C:\Users\alber\Downloads\Binance"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=basket
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=inspect
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=train -p:Extra="--a ETHUSDT --b BTCUSDT --promote"
|
||||
# La ricerca sui basket: barre M15 in data/market (da `ticks`), tabelle in results/ e reports/
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data\market"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=falsify -p:Extra="--costs api"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="A:\Download\Trading" -p:Comando=ticks
|
||||
```
|
||||
|
||||
| Proprietà | Predefinito | A cosa serve |
|
||||
@@ -54,20 +53,17 @@ dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=train -p:
|
||||
| `Sovrascrivi` | `false` | Sostituisci una release Gitea con lo stesso tag. |
|
||||
| `Bozza` | `false` | Crea la release come bozza. |
|
||||
| `ConsentiModifiche` | `false` | Tagga anche con l'albero sporco. Serve saperlo. |
|
||||
| `Dati` | — | La cartella con i CSV a un minuto (`<SIMBOLO>.csv`). Obbligatoria per `Backtest`. |
|
||||
| `Comando` | `pairs` | Rigiocata: `run`, `pairs`, `explore`, `sweep`, `confirm`, `basket`. Ricerca (le fasi della guida): `inspect`, `import`, `baseline`, `cointegration`, `dataset`, `train`, `rl`, `status`. |
|
||||
| `Barre` | `15m` | Timeframe su cui ripiegare le barre da un minuto. |
|
||||
| `Extra` | vuoto | Altre opzioni passate allo strumento così come sono, es. `--a ETHUSDT --b BTCUSDT --promote`. |
|
||||
| `Dati` | — | La cartella dei dati (`candles_<SYMBOL>_M15.csv` per `baskets`/`falsify`, tick MT5 per `ticks`). Obbligatoria per `Backtest`. |
|
||||
| `Comando` | `baskets` | `ticks`, `baskets`, `falsify`. |
|
||||
| `Extra` | vuoto | Altre opzioni passate allo strumento così come sono, es. `--costs api --quick`. |
|
||||
|
||||
## La ricerca e il meta-modello
|
||||
|
||||
`backtest train` addestra il meta-modello (GBDT nativo, validazione CPCV, PBO e DSR
|
||||
contando tutte le configurazioni provate) e lo salva nel database locale del bot,
|
||||
`%ProgramData%\Encelado\encelado.db`. Con `--promote` lo rende **campione** della
|
||||
coppia, ma solo se supera i criteri della Fase 3; il bot lo carica alla prima
|
||||
ricalibrazione utile e da quel momento può rifiutare un ingresso o ridurne la size.
|
||||
Ogni tabella prodotta dalla ricerca è un CSV `;` con una colonna `motivazione`, nella
|
||||
cartella `ricerca` accanto allo strumento oppure dove indica `--out`.
|
||||
Il meta-modello del bot (regressione logistica in ombra, MLP challenger, bandit) si
|
||||
addestra da solo dal ledger nel ciclo settimanale e non passa da qui: vedi
|
||||
`docs/ML_AND_LEARNING.md`. Lo strumento di ricerca produce solo le tabelle del
|
||||
backtest (`results/trials.csv`, `results/riepilogo_baskets.csv`,
|
||||
`reports/falsificazione.csv`), che `docs/STRATEGY.md` commenta.
|
||||
|
||||
## Chi chiede la versione
|
||||
|
||||
|
||||
+13
-23
@@ -415,48 +415,38 @@
|
||||
<Message Importance="High" Text=" tutto a posto" />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Rigiocata sui prezzi ═════════════════════ -->
|
||||
<!-- ═════════════════════ Ricerca sui basket ═════════════════════ -->
|
||||
|
||||
<!--
|
||||
Il corrispettivo del backtest sui dossier di AutoBidder. Qui non si rigiocano
|
||||
aste ma coppie di serie storiche di prezzi, con lo strumento in
|
||||
tools/Encelado.Backtest.
|
||||
Lo strumento in tools/Encelado.Backtest: `ticks` converte i tick MT5 in barre
|
||||
M15 bid/ask, `baskets` rigioca la strategia (baseline, griglia, PSR/DSR, PBO,
|
||||
walk-forward), `falsify` esegue i test di falsificazione. Ogni tabella è un CSV
|
||||
con ; e colonna motivazione. Vedi docs/STRATEGY.md per i risultati.
|
||||
|
||||
── 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.
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data\market"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=falsify -p:Extra="‐‐costs api"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="A:\Download\Trading" -p:Comando=ticks
|
||||
-->
|
||||
<Target Name="Backtest">
|
||||
<PropertyGroup>
|
||||
<Comando Condition="'$(Comando)' == ''">pairs</Comando>
|
||||
<Barre Condition="'$(Barre)' == ''">15m</Barre>
|
||||
<Comando Condition="'$(Comando)' == ''">baskets</Comando>
|
||||
<BacktestExe>$(Radice)\tools\Encelado.Backtest\bin\Release\net10.0\backtest.exe</BacktestExe>
|
||||
</PropertyGroup>
|
||||
|
||||
<Error Condition="'$(Dati)' == ''"
|
||||
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; ricerca: inspect, import, baseline, cointegration, dataset, train, rl, status (altre opzioni in -p:Extra=)." />
|
||||
Text="Serve la cartella dei dati: -p:Dati="%USERPROFILE%\Documents\Encelado\data\market" (barre candles_SYMBOL_M15.csv) per baskets e falsify, oppure la cartella dei tick MT5 per ticks.%0AComandi disponibili in -p:Comando= : ticks, baskets, falsify (altre opzioni in -p:Extra=, es. --costs api --quick)." />
|
||||
|
||||
<Error Condition="!Exists('$(Dati)')" Text="Cartella dati non trovata: $(Dati)" />
|
||||
|
||||
<Message Importance="High" Text="== Rigiocata sulle coppie ==" />
|
||||
<Message Importance="High" Text="== Ricerca sui basket ==" />
|
||||
<Message Importance="High" Text=" dati : $(Dati)" />
|
||||
<Message Importance="High" Text=" comando : $(Comando) su barre da $(Barre)" />
|
||||
<Message Importance="High" Text=" comando : $(Comando) $(Extra)" />
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)" EnvironmentVariables="$(AmbientePulito)"
|
||||
Command="dotnet build "$(BacktestProj)" -c Release --nologo -v q" />
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)"
|
||||
Command=""$(BacktestExe)" $(Comando) --data "$(Dati)" --tf $(Barre) $(Extra)" />
|
||||
Command=""$(BacktestExe)" $(Comando) --data "$(Dati)" $(Extra)" />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Eseguibile ═════════════════════ -->
|
||||
|
||||
+33
-206
@@ -1,225 +1,52 @@
|
||||
{
|
||||
"_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.",
|
||||
"_commento": "Configurazione di Encelado — Correlation Baskets su eToro (CFD forex). Le chiavi con il prefisso _ sono documentazione e vengono ignorate. I parametri della strategia (basket, preset, soglie, rischio) stanno in strategy.json accanto a questo file. Le chiavi API non stanno qui: si inseriscono dalla finestra e vivono cifrate in %LOCALAPPDATA%\\Encelado\\etoro.dat, oppure nelle variabili d'ambiente ETORO_API_KEY e ETORO_USER_KEY.",
|
||||
|
||||
"_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: per questo la configurazione di fabbrica punta alla TESTNET, dove gli ordini partono davvero ma i soldi sono finti. Lo strumento è in tools/Encelado.Backtest: 'backtest basket --data <cartella>' rifà 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
|
||||
"etoro": {
|
||||
"_note": "eToro Public API. environment = demo oppure real: chiavi e rotte sono diverse, e l'ambiente attivo è sempre visibile nella finestra.",
|
||||
"environment": "demo",
|
||||
"baseUrl": "https://public-api.etoro.com",
|
||||
"requestTimeoutSeconds": 20,
|
||||
"_fillTimeoutSeconds": "Quanto attendere l'esito di un ordine (eToro lo lavora in modo asincrono) prima di trattarlo come non confermato e riconciliare. È anche il timeout della seconda gamba (leg-risk).",
|
||||
"fillTimeoutSeconds": 5
|
||||
},
|
||||
|
||||
"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": "Calcola e registra tutto, non invia nessun ordine. Spento di fabbrica: in testnet i soldi sono finti e la prova più vicina alla produzione è quella con gli ordini veri sul conto di prova — eseguiti, rifiutati, parzialmente riempiti, con le stesse regole di lotto e di margine. Accendilo per osservare una configurazione nuova senza toccare nemmeno il conto di prova. Sul conto reale resta comunque la conferma esplicita all'avvio.",
|
||||
"dryRun": false,
|
||||
|
||||
"_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,
|
||||
"run": {
|
||||
"_executionMode": "Paper = simulatore locale sopra le quotazioni reali (nessun ordine sul conto). Demo = conto demo di eToro: ordini veri, denaro virtuale, il bot apre e chiude da solo. Live = conto reale: richiede allowLive = true e la frase CONFERMO LIVE a ogni avvio. Nessuna modalità chiede l'approvazione dei singoli ordini (decisione D-20).",
|
||||
"executionMode": "Demo",
|
||||
"allowLive": false,
|
||||
"_pollSeconds": "Secondi fra due letture delle quotazioni (una richiesta per tutti gli strumenti). 3 s = 20 richieste al minuto su una quota di 120: resta spazio per candele e costi.",
|
||||
"pollSeconds": 3,
|
||||
"_statusSeconds": "Ogni quanti secondi il bot scrive una riga di stato nel log (e sulla console in headless).",
|
||||
"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": "true = fermare il bot chiude i basket aperti a mercato. false = restano sul conto con gli stop nativi sul server, senza nessuno che applichi il take-profit o lo stop di basket finché il bot non riparte.",
|
||||
"closeOnShutdown": false,
|
||||
"logEveryBar": true
|
||||
"strategyFile": "strategy.json",
|
||||
"_cartelle": "Relative alla cartella di questo file: data (mercato, calendario, notizie, ledger, modelli), knowledge (calibrazione, proposte, registri), reports.",
|
||||
"dataDirectory": "data",
|
||||
"knowledgeDirectory": "knowledge",
|
||||
"reportsDirectory": "reports",
|
||||
"_paper": "Solo per executionMode = Paper: saldo iniziale del simulatore e slippage per gamba oltre lo spread reale del momento.",
|
||||
"paperStartingBalance": 10000,
|
||||
"paperSlippagePips": 0.3
|
||||
},
|
||||
|
||||
"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
|
||||
"ui": {
|
||||
"_timeZone": "Fuso orario con cui la finestra mostra gli orari. 'computer' = quello di Windows; 'UTC'; oppure un id di Windows (es. 'W. Europe Standard Time') o IANA (es. 'Europe/Rome'). Il file di log porta l'offset, il ledger è in UTC: cambiare questo valore non tocca nessun file.",
|
||||
"timeZone": "computer"
|
||||
},
|
||||
|
||||
"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": "trace, debug, info, warn, error, none. 'info' basta: ogni rifiuto che impedisce un ordine viene scritto a questo livello o sopra, con il basket e il motivo esatto.",
|
||||
"level": "info",
|
||||
|
||||
"_directory": "Dove salvare tutti gli output. Relativa alla cartella della configurazione (Documenti\\Encelado), oppure un percorso assoluto. Si cambia anche da Impostazioni.",
|
||||
"directory": "logs",
|
||||
|
||||
"console": false,
|
||||
"_directory": "Cartella dei log, relativa a questo file se non è assoluta.",
|
||||
"directory": "logs",
|
||||
"file": "encelado.log",
|
||||
"_rotazione": "Superata maxFileSizeMb il file viene ruotato (encelado.1.log, encelado.2.log…) e ne restano maxFiles.",
|
||||
"maxFileSizeMb": 32,
|
||||
"maxFiles": 10,
|
||||
|
||||
"_analysis": "Tutte le tabelle usano il separatore ; e hanno una colonna motivazione che spiega in italiano il perché della riga. 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; trades.csv una riga per ogni evento d'ordine. decisions ed executions si uniscono su decisionId.",
|
||||
"tradeJournal": "trades.csv",
|
||||
"decisionLog": "decisions.csv",
|
||||
"executionLog": "executions.csv",
|
||||
|
||||
"logMarketData": false,
|
||||
"_righe": "statusLines = righe della striscia di attività nella dashboard; bufferedLines = righe tenute in memoria dalla pagina Log (il file su disco resta completo).",
|
||||
"statusLines": 200,
|
||||
"bufferedLines": 5000
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"_note": "Il database locale (SQLite): barre canoniche, dataset, modelli, validazioni, journal e drift. Vuoto = %ProgramData%\\Encelado\\encelado.db, una cartella di sistema separata sia da Documenti (dove sta la configurazione) sia dai dati utente (dove stanno le chiavi).",
|
||||
"enabled": true,
|
||||
"databasePath": ""
|
||||
},
|
||||
|
||||
"ml": {
|
||||
"_note": "Il meta-modello (meta-labeling): un classificatore addestrato sugli esiti dei segnali passati che dice se un ingresso proposto dalla regola statistica ha probabilità di ripagare i costi. Non propone mai operazioni: può solo rifiutarne una o ridurne la size. Si addestra con lo strumento tools/Encelado.Backtest (comandi dataset, train) e vive nel database locale come 'campione' della coppia; senza campione queste voci non fanno nulla.",
|
||||
"enabled": true,
|
||||
"_minProbability": "Sotto questa probabilità predetta l'ingresso viene rifiutato. 0.55 significa: opero solo quando il modello vede un vantaggio, anche piccolo, rispetto a lanciare una moneta.",
|
||||
"minProbability": 0.55,
|
||||
"_sizeByProbability": "Con true la size è proporzionale alla convinzione del modello (2·Φ(z)−1, López de Prado), fra minSizeFraction e 1; con false è tutto o niente.",
|
||||
"sizeByProbability": true,
|
||||
"minSizeFraction": 0.25,
|
||||
"_drift": "Ogni driftCheckBars segnali valutati le feature live vengono confrontate con quelle di addestramento (PSI e Kolmogorov-Smirnov). Una feature con PSI ≥ driftPsiAlert è 'in deriva'; con driftAlertsToSuspend feature in deriva il modello viene sospeso fino alla ricalibrazione successiva e il bot torna alla sola regola statistica. 0 = mai sospendere, solo registrare.",
|
||||
"driftCheckBars": 50,
|
||||
"driftPsiAlert": 0.25,
|
||||
"driftAlertsToSuspend": 3
|
||||
},
|
||||
|
||||
"_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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"_comment": "Encelado — strategia Correlation Baskets su eToro. Cinque basket di due coppie forex correlate: si entra quando il cross sintetico diverge (z-score), si esce quando converge o al take-profit di basket in pip; lo stop di basket è obbligatorio. Ogni chiave con '_' davanti è documentazione.",
|
||||
|
||||
"_preset": "Conservative | Moderate | Aggressive. Fissa zIn, riskPerBasketPct, maxBaskets, tpPips, maxAdds, zStop; si cambia a caldo dalla finestra senza toccare i basket aperti. Le chiavi omonime qui sotto, se presenti, sovrascrivono il preset.",
|
||||
"preset": "Moderate",
|
||||
|
||||
"_signalMode": "ZScoreSynthetic (default, |z| >= zIn sul cross sintetico) oppure PipDivergence (fedele all'interfaccia Titany: divergenza in pip dall'ancora, dIn).",
|
||||
"signalMode": "ZScoreSynthetic",
|
||||
"_exitMode": "First = la prima fra TP in pip e rientro dello z; FixedPips = solo TP in pip lordi; ZReturn = solo |z| <= zOut.",
|
||||
"exitMode": "First",
|
||||
"_averagingMode": "Off | AddOnce | Grid. Off in live; AddOnce in paper. Moltiplicatore di lotto sempre 1,0 (niente martingala).",
|
||||
"averagingMode": "Off",
|
||||
"tpMode": "Pips",
|
||||
"_sameCrossPolicy": "I basket 4 e 5 sono entrambi EURCAD: Exclusive = uno solo aperto per volta; Half = entrambi a metà size.",
|
||||
"sameCrossPolicy": "Exclusive",
|
||||
"preferDirectCross": false,
|
||||
|
||||
"_indicatori": "Correlazione di Pearson rolling dei rendimenti M15 su window (ρ_W) e windowShort (ρ_20); z-score del cross sintetico su window; half-life OLS ricalcolata ogni halfLifeRecalcHours.",
|
||||
"window": 100,
|
||||
"windowShort": 20,
|
||||
"rhoMin": 0.60,
|
||||
"rhoShortMin": 0.40,
|
||||
"halfLifeMinBars": 4,
|
||||
"halfLifeMaxBars": 96,
|
||||
"halfLifeRecalcHours": 4,
|
||||
"atrPeriod": 14,
|
||||
"ewmaSpan": 100,
|
||||
"trendPeriod": 14,
|
||||
|
||||
"zOut": 0.25,
|
||||
"dIn": 15,
|
||||
"anchorBars": 32,
|
||||
"gridStepZ": 0.75,
|
||||
"lotMultiplier": 1.0,
|
||||
|
||||
"_uscite": "Stop di basket: |z| >= zStop, oppure perdita netta >= maxLossPerBasketPct dell'equity, oppure |ρ_20| < rhoBreak per rhoBreakBars barre, oppure maxHoldingBars barre (96 = 24 h).",
|
||||
"maxLossPerBasketPct": 1.5,
|
||||
"rhoBreak": 0.20,
|
||||
"rhoBreakBars": 8,
|
||||
"maxHoldingBars": 96,
|
||||
"tpAtrMultiple": 1.0,
|
||||
|
||||
"_costGate": "Costo = spread_A + spread_B (in pip-equivalenti di A) + markup e commissioni dell'API + overnight stimato per maxHoldingBars. Entrata solo se TP >= costMultiple × costo e ogni spread <= spreadMedianMultiple × la sua mediana delle ultime 24 h; spread oltre spreadAnomalyMultiple × mediana = chiusura forzata.",
|
||||
"costMultiple": 3,
|
||||
"spreadMedianMultiple": 2,
|
||||
"spreadAnomalyMultiple": 3,
|
||||
"slippagePipsPerLeg": 0.3,
|
||||
"overnightPipsPerDay": 0.3,
|
||||
|
||||
"_calendario": "Nessuna entrata nei blackoutBeforeMin minuti prima e blackoutAfterMin dopo un evento ad alto impatto sulle valute del basket; niente entrate dal venerdì fridayCutoffUtcHour UTC alla riapertura né nei primi openDelayMinutes dopo l'apertura settimanale; sessions = fasce orarie UTC ammesse (vuoto = sempre).",
|
||||
"blackoutBeforeMin": 45,
|
||||
"blackoutAfterMin": 30,
|
||||
"fridayCutoffUtcHour": 20,
|
||||
"openDelayMinutes": 30,
|
||||
"sessions": [],
|
||||
|
||||
"_sizing": "Lotto B = lotto A × (ATR_A × pipValue_A) / (ATR_B × pipValue_B); lotto A tale che la perdita allo stop valga riskPerBasketPct dell'equity; leva effettiva <= maxEffectiveLeverage sul nozionale complessivo; orderLeverage è la leva dichiarata a eToro per ogni gamba (1, 2, 5, 10, 20, 30).",
|
||||
"maxEffectiveLeverage": 10,
|
||||
"orderLeverage": 10,
|
||||
"_volScale": "zIn effettivo = zIn × clamp(σ_prevista / σ_media_30g, volScaleMin, volScaleMax).",
|
||||
"volScaleMin": 0.8,
|
||||
"volScaleMax": 1.5,
|
||||
"volAverageDays": 30,
|
||||
"mlMinProbability": 0.55,
|
||||
|
||||
"_sicurezza": "equityStopPct: perdita dal picco di equity oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.",
|
||||
"equityStopPct": 9,
|
||||
"dailyLossPct": 3,
|
||||
"legTimeoutSec": 5,
|
||||
"clockSkewMaxSeconds": 5,
|
||||
|
||||
"_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.",
|
||||
"baskets": [
|
||||
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
|
||||
{ "a": "AUDUSD", "b": "USDCAD", "enabled": true, "note": "cross sintetico AUDCAD" },
|
||||
{ "a": "NZDUSD", "b": "EURNZD", "enabled": true, "note": "cross sintetico EURUSD: replica EURUSD pagando due spread" },
|
||||
{ "a": "USDCAD", "b": "EURUSD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 5)" },
|
||||
{ "a": "EURAUD", "b": "AUDCAD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 4)" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# Architettura di Encelado
|
||||
|
||||
Aggiornato: 2026-09-16 (Fase 0 della modifica "Correlation Baskets" su eToro).
|
||||
|
||||
## 1. Che cosa c'era prima della modifica (ricognizione)
|
||||
|
||||
### 1.1 Albero dei progetti
|
||||
|
||||
```
|
||||
Encelado.slnx
|
||||
├── src/Encelado.Core libreria portabile (net10.0), zero NuGet, AOT/trim-compatibile
|
||||
│ ├── Backtest/ replay su coppie cointegrate (era Binance), CsvBarSource, CrossSectional
|
||||
│ ├── Indicators/ SMA, EMA, RSI, MACD, ATR, RollingStdDev, Bollinger, Donchian, RollingWindow<T>
|
||||
│ ├── Journal/ IJournalSink e record dei journal (DecisionRow, TradeRow, ProbaDecisionRow…)
|
||||
│ ├── Market/ Bar, Quote, Tick, Side, TimeFrame
|
||||
│ ├── Ml/ GBDT nativo, meta-labeling, triple barrier, PurgedCv/CPCV, Pbo (CSCV), Classification (AUC, Brier, log-loss, calibrazione), DriftMonitor (PSI/KS)
|
||||
│ ├── Research/ pipeline ProbaBot: AssetFrame, eventi CUSUM, bracci di feature B/C, EventBacktest, Trials, Gates
|
||||
│ ├── Risk/ RiskEngine e RiskLimits (kill-switch giornaliero, esposizione, spread)
|
||||
│ ├── Rl/ Mlp a due strati (Adam), DqnAgent, PairEnvironment
|
||||
│ ├── Statistics/ Ols, DickeyFuller, Cointegration (+HalfLife), Johansen, Kalman, Pca, Performance (Sharpe, PSR, DSR, Kelly), Normal
|
||||
│ └── Strategies/ StatArbStrategy (coppie), StrategyParameters
|
||||
├── src/Encelado.Storage SQLite (Microsoft.Data.Sqlite) — l'unica dipendenza NuGet a runtime; journal in doppia scrittura, dataset, modelli, campioni
|
||||
├── src/Encelado.CTrader adattatore cTrader Open API (NuGet cTrader.OpenAPI.Net). NON referenziato dal Bot: non è mai stato collegato
|
||||
├── src/Encelado.Bot WPF (net10.0-windows), WinExe "Encelado.exe", zero NuGet
|
||||
│ ├── Configuration/ BotConfig, ConfigLoader (JsonDocument a mano, chiavi sconosciute segnalate), ConfigDefaults (JSON di fabbrica incorporato), ConfigWriter (modifica per percorso puntato, scrittura atomica), CredentialStore (DPAPI in %LOCALAPPDATA%\Encelado), CredentialResolver
|
||||
│ ├── Engine/ BotSupervisor (ciclo di vita, snapshot), ProbaEngine (motore cTrader, incompleto), AccountState, BotSnapshot, TradeJournal, DecisionLog
|
||||
│ ├── Diagnostics/ CsvTable (tabelle ';' con header e spostamento in .old), Metrics
|
||||
│ ├── Logging/ Log statico non bloccante su Channel<T>, file ';' con rotazione, Sink per la UI
|
||||
│ ├── Ui/ MainViewModel (INotifyPropertyChanged a mano), Theme.xaml (tema scuro proprio), pagine Status/Log/Settings, LoginWindow (OAuth cTrader), SettingsCatalogue, SettingField, Converters
|
||||
│ └── MainWindow.xaml(.cs) shell con navigazione laterale, timer 1 s che applica lo snapshot
|
||||
├── tests/Encelado.Tests xunit 2.9 (framework GIÀ presente: si usa quello, niente mini-runner)
|
||||
├── tools/Encelado.Backtest strumento console di ricerca ("backtest <comando>"), unico progetto con TA-Lib
|
||||
└── build/ Release.proj (verifica, pacchetto, rilascio su Gitea), Encelado.iss (Inno Setup)
|
||||
```
|
||||
|
||||
- **Target framework**: `net10.0` (Bot e test `net10.0-windows`), `Nullable` e `TreatWarningsAsErrors` attivi per tutti i progetti via `Directory.Build.props`. SDK installato: 10.0.301.
|
||||
- **Pattern**: nessun contenitore DI; oggetti costruiti a mano nel supervisore; async/await con `ConfigureAwait(false)` nel motore; `Channel<T>` per il log; `Lock` per lo stato condiviso; snapshot immutabili verso la UI; ogni tabella è CSV `;` con colonna finale `motivazione`; log strutturato `timestamp;level;source;subject;event;message;exception;stack`.
|
||||
- **Client broker esistente**: nessun client eToro. Esisteva un adattatore Binance (cancellato, non committato) e un adattatore cTrader (mai collegato al Bot). Il motore `ProbaEngine` usa i tipi cTrader direttamente.
|
||||
- **Storage**: SQLite in `%ProgramData%\Encelado\encelado.db` (barre, dataset, modelli, journal) più CSV nella cartella dei log. Configurazione in `Documenti\Encelado\encelado.json`; credenziali cifrate DPAPI in `%LOCALAPPDATA%\Encelado`.
|
||||
- **UI**: WPF, tema scuro proprio (`Ui/Theme.xaml`: palette, `Card`, `Chip`, `Kpi`, `Label`, `Value`, `Sub`, `Head`, pulsanti `Primary`/`Danger`, `PowerButton`, `ModeBadge`), font tabulare `Cascadia Mono`. Nessuna libreria MVVM: `MainViewModel` implementa `INotifyPropertyChanged` a mano.
|
||||
- **Test**: xunit con test di binding WPF (`UiBindingTests` ascolta la trace source dei binding e fallisce su ogni binding irrisolto), test di configurazione, statistica, ML, rischio.
|
||||
- **Build ed esecuzione**: `dotnet build Encelado.slnx`; verifica completa `dotnet msbuild build/Release.proj -t:Verifica`; l'app legge `Documenti\Encelado\encelado.json` (creato dal JSON di fabbrica al primo avvio); la versione rilasciata viene dal tag git (`build/Release.proj`).
|
||||
|
||||
### 1.2 Stato dell'albero di lavoro trovato il 2026-09-16
|
||||
|
||||
L'albero **non compilava**: la sessione precedente (rework verso cTrader, 2026-09-09) era rimasta a metà e non committata.
|
||||
|
||||
| Problema | Dove |
|
||||
|---|---|
|
||||
| `Encelado.Bot` non referenzia `Encelado.CTrader`, quindi `ProbaEngine`, `BotConfig`, `CredentialResolver`, `LoginWindow`, `AccountState` non risolvono i tipi cTrader | `src/Encelado.Bot/Encelado.Bot.csproj` |
|
||||
| `MainWindow.xaml.cs` referenzia `PositionsPage` (cancellata), `_config.Binance`, `ClosePairAsync`, `EnabledPairs` (era Binance) | `src/Encelado.Bot/MainWindow.xaml.cs` |
|
||||
| `CsvTable.cs` usa `Side` senza `using Encelado.Core.Market` | `src/Encelado.Bot/Diagnostics/CsvTable.cs` |
|
||||
| `TestSnapshots.cs` costruisce lo snapshot dell'era Binance (`PairRow`, `EquityCurve`, `OrderRow`…) | `tests/Encelado.Tests/TestSnapshots.cs` |
|
||||
| `Documenti\Encelado\encelado.json` dell'utente è nel formato Binance (sezioni `binance`, `pairs`) | file dell'utente, non nel repo |
|
||||
|
||||
Decisione presa (vedi `docs/QUESTIONS.md`, D-09): il motore cTrader resta nel repository come modulo selezionabile (`engine.strategy = "proba"`) e viene rimesso in compilazione; il motore nuovo (`"baskets"`) è il predefinito.
|
||||
|
||||
### 1.3 Punti di estensione usati dalla modifica
|
||||
|
||||
| Cosa | Dove si aggancia |
|
||||
|---|---|
|
||||
| Nuova strategia | `BotSupervisor` costruisce il motore in base a `engine.strategy`; il motore espone `IEngine` (`RunAsync`, `Snapshot`, comandi) |
|
||||
| Flusso dati di mercato | il motore basket interroga `IBroker.GetQuotesAsync` a polling (2-5 s) e costruisce le barre M15 in locale; le candele ufficiali servono per il riscaldamento e la riconciliazione |
|
||||
| Esecuzione ordini | `IBroker.OpenAsync/CloseAsync/UpdateStopsAsync`, tre implementazioni (`EtoroBroker`, `PaperBroker`, `BacktestBroker`) |
|
||||
| Log delle operazioni | `Log` (file `;`), più il ledger nuovo (`data/ledger/decisions.jsonl`, `baskets.csv`) |
|
||||
| UI | pagine nuove (`BasketsPage`) selezionate dalla shell in base al motore; `Theme.xaml` riusato |
|
||||
| Configurazione | `encelado.json` (sezioni `engine`, `etoro`, `logging`) + `strategy.json` (parametri e preset dei basket) letti con `JsonDocument`, modificati con `ConfigWriter` |
|
||||
| Test | xunit esistente; nuove suite in `tests/Encelado.Tests/Baskets*.cs` |
|
||||
|
||||
## 2. Architettura della modifica (obiettivo)
|
||||
|
||||
### 2.1 Progetti (stato del 2026-09-16 sera, dopo la rimozione dei motori precedenti — ADR-0004)
|
||||
|
||||
```
|
||||
src/Encelado.Core/Broker/ IBroker, modelli (Instrument, QuoteSnapshot, AccountSnapshot, BrokerPosition, OrderRequest, OrderOutcome), PaperBroker (simulatore sopra un feed reale), RateLimiter
|
||||
src/Encelado.Core/Baskets/ matematica e logica pura, senza I/O:
|
||||
SyntheticCross (derivazione automatica del cross e dei segni), PipMath, BasketMath (rendimenti log, ATR, EWMA vol, ρ_W/ρ_20, z-score, semiperiodo OLS, forza di trend),
|
||||
SymbolSeries (barre + quote + qualità dati), BasketDecider (entrate/uscite/averaging di §5), CostGate, VolParitySizing, BasketExecutor (protocollo leg-risk),
|
||||
BasketPosition (macchina a stati), BasketStrategyConfig (strategy.json, preset), ExecutionMode (Paper | Demo | Live)
|
||||
src/Encelado.Core/Baskets/Data/ BidAskBar + CSV, TickToBars (tick MT5 → M15)
|
||||
src/Encelado.Core/Baskets/Learning/ livelli 0-3: CalibrationTables, OnlineLogistic (SGD+L2, standardizzazione rolling), SmallMlp (16 ReLU, Adam, early stopping, gradient check),
|
||||
ThompsonBandit (Beta per preset × terzile di vol), VolForecast (EWMA vs HAR-RV, PSI), LearningFeatures (28 feature del ledger), ModelEvaluator (walk-forward, fold purgati, bootstrap, attivazione)
|
||||
src/Encelado.Core/Baskets/Backtest/ BasketBacktest (event-driven su barre M15 bid/ask), BacktestBroker, BasketTrials (griglia, PSR/DSR, PBO, walk-forward 6m/1m)
|
||||
src/Encelado.Core/News/ parser puri: CalendarParser (JSON/XML FairEconomy), RssParser (XmlReader), SentimentLexicon, SentimentEngine (finestre 1h/4h/24h con decadimento)
|
||||
src/Encelado.Core/Ml/, Statistics/ la statistica condivisa rimasta: Classification (AUC, Brier, log-loss, calibrazione), Pbo (CSCV), Performance (Sharpe, PSR, DSR, drawdown, momenti), Ols, Normal
|
||||
src/Encelado.Etoro/ EtoroOptions, EtoroHttp (HttpClient, x-api-key/x-user-key/x-request-id, limitatore per classe di quota, 429 con Retry-After, scarto orologio dall'header Date), EtoroBroker : IBroker
|
||||
src/Encelado.Bot/Baskets/ BasketEngine (ciclo di decisione a thread singolo, polling quote, barre locali, esecuzione diretta, equity stop, kill-switch, file STOP, riconciliazione),
|
||||
Ledger (decisions.jsonl append-only, baskets.csv, rotazione mensile, scritture atomiche), Feeds (calendario + RSS con cache su disco, robots.txt, backoff),
|
||||
LearningState (modello in ombra, bandit, ciclo settimanale, knowledge/), HeadlessRunner (--headless)
|
||||
src/Encelado.Bot/Configuration/ BotConfig (etoro, run, ui, logging), ConfigLoader (JsonDocument, avvisi sulle sezioni di versioni precedenti), ConfigDefaults, ConfigWriter, EtoroKeyStore (DPAPI)
|
||||
src/Encelado.Bot/Engine/ IEngine, BotSupervisor (ciclo di vita, snapshot, feed di attività), BotSnapshot
|
||||
src/Encelado.Bot/Ui/ Theme.xaml, MainWindow (barra in alto con le tre schede), Pages/DashboardPage (i cinque numeri, la tabella dei basket, il contesto, l'attività), LogPage, SettingsPage (SettingsCatalogue, fuso orario),
|
||||
EtoroLoginWindow, PromptWindow (CONFERMO LIVE, motivazione del reset), UiClock (fuso orario della finestra), MainViewModel
|
||||
tools/Encelado.Backtest `ticks` (tick MT5 → barre M15 bid/ask), `baskets` (baseline, griglia, trials, PBO, walk-forward), `falsify` (i cinque test di falsificazione); `--costs etoro|api`
|
||||
```
|
||||
|
||||
Progetti rimossi il 2026-09-16 (ADR-0004): `Encelado.CTrader`, `Encelado.Storage`, `Core/Backtest`, `Indicators`, `Journal`, `Market`, `Portfolio`, `Research`, `Risk`, `Rl`, `Strategies`, il grosso di `Ml` e `Statistics`, `ProbaEngine`, le pagine `StatusPage`/`LoginWindow`, `ApprovalQueue`.
|
||||
|
||||
### 2.2 Flusso dati (live)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E[eToro Public API] -->|rates ogni 3 s| Q[Quote poller]
|
||||
Q --> B[Bar builder M15]
|
||||
E -->|candles| B
|
||||
B --> S[Strategy loop<br/>un solo thread]
|
||||
C[Calendario + RSS] --> F[Feature contesto]
|
||||
F --> S
|
||||
M[Meta-modello in ombra<br/>vol forecast] --> S
|
||||
S -->|decisione| X[Executor<br/>leg-risk protocol]
|
||||
X --> E
|
||||
S --> L[(Ledger jsonl/csv)]
|
||||
X --> L
|
||||
S --> U[Snapshot → UI / headless]
|
||||
L --> K[Ciclo settimanale: L0-L3]
|
||||
K --> M
|
||||
```
|
||||
|
||||
Le decisioni avvengono su un solo thread; l'I/O è asincrono; l'unico gate umano per ordine è sparito (ADR-0005): restano avvio del reale, kill-switch, reset e cambio di preset.
|
||||
|
||||
### 2.3 Macchina a stati del basket
|
||||
|
||||
```
|
||||
Idle ──(segnale + cancelli)──► Entering ──(A e B eseguite)──► Open ──(add)──► Adding ──► Open
|
||||
▲ │ (B rifiutata/timeout → chiudi A, leg_risk_unwind, basket disattivato 1 h)
|
||||
│ ▼
|
||||
└────────── Closed ◄──── Exiting ◄──(TP | z_out | stop | time-stop | manuale | forzata)── Open
|
||||
│ (una gamba non chiude dopo 3 tentativi)
|
||||
▼
|
||||
Error (blocco nuove entrate finché non risolto)
|
||||
```
|
||||
|
||||
### 2.4 Interfacce
|
||||
|
||||
- `IBroker`: `Environment`, `GetInstrumentsAsync`, `GetQuotesAsync(ids)`, `GetCandlesAsync(id, interval, count)`, `GetAccountAsync`, `GetPositionsAsync`, `OpenAsync(OrderRequest)`, `LookupOrderAsync`, `CloseAsync(positionId, instrumentId)`, `UpdateStopsAsync(positionId, sl, tp)`, `GetCostAsync(OrderRequest)`, `GetClosedTradesAsync`, `ClockSkew`.
|
||||
- `IContextProvider` (Bot): calendario, notizie e sentiment per basket (`FeedContextProvider`; `EmptyContextProvider` nei test).
|
||||
- `IModel`: `Predict(features)`, `Update(features, label)`, JSON, implementato da `OnlineLogistic` e `SmallMlp`.
|
||||
- `IEngine` (Bot): `RunAsync`, `CloseAllAsync`, `ExecuteAsync(EngineCommand)` con `Close`, `KillSwitch`, `SetPreset`, `ResetEquityStop(motivazione)`, `Snapshot()`.
|
||||
- `IUiActions` (Bot): ciò che le pagine possono chiedere alla finestra (chiudi basket, kill-switch, preset, reset, chiavi, file).
|
||||
|
||||
### 2.5 Vincoli e limiti scoperti in Fase 0
|
||||
|
||||
- L'endpoint candele di eToro accetta solo `count ≤ 1000` senza data di partenza: dà al massimo ~10 giorni di M15. Lo storico per il backtest viene dai tick MT5 forniti dall'utente (`A:\Download\Trading`, 2018-12 → 2026-09, UTC), convertiti in M15 bid/ask dallo strumento `backtest ticks`.
|
||||
- Quote di mercato (`/api/v2/market-data/rates`) in batch fino a 1000 strumenti per chiamata: un polling ogni 3 s costa 20 richieste/min sulla quota condivisa di 120/min.
|
||||
- Quota ordini: 20 richieste/min (demo e reale separate). Un basket costa 2 aperture + 2 chiusure.
|
||||
- Le quote di `rates` sono senza markup; il costo effettivo (markup + spread di mercato + overnight) arriva da `POST /trading/info/{demo/}costs` (20/min dedicate). Il cost gate somma i due.
|
||||
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (asincrono: esito con `orders:lookup` per `referenceId` = `x-request-id`); `sellShort` e leva > 1 richiedono `stopLossRate`. Chiusura: `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}`.
|
||||
- Esposizione minima per posizione: 1000 USD (`minPositionExposure`); leva ammessa 1-30 (majors) e 1-20 (minors). Il conto reale dell'utente vale 193,18 USD: con i limiti di rischio della strategia il reale non è praticabile oggi (vedi QUESTIONS D-05).
|
||||
@@ -0,0 +1,69 @@
|
||||
# Fonti dei dati
|
||||
|
||||
Aggiornato: 2026-09-16. Ogni fonte è stata verificata alla data indicata; se un feed cambia o sparisce, la voce va aggiornata e la decisione annotata in `docs/QUESTIONS.md`.
|
||||
|
||||
## 1. Mercato
|
||||
|
||||
| Fonte | Cosa | Formato | Frequenza | Limiti | Fallback |
|
||||
|---|---|---|---|---|---|
|
||||
| **eToro Public API** — `GET /api/v2/market-data/rates?instrumentIds=…` | bid/ask di tutti gli strumenti in una chiamata | JSON `{results:[{instrumentId,bid,ask,date,quoteType}]}` | polling ogni `run.pollSeconds` (3 s) | quota condivisa 120/min con le altre rotte di market data; il bot ne usa ~20/min | nessuno: senza quote il bot non decide |
|
||||
| **eToro Public API** — `GET /api/v1/market-data/instruments/{id}/history/candles/asc/FifteenMinutes/1000` | ultime 1000 candele M15 (mid, senza spread) | JSON | all'avvio, per il riscaldamento e il delta | **non pagina**: niente data di partenza, al massimo ~10 giorni | le barre locali salvate a ogni chiusura |
|
||||
| **eToro Public API** — `GET /api/v2/market-data/instruments?symbols=…`, `POST /trading/info/{demo/}eligibility` | id, nome, esposizione minima, leve, limiti di stop | JSON | all'avvio, scritti in `instruments.json` | 120/min e 20/min dedicate | valori prudenti incorporati (esposizione minima 1000 USD, leve 1-20) |
|
||||
| **eToro Public API** — `POST /trading/info/{demo/}costs` | markup, spread di mercato, commissione, overnight e weekend per un ordine ipotetico | JSON `{costs:[{costType, currency, value}]}` — il campo è **`value`** (verificato il 2026-09-16: EURUSD 10 000 unità → markup 0, marketSpread 0,1 USD, overnightFee 0,91 USD/giorno) | ogni 15 minuti per strumento | 20/min dedicate | markup 0 e overnight da `strategy.json` |
|
||||
| **Tick MetaTrader 5** — `A:\Download\Trading\<SYMBOL>_<da>_<a>.csv` | tick bid/ask 2018-12-12 → 2026-09-15, **UTC** (verificato sui fine settimana: chiusura venerdì 20:53 estate / 21:57 inverno, riapertura domenica 21:05 / 22:05) | tab-separato `<DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>`; flag 2 = solo bid, 4 = solo ask, 6 = entrambi | una tantum, `backtest ticks` | EURAUD copre solo parte del 2018, del 2021 e del 2026 (39 615 barre contro ~192 000 delle altre): il basket EURAUD/AUDCAD è misurabile solo su quei tratti | — |
|
||||
| Barre M15 derivate — `Documenti\Encelado\data\market\candles_<SYMBOL>_M15.csv` | OHLC bid e ask, spread medio, numero di tick, provenienza | CSV `;` (schema in `docs/LEDGER_SCHEMA.md`) | scritte dallo strumento e aggiornate dal bot a ogni barra chiusa | — | — |
|
||||
|
||||
Qualità (`data/market/data_quality.csv`, generato da `backtest ticks`, e `reports/data_quality.csv` dal bot): buchi > 1 h nei giorni feriali, salti > 2 % fra barre, duplicati. Una barra sospetta sospende le decisioni sul basket coinvolto per quella barra.
|
||||
|
||||
## 2. Calendario economico
|
||||
|
||||
| Fonte | URL | Formato | Aggiornamento | Note |
|
||||
|---|---|---|---|---|
|
||||
| Forex Factory via FairEconomy | `https://nfs.faireconomy.media/ff_calendar_thisweek.json` | JSON `[{title,country,date,impact,forecast,previous,actual}]`, `date` con offset (ora di New York) | il feed cambia più volte al giorno; il bot lo rilegge ogni 10 minuti, mai più di una richiesta al minuto | `country` è già il codice valuta (`USD, EUR, GBP, JPY, AUD, NZD, CAD, CHF, CNY`, `All`); `impact` ∈ {High, Medium, Low, Holiday} |
|
||||
| variante XML | `https://nfs.faireconomy.media/ff_calendar_thisweek.xml` | `<weeklyevents><event>` con `date` MM-DD-YYYY e `time` 8:15am **in UTC** (verificato contro il JSON: "10:30pm" del 09-13 = "18:30-04:00") | idem | usata solo come riserva |
|
||||
|
||||
Archivio: `data/calendar/events.jsonl` (append-only, una riga per evento, dedup per `title+date+country`; un `actual` che arriva dopo la pubblicazione è una riga nuova). Feature derivate per ogni valuta: `minutesToNextHigh`, `minutesSinceLastHigh`, `surpriseLast = (actual − forecast)/|forecast|`.
|
||||
|
||||
Limite: il feed copre **la settimana corrente**. Non esiste uno storico gratuito: il backtest non applica il blackout né le feature di calendario, e lo dice (`docs/STRATEGY.md`).
|
||||
|
||||
## 3. Notizie (RSS)
|
||||
|
||||
Tutte lette con `User-Agent: Encelado/4.0 (+correlation baskets; contact: operator)`, al massimo una richiesta al minuto per fonte, con backoff esponenziale sugli errori e rispetto di `robots.txt` (gruppo `User-agent: *`). Verifica del 2026-09-16:
|
||||
|
||||
| Fonte | URL | Formato | Esito |
|
||||
|---|---|---|---|
|
||||
| FXStreet | `https://www.fxstreet.com/rss/news` | RSS 2.0 | 200 |
|
||||
| ForexLive | `https://www.forexlive.com/feed/` | RSS 2.0 | 200 |
|
||||
| Federal Reserve | `https://www.federalreserve.gov/feeds/press_all.xml` | RSS 2.0 | 200 con lo User-Agent del bot; con uno User-Agent minimale risponde con una pagina HTML "not found" |
|
||||
| BCE | `https://www.ecb.europa.eu/rss/press.html` | RSS 2.0 | 200 |
|
||||
| Bank of England | `https://www.bankofengland.co.uk/rss/news` | RSS 2.0 | 200 |
|
||||
| RBA | `https://www.rba.gov.au/rss/rss-cb-media-releases.xml` | RSS 1.0 (RDF) | 200 alla prima verifica, poi "Access Denied" (Akamai) a richieste successive: tenuta con backoff, coperta anche da Google News `"Reserve Bank of Australia"` |
|
||||
| Bank of Canada | `https://www.bankofcanada.ca/content_type/press-releases/feed/` | RSS 1.0 (RDF) | 200 |
|
||||
| SNB | `https://www.snb.ch/en/rss/press-releases` | — | **404**: omessa (D-08); coperta da Google News `"Swiss National Bank"` |
|
||||
| RBNZ | `https://www.rbnz.govt.nz/rss/news` | — | **403** "website unavailable": omessa (D-08); coperta da Google News `RBNZ` |
|
||||
| Google News | `https://news.google.com/rss/search?q=<query>&hl=en-US&gl=US&ceid=US:en` per `EURUSD`, `"Swiss National Bank"`, `RBNZ`, `forex dollar` | RSS 2.0 | 200 |
|
||||
|
||||
Archivio: `data/news/news_YYYYMM.jsonl` (append-only, una riga per item, dedup per `hash(link)`), con `published, source, title, summary, link, currencies, scores{net, hawkish, riskOff}`.
|
||||
|
||||
Sentiment senza librerie (`Encelado.Core/News/SentimentLexicon.cs`, `SentimentEngine.cs`): lessico incorporato in tre dimensioni (tono positivo/negativo ~180 termini ciascuno, hawkish/dovish ~80, risk-on/risk-off ~50), negazione a finestra di tre parole, attribuzione alle valute per entità (`Fed, Powell, FOMC → USD; ECB, Lagarde → EUR; BoJ → JPY; RBA → AUD; RBNZ → NZD; BoC → CAD; SNB → CHF; BoE → GBP`), parole-paese e nomi di coppia. Per ogni valuta e finestra (1 h, 4 h, 24 h): `netSentiment`, `hawkishScore`, `riskOff` (globale), `newsCount`, con decadimento esponenziale a emivita 2 h. Le feature di un basket sono le differenze fra le sue due valute non comuni.
|
||||
|
||||
Copie dei feed usate dai test: `tests/fixtures/` (scaricate il 2026-09-16).
|
||||
|
||||
## 4. Schema dei file in `Documenti\Encelado`
|
||||
|
||||
```
|
||||
encelado.json, strategy.json, instruments.json
|
||||
data/market/candles_<SYMBOL>_M15.csv timeUtc;bidOpen;bidHigh;bidLow;bidClose;askOpen;askHigh;askLow;askClose;spreadMean;ticks;motivazione
|
||||
data/market/data_quality.csv simbolo;tick_letti;tick_scartati;barre;prima_barra;ultima_barra;buchi_feriali_oltre_1h;barre_spike;spread_mediano_pip;motivazione
|
||||
data/calendar/events.jsonl {title,country,date,impact,forecast,previous,actual}
|
||||
data/news/news_YYYYMM.jsonl {hash,published,source,title,summary,link,currencies[],scores{net,hawkish,riskOff}}
|
||||
data/cache/<fonte>.xml|json ultimo corpo buono di ogni feed
|
||||
data/ledger/decisions.jsonl vedi docs/LEDGER_SCHEMA.md (rotazione mensile in decisions_YYYYMM.jsonl)
|
||||
data/ledger/baskets.csv vedi docs/LEDGER_SCHEMA.md
|
||||
data/state/baskets_state.json posizioni aperte, picco di equity, blocchi (per ripartire dopo un riavvio)
|
||||
data/state/paper_state.json il conto del simulatore (solo Paper)
|
||||
data/models/*.json modelli (livelli 1-3) e stato del bandit
|
||||
knowledge/*.csv, *.md calibrazione, proposte, registri, insight settimanali
|
||||
reports/*.csv qualità dati, falsificazione
|
||||
logs/encelado.log log applicativo (;)
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# Glossario
|
||||
|
||||
| Termine | Significato in Encelado |
|
||||
|---|---|
|
||||
| **Basket** | Due posizioni (una per coppia forex) aperte insieme e chiuse insieme, trattate come una sola scommessa sul cross sintetico. |
|
||||
| **Cross sintetico** | La coppia implicita nelle due gambe: `X = ln A + s·ln B`, con `s = +1` se la valuta comune ha ruoli opposti (EURUSD/USDCHF → EURCHF) e `−1` se uguali. Tutti e cinque i basket della specifica hanno `s = +1`. |
|
||||
| **Gamba** | Una delle due posizioni del basket. |
|
||||
| **z-score** | `(X − media_W) / σ_W` del cross sintetico su una finestra di W barre M15. Ingresso a `|z| ≥ z_in`, uscita a `|z| ≤ z_out` o allo stop `|z| ≥ z_stop`. |
|
||||
| **ρ_W, ρ_20** | Correlazione rolling dei rendimenti delle due gambe su W e su 20 barre. Attesa negativa per i basket della specifica (`rho_min` −0,6). |
|
||||
| **Half-life (HL)** | Semiperiodo di mean reversion del cross, in barre, da un OLS di Δx su x(t−1). Ammesso fra `halfLifeMinBars` e `halfLifeMaxBars`. |
|
||||
| **Preset** | Conservative / Moderate / Aggressive: z_in, rischio per basket, numero massimo di basket, TP in pip, aggiunte, z_stop. |
|
||||
| **TP di basket** | Take-profit in pip, somma dei pip delle due gambe (come nell'interfaccia di riferimento). |
|
||||
| **Cost gate** | Il rifiuto di un ingresso se il TP non copre almeno `costMultiple` volte il costo stimato (spread reale + markup + commissioni + overnight atteso), o se lo spread è più del doppio della mediana delle ultime 24 ore. |
|
||||
| **Break-even** | Il costo in pip oltre il quale il P&L medio lordo di un basket diventa negativo: se è vicino a zero, il segnale non ha contenuto. |
|
||||
| **Vol-parity sizing** | Le unità di ogni gamba sono inversamente proporzionali alla sua volatilità (ATR), così le due gambe contribuiscono allo stesso rischio; il rischio totale è `riskPerBasketPct` dell'equity alla distanza dello stop. |
|
||||
| **Leg-risk** | Il rischio di restare con una sola gamba: se la seconda non viene eseguita entro `legTimeoutSec`, la prima viene chiusa subito (`leg_risk_unwind`). |
|
||||
| **Equity stop** | Chiusura di tutto e blocco a un drawdown del 9 % dal picco; riparte solo con un reset motivato. |
|
||||
| **Kill-switch** | Chiusura immediata di tutto e blocco delle nuove entrate: pulsante, comando o file `STOP`. |
|
||||
| **Paper / Demo / Live** | Simulatore locale / conto demo eToro / conto reale. Il bot opera da solo in tutte e tre (D-20). |
|
||||
| **Ledger** | `decisions.jsonl` (ogni decisione con le sue feature) e `baskets.csv` (ogni basket chiuso). Append-only: le correzioni sono righe nuove. |
|
||||
| **Meta-modello** | La regressione logistica (livello 1) che stima la probabilità che un basket finisca in utile. In ombra finché non supera i cancelli di attivazione. |
|
||||
| **Challenger** | L'MLP (livello 2) valutato accanto al campione. |
|
||||
| **Bandit** | Il campionamento di Thompson (livello 3) che propone il preset per terzile di volatilità. |
|
||||
| **Walk-forward** | Valutazione in cui ogni previsione usa solo dati precedenti; per la griglia del backtest: scegli il migliore dei 6 mesi passati, applicalo al mese successivo. |
|
||||
| **PSR / DSR** | Probabilistic e Deflated Sharpe Ratio: la probabilità che lo Sharpe osservato sia sopra zero, tenendo conto di asimmetria, curtosi, lunghezza e (DSR) del numero di configurazioni provate. |
|
||||
| **PBO** | Probabilità di overfitting del backtest (CSCV, 16 blocchi): quante volte la configurazione migliore in-sample finisce sotto la mediana out-of-sample. |
|
||||
| **Falsificazione** | I test di §9.2: ZScore contro PipDivergence, averaging on/off, con e senza stop, cost gate a 2×/3×/4×, segnale invertito. Servono a rompere il risultato, non a confermarlo. |
|
||||
| **Forward test** | Il periodo in Demo con metrica, soglia e durata scritte prima (`knowledge/preregistrazione.csv`). |
|
||||
| **Blackout** | Niente entrate 45 minuti prima e 30 dopo un evento ad alto impatto sulle valute del basket. |
|
||||
| **PSI** | Population Stability Index: misura la deriva della distribuzione di una feature rispetto all'addestramento. |
|
||||
| **HAR-RV** | Modello eterogeneo autoregressivo della varianza realizzata (medie a 1, 5, 22 giorni). |
|
||||
| **Run id** | Identificatore della sessione del bot, scritto in ogni riga del ledger con l'hash di `strategy.json`. |
|
||||
@@ -0,0 +1,36 @@
|
||||
# Problemi noti e limiti
|
||||
|
||||
Aggiornato: 2026-09-16. Una voce per limite, con lo stato. Quando un limite viene rimosso, la voce si sposta nel `CHANGELOG.md`.
|
||||
|
||||
## Strategia
|
||||
|
||||
- **Il backtest è negativo.** Su 7,75 anni di barre M15 nessuna configurazione della griglia è profittevole al netto dei costi assunti; il break-even è vicino a zero, cioè il segnale non ha contenuto misurabile (`docs/STRATEGY.md`). Il modulo resta uno strumento di forward test in Demo, non un sistema da mettere sul reale.
|
||||
- **EURAUD** ha tick solo per parti del 2018, 2021 e 2026: il basket EURAUD/AUDCAD è misurabile in backtest solo su quei tratti.
|
||||
- Il backtest non ha calendario né notizie: blackout e sentiment sono attivi solo dal vivo. L'effetto del blackout sui risultati non è misurato.
|
||||
- I costi del backtest sono assunzioni (spread tipici pubblicati o spread dei tick, overnight 0,3 o 0,9 pip/gamba/giorno). Il costo vero si misura solo nel ledger del Demo.
|
||||
|
||||
## eToro
|
||||
|
||||
- L'endpoint delle candele non pagina: al massimo ~10 giorni di M15. Lo storico dipende dai tick forniti dall'utente.
|
||||
- L'API demo mostra spread di mercato di 0,1-0,7 pip senza markup e un overnight di 0,91 USD/giorno per 10 000 EURUSD. Se l'esecuzione reale applica uno spread diverso, lo si vedrà dallo slippage scritto nel ledger a ogni ingresso.
|
||||
- Il campo dei costi si chiama `value` (non `amount`, come si era scritto in prima battuta): corretto il 2026-09-16 pomeriggio; le righe del ledger della mattina hanno `markupA/B = 0` e `overnight` nullo per questo motivo.
|
||||
- Il conto reale dell'utente vale 193,18 USD: con l'esposizione minima di 1000 USD per gamba il Live non è praticabile a prescindere dai cancelli.
|
||||
|
||||
## Feed
|
||||
|
||||
- **Google News** vieta `/rss/search` nel `robots.txt`: le cinque query (EURUSD, SNB, RBNZ, RBA, forex) non vengono scaricate e restano vuote. SNB e RBNZ non hanno quindi nessuna fonte; RBA solo il feed ufficiale, che risponde 403 a intermittenza (Akamai). Il sentiment su CHF, NZD e in parte AUD è di fatto zero.
|
||||
- Il feed della Fed risponde 404 a tratti (osservato alle 15:16 UTC+2 del 2026-09-16): la cache copre i buchi.
|
||||
- Il calendario FairEconomy è settimanale: la settimana successiva compare solo da domenica.
|
||||
|
||||
## Bot
|
||||
|
||||
- **Una sola istanza** per cartella di lavoro: non c'è un lock; due bot sullo stesso conto si contendono le posizioni. Documentato nel runbook, non imposto dal codice.
|
||||
- Le posizioni salvate in `baskets_state.json` da una modalità diversa non vengono riprese (si riparte dalla riconciliazione del conto).
|
||||
- Il ciclo settimanale gira solo mentre il bot è acceso la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni).
|
||||
- La finestra e l'headless usano lo stesso log e lo stesso ledger: se si avviano insieme le righe si mescolano.
|
||||
|
||||
## Codice
|
||||
|
||||
- `BasketEngine.cs` è un file unico di ~1900 righe: funziona, ma un intervento vi costa più di quanto dovrebbe. Da spezzare (quote poller, riconciliazione, snapshot) in una sessione dedicata.
|
||||
- I test dell'interfaccia rendono le pagine in memoria (`UiRenderTests`, con `ENCELADO_RENDER_DIR`), non il comportamento della finestra vera (dialoghi, timer).
|
||||
- Il test (l) copre i blocchi nel decisore, non la simulazione completa dell'equity stop nel motore live; quella è coperta dal backtest (`EquityStops` in `BacktestResult`) e dal ledger.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Schema del ledger e delle tabelle
|
||||
|
||||
Regole comuni: UTC ovunque, `CultureInfo.InvariantCulture` per numeri e date, CSV con separatore `;` e ultima colonna `motivazione`, JSONL append-only. **Nessuna riga viene mai modificata**: le correzioni sono righe nuove con `evento = correzione`. Ogni riga porta `run_id` e, dove ha senso, `config_hash` (SHA-256 abbreviato di `strategy.json` canonicalizzato).
|
||||
|
||||
## `data/ledger/decisions.jsonl`
|
||||
|
||||
Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15 (ingresso, skip, aggiunta, posizione, uscita) più le uscite decise su una quotazione intermedia e gli esiti di esecuzione. Le feature sono quelle disponibili **al momento della decisione**: è la regola anti look-ahead, e il dataset di addestramento è questo file, non una ricostruzione.
|
||||
|
||||
| Campo | Tipo | Significato |
|
||||
|---|---|---|
|
||||
| `ts` | ISO 8601 UTC | istante della valutazione |
|
||||
| `run_id` | testo | `yyyyMMdd-HHmmss-xxxxxx` della sessione del bot |
|
||||
| `config_hash` | testo | hash di `strategy.json` in vigore |
|
||||
| `basket` | testo | `A/B`, es. `EURUSD/USDCHF` |
|
||||
| `basket_id` | testo | id del basket aperto (`B<yyyyMMddHHmmss>-<AB>`), vuoto se piatto; collega a `baskets.csv` |
|
||||
| `cross` | testo | cross sintetico (`EURCHF`) |
|
||||
| `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` |
|
||||
| `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` |
|
||||
| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione` |
|
||||
| `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` |
|
||||
| `buy_cross` | bool | verso deciso (compra il cross = compra entrambe le gambe nei cinque basket) |
|
||||
| `z`, `z_in_eff` | numero | z-score del cross e soglia effettiva (scalata dalla vol prevista) |
|
||||
| `D_pips` | numero | divergenza in pip dall'ancora (solo `PipDivergence`) |
|
||||
| `rho_W`, `rho_20` | numero | correlazione rolling dei rendimenti su `window` e `windowShort` |
|
||||
| `halfLife` | numero | emivita OLS in barre (null se λ ≥ 0) |
|
||||
| `atrA`, `atrB` | numero | ATR(14) in pip |
|
||||
| `sigmaX`, `ewmaVolX` | numero | deviazione standard del livello del cross sulla finestra; vol EWMA dei rendimenti del cross |
|
||||
| `sigmaForecast`, `sigmaAverage30d` | numero | vol prevista 1-4 h e media 30 g (null finché il livello 8.5 non è attivo) |
|
||||
| `costPips`, `breakEvenWinRate` | numero | costo stimato in pip-equivalenti di A; win rate di pareggio |
|
||||
| `spreadA`, `spreadB`, `markupA`, `markupB` | numero | spread correnti in pip; markup dell'API in pip |
|
||||
| `hourSin`, `hourCos`, `dow` | numero | ora sul cerchio; giorno della settimana (0 = domenica) |
|
||||
| `minutesToNextHigh`, `minutesSinceLastHigh`, `surpriseLast` | numero/null | calendario per le valute del basket |
|
||||
| `netSentDiff_1h/4h/24h`, `hawkishDiff`, `riskOff`, `newsCount` | numero | sentiment (valuta lunga − valuta corta del cross) |
|
||||
| `regimeTrend` | numero | forza di trend (ADX-like) del cross |
|
||||
| `lastNOutcomes` | numero | media degli ultimi esiti (null finché non c'è storia) |
|
||||
| `p_ML`, `mlActive` | numero, bool | probabilità del meta-modello e se è gate o ombra |
|
||||
| `equity`, `openBaskets` | numero | equity e basket aperti al momento |
|
||||
| `priceA`, `priceB`, `pipsOpen`, `pnlOpenUsd`, `barsHeld` | numero | stato della posizione (se aperta) |
|
||||
| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage` | numero | sizing (solo su `Enter`) |
|
||||
| `reasonCodes` | array | codici: `no_signal`, `rho_low`, `rho_short_low`, `half_life`, `blackout_before`, `blackout_after`, `weekend`, `just_opened`, `session`, `max_baskets`, `same_cross`, `ml_gate`, `cost_gate`, `sizing`, `kill_switch`, `equity_stop`, `daily_loss`, `entries_blocked`, `data_quality`, `warmup`, `not_bar_close`, `enter`, `add`, `hold`, `tp_pips`, `tp_z`, `stop_z`, `stop_max_loss`, `spread_anomaly`, `time_stop`, `rho_break` |
|
||||
| `motivazione` | testo | la frase, in italiano, con i numeri |
|
||||
|
||||
## `data/ledger/baskets.csv`
|
||||
|
||||
Una riga per basket chiuso. `label = 1` se `pnl_net_usd > 0`, altrimenti 0: è l'etichetta dei livelli 1-3.
|
||||
|
||||
```
|
||||
basket_id;run_id;basket;mode;preset;opened_utc;closed_utc;buy_cross;entry_z;exit_z;pnl_gross_usd;pnl_net_usd;pips_gross;cost_pips;cost_usd;slippage_pips;adds;bars_held;exit_reason;equity_at_entry;p_ml_at_entry;label;durata_min;motivazione
|
||||
```
|
||||
|
||||
`pips_gross` è la somma dei pip delle due gambe ai prezzi di esecuzione (la colonna "Pips" della UI), `cost_pips` il costo stimato all'ingresso, `slippage_pips` la differenza fra quotazione vista e prezzo eseguito sommata sulle gambe, `exit_reason` uno dei codici sopra più `manual`, `closed_by_broker`, `leg_closed_by_broker`, `end_of_data`.
|
||||
|
||||
## `results/trials.csv`
|
||||
|
||||
Una riga per configurazione provata nel backtest; N del Sharpe deflazionato = numero di righe.
|
||||
|
||||
```
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
```
|
||||
|
||||
`break_even_cost` = media per basket di (pip eseguiti + costo stimato), cioè i pip "mid-to-mid" catturati: il costo di giro che azzera il risultato. `p1_pnl`, `p5_pnl` = percentili 1 % e 5 % del P&L per basket (la coda che il win rate nasconde).
|
||||
|
||||
## `reports/falsificazione.csv`
|
||||
|
||||
```
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
```
|
||||
|
||||
## `knowledge/calibration.csv`
|
||||
|
||||
Win rate e P&L netto medio per bucket: `dimensione;bucket;n;win_rate;pnl_medio;pnl_totale;motivazione`, con dimensioni `|z|`, `rho_W`, `ora`, `giorno`, `minuti_evento`, `sentiment`, `preset`, `basket`.
|
||||
|
||||
## `knowledge/preregistrazione.csv`
|
||||
|
||||
Una riga per forward test: `data;config_hash;modalita;durata_minima;n_minimo_basket;sharpe_atteso;win_rate_atteso;dd_stop;stop_basket_consecutivi;esito;motivazione`.
|
||||
|
||||
## `knowledge/proposals.csv`
|
||||
|
||||
`data;origine;parametro;valore_attuale;valore_proposto;evidenza;stato;motivazione` — le proposte del ciclo settimanale; `stato` ∈ {proposta, in forward, accettata, respinta}. Nessuna proposta cambia i parametri live da sola.
|
||||
|
||||
## `knowledge/models_registry.csv`, `knowledge/forward_registry.csv`
|
||||
|
||||
`versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione` (stato ∈ shadow, challenger, champion, ritirato) e `data;config_hash;modalita;basket;pnl_net;sharpe;dd;stato;motivazione`.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Apprendimento: livelli 0-3
|
||||
|
||||
Aggiornato: 2026-09-16. Tutto è costruito da zero nel Core (`src/Encelado.Core/Baskets/Learning/`), senza pacchetti: regressione logistica online, un MLP a 16 unità ReLU con Adam, un bandit di Thompson, due previsori di volatilità e un indice di stabilità (PSI). Il codice del Bot che li usa a runtime è `src/Encelado.Bot/Baskets/LearningState.cs`.
|
||||
|
||||
**Regola che governa tutto**: nessun livello cambia un parametro live da solo. Il meta-modello può soltanto *rifiutare* un ingresso quando è attivo; il bandit *propone* un preset e lo applica solo in Paper/Demo; tutto il resto finisce in `knowledge/proposals.csv` e passa dal forward test pre-registrato.
|
||||
|
||||
## Il dataset
|
||||
|
||||
Una riga per basket **aperto**: le 28 feature scritte nel ledger nel momento della decisione (`decisions.jsonl`, evento `ingresso`), unite per `basket_id` all'esito scritto alla chiusura (`baskets.csv`: `label` = 1 se il P&L netto è positivo, `pnl_net`). Le feature non vengono mai ricostruite a posteriori: il dataset è il ledger (`LearningState.BuildDataset`).
|
||||
|
||||
| # | Feature | Origine |
|
||||
|---|---|---|
|
||||
| 0-3 | `z`, `abs_z`, `z_in_eff`, `d_pips` | z-score del cross sintetico, soglia effettiva dopo la scala di volatilità, divergenza in pip |
|
||||
| 4-6 | `rho_w`, `rho_20`, `half_life` | correlazione rolling (finestra W e 20 barre), semiperiodo OLS |
|
||||
| 7-10 | `atr_a`, `atr_b`, `sigma_x`, `ewma_vol_x` | volatilità delle gambe e del cross |
|
||||
| 11 | `trend_strength` | forza di trend (ADX-like) del cross |
|
||||
| 12-15 | `spread_a`, `spread_b`, `cost_pips`, `break_even_win_rate` | costi del momento |
|
||||
| 16-18 | `hour_sin`, `hour_cos`, `day_of_week` | ora UTC ciclica, giorno |
|
||||
| 19-20 | `minutes_to_high`, `minutes_since_high` | calendario (troncati a 24 h) |
|
||||
| 21-23 | `sent_1h`, `sent_4h`, `hawkish_diff` | sentiment (valuta lunga − valuta corta del cross) |
|
||||
| 24 | `risk_off` | sentiment risk-off |
|
||||
| 25 | `vol_ratio` | σ prevista / σ media 30 giorni |
|
||||
| 26 | `last_outcomes` | media degli ultimi 10 esiti |
|
||||
| 27 | `buy_cross` | direzione |
|
||||
|
||||
I nomi sono in `LearningFeatures.Names`; il test `LeakTests` verifica che nessun nome contenga l'esito e che un'etichetta presa dal futuro non sia apprendibile (AUC ≈ 0,5).
|
||||
|
||||
## Livello 0 — Calibrazione
|
||||
|
||||
`CalibrationTables.Build` raggruppa i basket chiusi per basket, preset, terzile di volatilità, ora del giorno, bucket di |z| e di costo, e scrive win rate e P&L medio per bucket in `knowledge/calibration.csv` (colonna `motivazione` con il conteggio). Serve a leggere dove la strategia paga e dove no, e a niente altro: non cambia soglie.
|
||||
|
||||
## Livello 1 — Logistica online (il campione)
|
||||
|
||||
`OnlineLogistic`: pesi su 28 feature standardizzate con statistiche rolling (`RollingStandardizer`, emivita 200 righe), SGD con L2 = 10⁻³ e tasso 0,01/√(1+n/100). Predice a ogni chiusura di barra (`p_ML` nella dashboard, "in ombra") e impara a ogni chiusura di basket. Stato in `data/models/logreg_current.json`; versioni datate `logreg_vN.json` con `trained_on_until` e hash del dataset.
|
||||
|
||||
**Valutazione walk-forward** (`ModelEvaluator.EvaluateLogistic`): sequenziale, predici-poi-aggiorna, con i primi 30 basket di burn-in esclusi dalle metriche. Metriche: AUC con intervallo bootstrap (1000 ricampionamenti), Brier, log-loss, curva di calibrazione in 10 bin, P&L di tutti i basket contro P&L dei soli basket con p ≥ `mlMinProbability`, Sharpe e DSR del filtrato.
|
||||
|
||||
**Attivazione** (§8.3 della specifica), tutte insieme:
|
||||
|
||||
1. almeno **300** basket chiusi;
|
||||
2. AUC walk-forward ≥ **0,55** con l'intervallo bootstrap che esclude 0,50;
|
||||
3. P&L filtrato migliore del P&L non filtrato **e** DSR del filtrato ≥ **0,95**.
|
||||
|
||||
Quando è attivo, un ingresso con p < `mlMinProbability` (0,55) viene rifiutato (`ml_gate` nel ledger). **Disattivazione**: se l'AUC mobile sugli ultimi 100 basket scende sotto **0,52** il modello torna in ombra e lo scrive in `models_registry.csv`.
|
||||
|
||||
Stato del 2026-09-16: **0 basket chiusi nel ledger** → il modello è in ombra e non è valutabile. Nessuna cifra qui è un risultato.
|
||||
|
||||
## Livello 2 — MLP challenger
|
||||
|
||||
`SmallMlp`: 28 → 16 ReLU → 1 sigmoide, inizializzazione Glorot con seme fisso, Adam (β 0,9/0,999), L2 = 10⁻⁴, mini-batch 8-64. Addestrato dal ciclo settimanale in **5 fold cronologici con purga ed embargo di 24 ore** attorno al fold di test, **5 semi** mediati, early stopping sull'ultimo 20 % (cronologico) dei dati di addestramento con pazienza 20 epoche. Lo standardizzatore viene adattato all'intero insieme di addestramento prima del fit (le statistiche rolling partono da zero e distorcono le prime righe: scoperto e corretto con il test sul cerchio, vedi `ModelTests`).
|
||||
|
||||
Il **gradient check** (`SmallMlp.GradientCheck`, test `TheMlpGradientMatchesTheNumericalOne`) confronta il gradiente analitico di ogni peso vivo del primo strato con la differenza centrale numerica: scarto relativo < 10⁻⁴.
|
||||
|
||||
Promozione a campione: solo se batte la logistica di almeno 0,01 di AUC walk-forward **e** supera gli stessi cancelli di attivazione, e comunque solo dopo il forward test. Fino ad allora è registrato come `challenger` in `models_registry.csv`.
|
||||
|
||||
## Livello 3 — Bandit sui preset
|
||||
|
||||
`ThompsonBandit`: una Beta(α, β) per braccio = preset × terzile di volatilità prevista (3 × 3). A ogni chiusura il braccio usato riceve 1 se il basket è positivo. La proposta campiona dalle posteriori con un **tetto del 10 %** alle scelte esplorative (`ExplorationCap`; test `TheBanditKeepsExplorationUnderTheCap`). In Paper e Demo la proposta viene applicata a caldo (i basket aperti non vengono toccati) e scritta nel ledger come correzione; in Live mai.
|
||||
|
||||
## Previsione della volatilità
|
||||
|
||||
`VolForecaster`: sui rendimenti a 15 minuti del cross calcola la varianza realizzata giornaliera e mantiene due previsori a 1-4 ore, **EWMA** (span 100) e **HAR-RV** (OLS sulle medie a 1, 5 e 22 giorni, rifittato ogni giorno). Ogni giorno confronta l'errore quadratico delle due previsioni sulla finestra mobile e usa quello migliore (`ActiveModel`). Il rapporto σ prevista / σ media 30 giorni scala la soglia `z_in` (`volScaleZIn`) e la size, ed è la feature `vol_ratio`.
|
||||
|
||||
## Deriva (PSI)
|
||||
|
||||
`Psi.Compute` confronta la distribuzione di ogni feature nelle ultime 50 decisioni con quella del dataset di addestramento (10 bin). Sopra 0,25 la feature è in deriva; con tre feature in deriva il meta-modello, se attivo, torna in ombra fino al ciclo successivo. Il ciclo settimanale scrive il PSI nel file degli insight.
|
||||
|
||||
## Ciclo settimanale
|
||||
|
||||
`LearningState.RunCycle`, la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni):
|
||||
|
||||
1. ricostruisce il dataset dal ledger;
|
||||
2. valuta e riaddestra logistica (walk-forward) e MLP (fold purgati);
|
||||
3. scrive `knowledge/calibration.csv`, `knowledge/insights_YYYYWW.md` (cosa ha funzionato, calibrazione, meta-modello, bandit, parametri suggeriti), `knowledge/models_registry.csv`, `knowledge/proposals.csv` (una riga per proposta, con evidenza e stato `proposta`);
|
||||
4. salva i modelli con versione.
|
||||
|
||||
**Le proposte non toccano niente.** Il percorso per cambiare un parametro live è: proposta → `knowledge/preregistrazione.csv` (metrica, soglia, periodo, N minimo, scritti prima) → forward test in Paper/Demo → `forward_registry.csv` → decisione umana.
|
||||
|
||||
## Cosa è stato escluso, e perché
|
||||
|
||||
- **LSTM / Transformer / RL profondo**: con qualche centinaio di basket l'anno per cinque coppie, un modello con migliaia di parametri impara il rumore del campione; la regressione logistica e un MLP minuscolo sono già al limite di ciò che il dataset può sostenere. Il costo (settimane di lavoro e di calcolo) non è giustificato da nessun indizio che un modello più ricco troverebbe struttura dove i test di falsificazione non ne trovano.
|
||||
- **Feature ricostruite a posteriori**: il ledger scrive ciò che il bot sapeva; ricostruire feature dopo è il modo più facile di introdurre look-ahead.
|
||||
- **Ottimizzazione automatica dei parametri**: la griglia del backtest serve a *sapere*, non a *scegliere*; ogni prova conta nel DSR.
|
||||
|
||||
## File
|
||||
|
||||
| File | Contenuto |
|
||||
|---|---|
|
||||
| `data/models/logreg_current.json`, `mlp_current.json`, `bandit.json` | stato corrente (ripreso all'avvio) |
|
||||
| `data/models/logreg_vN.json`, `mlp_vN.json` | versioni del ciclo settimanale con `trained_on_until`, righe, hash del dataset, nomi delle feature |
|
||||
| `data/models/learning_state.json` | attivo/ombra, campione, versione, ultimi 200 (p, esito) per l'AUC mobile, feature dei basket aperti |
|
||||
| `knowledge/calibration.csv`, `insights_YYYYWW.md`, `models_registry.csv`, `proposals.csv`, `forward_registry.csv`, `preregistrazione.csv` | vedi `docs/LEDGER_SCHEMA.md` |
|
||||
|
||||
Test: `tests/Encelado.Tests/LearningTests.cs` (i: gradient check, apprendimento walk-forward, MLP contro logistica su una regola non lineare, bandit, volatilità; j: leak; l: blocchi).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Domande e risposte
|
||||
|
||||
Ogni domanda è numerata per fase. Quando l'utente non ha risposto, è stato applicato il default più prudente e la scelta è segnata come **default applicato**: resta aperta finché non arriva una risposta.
|
||||
|
||||
## Fase 0 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-01 | Il bot è già in C#? Quale target framework? | quello del repo | **Risposto dal repo**: C#, `net10.0` (Bot e test `net10.0-windows`), SDK 10.0.301. Nessuna proposta di cambio. |
|
||||
| D-02 | UI attuale: console, WinForms o WPF? Posso aggiungere un progetto WPF? | nuovo progetto WPF + headless | **Risposto dal repo**: è già WPF (`Encelado.Bot`, tema scuro proprio). Non si aggiunge un progetto: si aggiungono pagine alla shell esistente e la modalità `--headless` nello stesso eseguibile. |
|
||||
| D-03 | Valuta del conto eToro e disponibilità di chiavi demo? | USD, demo | **Verificato via API** (collegamento MCP dell'utente, sola lettura): conto in **USD**; `demoCid` e `realCid` esistono. Le chiavi long-lived (`x-api-key` + `x-user-key`) non sono ancora state fornite al bot: la finestra di accesso le chiede e le salva cifrate (DPAPI). **Default applicato: USD, demo.** |
|
||||
| D-04 | Regola di approvazione: automatismo consentito già in demo? | `DemoApprove` | **Default applicato: `DemoApprove`**. `DemoAuto` richiede `etoro.allowDemoAuto = true` in `encelado.json` e la conferma all'avvio (finestra, o `--confirm-demo-auto` in headless). Le modalità Live richiedono `etoro.allowLive = true` e la frase `CONFERMO LIVE`. L'utente ha chiesto una lunga sessione di test "sperando di piazzare trade": senza risposta il test lungo gira in `Paper` (simulatore locale sopra le quote reali) o in `DemoApprove` con approvazione manuale. |
|
||||
| D-05 | Gli 8 strumenti sono disponibili sul conto? Spread tipici? | verifica via API | **Verificato via API il 2026-09-16 07:23 UTC**: tutti e 8 disponibili (id: EURUSD 1, USDCHF 6, AUDUSD 7, USDCAD 4, NZDUSD 3, EURNZD 49, EURAUD 12, AUDCAD 47; anche EURCHF 9 ed EURCAD 13 per `PreferDirectCross`). Spread di mercato osservati senza markup: 0,1 pip sulle majors, 0,3-0,7 pip sui cross. Il markup di eToro si legge dall'endpoint dei costi e viene sommato nel cost gate. Esposizione minima 1000 USD per posizione, leva fino a 30 (majors) / 20 (minors). **Attenzione**: il conto reale vale 193,18 USD; con `RiskPerBasket` 0,5 % e esposizione minima 1000 USD il reale non è operabile senza leva alta: il passaggio a `LiveApprove` resta comunque subordinato ai cancelli di §9.4. |
|
||||
| D-06 | Dove gira il bot (PC locale Windows, VPS Windows)? | PC locale + headless pronto per VPS | **Default applicato**: Windows 11 locale (questa macchina); `--headless` disponibile per un VPS Windows. |
|
||||
| D-07 | Esiste già uno storage/log da riusare? | nuovi file in `data/` | **Default applicato con una precisazione**: il log applicativo (`Log`, file `;`) e `CsvTable` vengono riusati; il ledger, i dati di mercato, i modelli e la base di conoscenza vanno in file (`data/`, `knowledge/`, `reports/`, `results/`) sotto `Documenti\Encelado\`, come richiesto. Il database SQLite esistente resta per il motore `proba` e non viene usato dal modulo basket. |
|
||||
| D-08 | Se una fonte news/calendario risulta irraggiungibile: sostituire o omettere? | omettere e annotare | **Verificato il 2026-09-16**: calendario FairEconomy (JSON e XML), FXStreet, ForexLive, Fed, ECB (`https://www.ecb.europa.eu/rss/press.html`), BoE (`https://www.bankofengland.co.uk/rss/news`), RBA (`https://www.rba.gov.au/rss/rss-cb-media-releases.xml`), BoC (`https://www.bankofcanada.ca/content_type/press-releases/feed/`) e Google News rispondono 200. **SNB** (`/en/rss/press-releases` → 404) e **RBNZ** (403 "website unavailable") no: **default applicato: omesse**, coperte da Google News con query mirate (`SNB`, `RBNZ`). Annotato in `docs/DATA_SOURCES.md`. |
|
||||
| D-09 | Che fare del motore cTrader/ProbaBot trovato a metà e non committato? | mantenerlo selezionabile | **Default applicato**: resta nel repo, rimesso in compilazione (riferimento di progetto e piccoli fix) e selezionabile con `engine.strategy = "proba"`; il predefinito diventa `"baskets"`. Nessun comportamento esistente viene cambiato. Se l'utente preferisce eliminarlo, basta rimuovere `src/Encelado.CTrader` e `Engine/ProbaEngine.cs`. |
|
||||
| D-10 | Dove stanno i parametri della strategia: in `encelado.json` o in un file separato? | `config/strategy.json` come da specifica | **Default applicato**: `strategy.json` separato (copia di fabbrica in `config/`, copia dell'utente in `Documenti\Encelado\`), letto con `JsonDocument`; `instruments.json` scritto dal bot all'avvio nella stessa cartella. `encelado.json` riceve solo le sezioni `etoro` e `engine.strategy`. |
|
||||
| D-11 | Fuso orario dei tick MT5 in `A:\Download\Trading`? | verificare sul fine settimana | **Verificato**: la chiusura del venerdì cade alle 20:53-20:57 in estate e alle 21:53-21:57 in inverno, la riapertura alle 21:05 (estate) / 22:05 (inverno) della domenica: è **UTC**. Nessuna conversione. Formato: tab-separato `<DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>`; le righe con solo bid o solo ask (flag 2/4) aggiornano un solo lato. |
|
||||
| D-12 | Lo storico M15 via API eToro si può scaricare paginando? | sì, 1000 barre per richiesta | **Verificato: no.** L'endpoint delle candele accetta solo `count ≤ 1000` e la direzione, senza data di partenza: fornisce al massimo ~10 giorni di M15. Il backtest usa i tick forniti dall'utente; l'API serve per riscaldamento (ultime 1000 barre) e riconciliazione. |
|
||||
| D-13 | Il TP di basket "in pip" con lotti diversi fra le gambe: pip lordi sommati come Titany, o P&L netto? | come da specifica | **Default applicato**: `Pips` di basket = somma dei pip delle due gambe (UI e `ExitMode = FixedPips`); ogni decisione di stop usa il P&L netto in USD; entrambi finiscono nel ledger. |
|
||||
| D-14 | Le credenziali eToro per il bot: quando? | attendere | L'utente ha scritto: «Aspetta l'input per le credenziali per la prima volta e poi potrai aprirlo in autonomia quando memorizzerò la password». La finestra di accesso chiede `x-api-key` e `x-user-key` e li salva in `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI). Finché non ci sono, il bot in headless resta in sola lettura e lo dice. |
|
||||
|
||||
## Fase 1 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-15 | Leva da usare su ogni gamba (l'API la richiede per ordine)? | 10 | **Default applicato**: `orderLeverage = 10` (ammessa su tutte le 8 coppie); l'esposizione complessiva resta comunque ≤ 10:1 sul nozionale (`MaxEffectiveLeverage`) e lo stop nativo di eToro viene messo alla distanza coerente con `MaxLossPerBasket%`, dentro i limiti di eligibility. |
|
||||
| D-16 | Overnight: usare il valore dell'endpoint dei costi o una tabella? | endpoint | **Default applicato**: l'endpoint dei costi (`overnightFee`, `overWeekendFee`) quando disponibile; in backtest una tabella configurabile per coppia (`overnightPipsPerDay`, default 0,3 pip/gamba/giorno, ×3 nel fine settimana). |
|
||||
|
||||
## Fasi 2-7 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-17 | Lo spread anomalo (> 3 × mediana) deve chiudere il basket alla prima barra o dopo una persistenza? | persistenza | **Default applicato**: chiusura forzata solo dopo **3 barre chiuse consecutive** sopra la soglia. Nel backtest la chiusura immediata scattava sui picchi di spread e perdeva sistematicamente (`BasketPosition.BarsWithSpreadAnomaly`). |
|
||||
| D-18 | Nel backtest l'equity stop blocca tutto per sempre o riparte? | riparte | **Default applicato**: dopo lo stop il picco riparte dall'equity corrente e il numero di stop viene contato (`EquityStops` nel riepilogo); altrimenti il primo stop del 2019 avrebbe fermato sette anni di prova. Dal vivo lo stop richiede il reset manuale. |
|
||||
| D-19 | Fed risponde 404 e RBA "Access Denied" con lo User-Agent minimale: cambiare UA? | UA esplicito del bot | **Applicato**: `Encelado/4.0 (+correlation baskets; contact: operator)`. La Fed risponde; RBA (Akamai) a intermittenza. Dopo due errori consecutivi il feed logga solo a debug e ritenta con attese crescenti. |
|
||||
| D-20 | Per il test lungo in demo: `DemoAuto` (bot autonomo) o `DemoApprove`? | DemoAuto | **Risposta dell'utente (2026-09-16 15:00)**: «tutti gli Approve devono sparire, almeno per il momento. Il bot deve girare in completa autonomia aprendo e chiudendo le posizioni senza il mio consenso». Modalità ridotte a `Paper`, `Demo`, `Live`; coda delle approvazioni rimossa (ADR-0005). Il Live conserva flag e frase `CONFERMO LIVE`. |
|
||||
| D-21 | La pulizia delle «vecchie gestioni» deve includere anche cTrader/proba e la pipeline di ricerca? | sì, tutto | **Risposta dell'utente**: «Tutto: resta solo eToro + basket». Rimossi `Encelado.CTrader`, `Encelado.Storage`, ricerca, indicatori, RL, TA-Lib e i test relativi (ADR-0004). |
|
||||
| D-22 | Versione del rilascio su Gitea? | 4.0.0 | **Risposta dell'utente**: 4.0.0 (nuovo broker, nuova strategia, configurazione incompatibile). |
|
||||
| D-23 | Fuso orario della finestra: quello del computer o selezionabile? | computer, selezionabile | **Applicato**: `ui.timeZone` = `computer` di fabbrica; elenco dei fusi di Windows in Impostazioni; `ENCELADO_TIME_ZONE` da ambiente. Solo la finestra cambia: il log porta l'offset, il ledger è UTC. |
|
||||
| D-24 | L'endpoint dei costi restituiva markup e overnight a zero: era davvero zero? | verificare | **Verificato via API il 2026-09-16 12:45 UTC**: il campo si chiama `value`, non `amount`. EURUSD 10 000 unità leva 10: markup 0,0, spread di mercato 0,1 USD (0,1 pip), overnight 0,91 USD/giorno (≈ 0,9 pip/gamba/giorno). Parser corretto; aggiunto lo scenario di costi `api` al backtest. |
|
||||
| D-25 | Google News vieta `/rss/search` nel robots.txt: forzare, sostituire o omettere? | omettere | **Default applicato: omettere** (il bot rispetta il robots.txt). SNB e RBNZ restano senza fonte; documentato in `KNOWN_ISSUES.md`. |
|
||||
@@ -0,0 +1,36 @@
|
||||
# Regole di sicurezza e approvazione
|
||||
|
||||
Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e chi può cambiarlo. "Operatore" è chi modifica i file in `Documenti\Encelado` o usa la finestra; "codice" vuol dire che non esiste una chiave di configurazione.
|
||||
|
||||
| Regola | Default | Dove | Chi la cambia |
|
||||
|---|---|---|---|
|
||||
| Modalità di esecuzione | `Demo` | `encelado.json` → `run.executionMode` (`Paper`, `Demo`, `Live`) | operatore; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` scritta all'avvio (o `--confirm-live "CONFERMO LIVE"` in headless) |
|
||||
| Approvazione dei singoli ordini | nessuna, in nessuna modalità (D-20, ADR-0005) | codice | nessuno. Il bot apre, aggiunge e chiude da solo; i gate umani sono l'avvio del reale, il kill-switch, il reset dopo un equity stop e il cambio di preset |
|
||||
| Equity stop | 9 % dal picco di equity | `strategy.json` → `equityStopPct` | operatore; scatta → chiude tutto, blocca, richiede reset con motivazione scritta (finestra o `reset <motivo>` in headless), che finisce nel ledger; il picco riparte dall'equity del reset |
|
||||
| Perdita giornaliera massima | 3 % dell'equity di inizio giornata (UTC) | `strategy.json` → `dailyLossPct` | operatore; blocca le nuove entrate fino al giorno dopo, non chiude |
|
||||
| Rischio per basket | 0,25 / 0,50 / 1,00 % (preset) | `strategy.json` → preset o `riskPerBasketPct` | operatore; il cambio di preset a caldo non tocca i basket aperti |
|
||||
| Perdita massima per basket | 1,5 % dell'equity all'ingresso | `strategy.json` → `maxLossPerBasketPct` | operatore; mai disattivabile |
|
||||
| Stop di basket su z | 3,0 / 3,5 / 4,0 (preset) | `strategy.json` → preset o `zStop` | operatore; mai disattivabile (solo il backtest lo spegne, nel test di falsificazione 3) |
|
||||
| Basket aperti | 2 / 3 / 5 (preset) | `strategy.json` → preset o `maxBaskets` | operatore |
|
||||
| Un solo basket per cross sintetico | `Exclusive` | `strategy.json` → `sameCrossPolicy` | operatore (`Half` dimezza la size di entrambi) |
|
||||
| Leva effettiva massima | 10:1 sul nozionale complessivo | `strategy.json` → `maxEffectiveLeverage` | operatore, tetto 30 |
|
||||
| Leva dichiarata per gamba | 10 | `strategy.json` → `orderLeverage` | operatore; la leva effettiva resta governata dal sizing |
|
||||
| Stop nativo su ogni gamba | sì, sempre (eToro lo richiede su short e leva > 1) | codice (`BasketExecutor.Request`) | nessuno; la distanza deriva da `maxLossPerBasketPct` entro i limiti di eligibility |
|
||||
| Cost gate | TP ≥ 3 × costo; spread ≤ 2 × mediana 24 h | `strategy.json` → `costMultiple`, `spreadMedianMultiple` | operatore |
|
||||
| Spread anomalo | > 3 × mediana per 3 barre chiuse consecutive → chiusura forzata | `strategy.json` → `spreadAnomalyMultiple` (persistenza: codice) | operatore (moltiplicatore) |
|
||||
| Blackout eventi | 45 min prima, 30 dopo, eventi High sulle valute del basket | `strategy.json` → `blackoutBeforeMin`, `blackoutAfterMin` | operatore |
|
||||
| Fine settimana | niente entrate dal venerdì 20:00 UTC alla riapertura, né nei primi 30 min | `strategy.json` → `fridayCutoffUtcHour`, `openDelayMinutes` | operatore |
|
||||
| Scarto orologio | > 5 s → banner e niente nuove entrate | `strategy.json` → `clockSkewMaxSeconds` | operatore; misurato sull'header `Date` di ogni risposta |
|
||||
| API in errore | 5 letture consecutive fallite → niente nuove entrate finché non risponde | codice | nessuno |
|
||||
| Quotazione vecchia | > 15 s → niente nuove entrate | codice (`BasketEngine.MaxQuoteAgeSeconds`) | nessuno |
|
||||
| Qualità dati | buco > 2 h feriale o salto > 8 σ → decisioni sospese su quella barra | codice | nessuno |
|
||||
| Leg-risk | seconda gamba non eseguita entro `legTimeoutSec` (5 s) → chiudi subito la prima, basket in pausa 1 h | `strategy.json` → `legTimeoutSec` (pausa: codice) | operatore (timeout) |
|
||||
| Gamba orfana | una gamba sparisce dal conto → l'altra viene chiusa alla riconciliazione successiva | codice | nessuno |
|
||||
| Chiusura incompleta | una gamba non chiude dopo 3 tentativi → stato `Error`, entrate bloccate, allarme | codice | nessuno; si risolve a mano sul conto e con la riconciliazione |
|
||||
| Kill-switch | pulsante con conferma; file `STOP` in `Documenti\Encelado` (controllato ogni 5 s) | codice | operatore; il reset richiede di rimuovere il file e una motivazione |
|
||||
| Posizioni sconosciute sul conto | segnalate una volta nel log, **mai toccate** | codice | nessuno |
|
||||
| Chiavi API | solo `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o `ETORO_API_KEY`/`ETORO_USER_KEY`; mai nel repo (`.gitignore`: `*.local.json`, `.env`) | codice | operatore |
|
||||
| Ambiente visibile | badge `PAPER/DEMO/LIVE` nella barra, nel log e nel ledger (`mode`) | codice | nessuno |
|
||||
| Controlli all'avvio | chiavi (profilo), orologio, strumenti e limiti, conto, riconciliazione, calendario | codice | nessuno; se falliscono il bot resta in sola lettura o non parte |
|
||||
| Averaging | `Off` in live; `AddOnce` ammesso in paper; moltiplicatore di lotto 1,0 | `strategy.json` → `averagingMode`, `lotMultiplier` (max 1,5, solo backtest) | operatore |
|
||||
| Parametri cambiati dal bot | mai. Le proposte vanno in `knowledge/proposals.csv` e passano dal forward test | codice | operatore |
|
||||
@@ -0,0 +1,92 @@
|
||||
# Runbook
|
||||
|
||||
Aggiornato: 2026-09-16. Come si avvia, si ferma, si sblocca e si ripara il bot. I file dell'operatore stanno in `Documenti\Encelado\`; le chiavi in `%LOCALAPPDATA%\Encelado\etoro.dat`.
|
||||
|
||||
## Prima volta
|
||||
|
||||
1. Avvia `Encelado.exe`. Vengono creati `Documenti\Encelado\encelado.json` (configurazione) e `strategy.json` (strategia) dalle copie di fabbrica.
|
||||
2. La finestra chiede le due chiavi di eToro Public API (`x-api-key` e `x-user-key`, dal portale sviluppatori; demo e reale hanno chiavi diverse). Le verifica con due letture (profilo e conto) e le salva cifrate con DPAPI. Da quel momento il bot parte da solo, anche in `--headless`.
|
||||
3. Controlla in **Impostazioni**: ambiente `demo`, modalità `Demo`, fuso orario.
|
||||
4. Premi **AVVIA**.
|
||||
|
||||
In alternativa alle chiavi salvate: variabili d'ambiente `ETORO_API_KEY` e `ETORO_USER_KEY` (hanno la precedenza), utili su un VPS.
|
||||
|
||||
## Modalità
|
||||
|
||||
| Modalità | Ordini | Conferma all'avvio |
|
||||
|---|---|---|
|
||||
| `Paper` | simulatore locale sopra le quotazioni reali (`data/state/paper_state.json`) | nessuna |
|
||||
| `Demo` (default) | conto demo eToro, denaro virtuale | nessuna |
|
||||
| `Live` | conto reale | `run.allowLive = true` **e** la frase `CONFERMO LIVE` (finestra) o `--confirm-live "CONFERMO LIVE"` (headless) |
|
||||
|
||||
In ogni modalità il bot apre e chiude da solo (decisione D-20). Il badge in alto a destra dice sempre in che ambiente sei.
|
||||
|
||||
## Headless (VPS, test lunghi)
|
||||
|
||||
```powershell
|
||||
Encelado.exe --headless [--minutes 240] [--confirm-live "CONFERMO LIVE"]
|
||||
```
|
||||
|
||||
Log sulla console e nel file; una riga di stato ogni `run.statusSeconds`. Comandi da tastiera: `status`, `close <basket>`, `kill`, `preset <nome>`, `reset <motivazione>`, `stop`. Variabile `ENCELADO_EXECUTION_MODE` per forzare la modalità senza toccare il file.
|
||||
|
||||
**Una sola istanza per cartella di lavoro**: due bot sullo stesso conto e sullo stesso ledger si contendono le posizioni. Prima di aprire la finestra mentre gira l'headless, fermalo.
|
||||
|
||||
## Fermare
|
||||
|
||||
- **FERMA** nella finestra, `stop` in headless, Ctrl+C. I basket aperti **restano sul conto** con gli stop nativi (`run.closeOnShutdown = false`): nessuno applica TP e stop di basket finché il bot non riparte, che li riprende dallo stato salvato e dalla riconciliazione.
|
||||
- Con `run.closeOnShutdown = true` la fermata chiude tutto a mercato.
|
||||
|
||||
## Kill-switch
|
||||
|
||||
Chiude tutte le gambe a mercato e blocca le nuove entrate. Tre modi: il pulsante **KILL-SWITCH** nella dashboard (chiede conferma), `kill` in headless, oppure un file chiamato `STOP` nella cartella `Documenti\Encelado` (controllato a ogni ciclo; utile da remoto). Il blocco resta finché non fai un **reset**.
|
||||
|
||||
## Equity stop e reset
|
||||
|
||||
Quando l'equity scende del 9 % dal picco (`equityStopPct`) il bot chiude tutto e si blocca: banner rosso nella dashboard, riga `equity_stop` nel ledger. Per ripartire: **Sblocca…** nel banner, oppure `reset <motivazione>` in headless. La motivazione (almeno dieci caratteri) finisce nel ledger come riga `correzione`; il picco riparte dall'equity corrente. Non si sblocca senza scrivere perché.
|
||||
|
||||
La perdita giornaliera del 3 % (`dailyLossPct`) blocca solo le nuove entrate fino alla mezzanotte UTC e non richiede reset.
|
||||
|
||||
## Riconciliazione
|
||||
|
||||
Ogni 20 secondi il bot rilegge conto e posizioni. Una gamba sparita dal conto (chiusa a mano, stop nativo) fa chiudere l'altra; una posizione sconosciuta viene segnalata e ignorata; una chiusura incompleta dopo tre tentativi mette il basket in stato `Error` e blocca le nuove entrate (banner giallo) finché non è risolta sul conto: chiudi la gamba a mano su eToro, la riconciliazione successiva la vede e sblocca.
|
||||
|
||||
## Errori API
|
||||
|
||||
| Sintomo | Cosa fa il bot | Cosa fare |
|
||||
|---|---|---|
|
||||
| 401/403 | avvio fallito, "eToro ha rifiutato le chiavi" | rigenera le chiavi sul portale, reinseriscile da Impostazioni |
|
||||
| 429 | rispetta `Retry-After`, rallenta | niente; se persiste alza `run.pollSeconds` |
|
||||
| 5 letture consecutive fallite | banner "entrate bloccate", uscite attive | aspetta; controlla rete e stato di eToro |
|
||||
| scarto orologio > 5 s | banner, entrate bloccate | sincronizza l'ora di Windows |
|
||||
| quotazioni più vecchie di 15 s | niente nuove entrate | come sopra |
|
||||
|
||||
## Feed
|
||||
|
||||
Calendario e notizie sono in cache su disco (`data/cache`) e vengono riletti ogni 10 minuti con attese crescenti dopo un errore. Un feed che non risponde non ferma il bot: senza calendario non c'è blackout, senza notizie il sentiment è 0, e il log lo dice. Google News blocca via `robots.txt` le ricerche RSS: quelle fonti vivono solo di cache (vedi `docs/DATA_SOURCES.md`).
|
||||
|
||||
## File utili
|
||||
|
||||
| Cosa | Dove |
|
||||
|---|---|
|
||||
| log | `Documenti\Encelado\logs\encelado.log` (CSV `;`) |
|
||||
| ledger | `data\ledger\decisions.jsonl`, `data\ledger\baskets.csv` |
|
||||
| stato | `data\state\baskets_state.json` (ripreso all'avvio) |
|
||||
| barre | `data\market\candles_<SYMBOL>_M15.csv` |
|
||||
| modelli | `data\models\` |
|
||||
| conoscenza | `knowledge\` |
|
||||
| strumenti | `instruments.json` accanto alla configurazione |
|
||||
|
||||
## Checklist prima del Live (§9.4 della specifica)
|
||||
|
||||
Tutte vere, altrimenti no:
|
||||
|
||||
- [ ] il forward test in Demo ha almeno 60 basket chiusi e 90 giorni;
|
||||
- [ ] il P&L netto del forward test è positivo con PSR ≥ 0,95 sulla metrica pre-registrata in `knowledge/preregistrazione.csv`;
|
||||
- [ ] nessun test di falsificazione contraddice il risultato (oggi `reports/falsificazione.csv` dice il contrario: vedi `docs/STRATEGY.md`);
|
||||
- [ ] `run.allowLive = true`, ambiente `real`, chiavi del reale inserite e verificate;
|
||||
- [ ] conto reale capiente rispetto a `riskPerBasketPct` e all'esposizione minima di 1000 USD per gamba (con 193 USD non lo è);
|
||||
- [ ] la frase `CONFERMO LIVE` scritta all'avvio.
|
||||
|
||||
## Aggiornare
|
||||
|
||||
L'installatore conserva `Documenti\Encelado` e le chiavi. Se dopo un aggiornamento il log segnala "chiavi di una versione precedente", da Impostazioni → **Ripristina i valori predefiniti** (backup automatico con la data accanto al file).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Stato del lavoro
|
||||
|
||||
Aggiornato: 2026-09-16 (fine della seconda sessione, rilascio 4.0.0).
|
||||
|
||||
## Fase in corso
|
||||
|
||||
**Forward test in Demo.** Il codice copre le fasi 0-7 della specifica; la strategia è in esercizio autonomo sul conto demo di eToro per accumulare basket nel ledger. Il backtest è negativo (`docs/STRATEGY.md`): il Demo misura, non guadagna.
|
||||
|
||||
## Fatto nell'ultima sessione (2026-09-16, pomeriggio)
|
||||
|
||||
- **Rework completo del codice**: rimossi Binance, Alpaca, cTrader/proba, SQLite, GBDT, RL, TA-Lib, indicatori e backtest a coppie (ADR-0004). Restano Core (basket, broker, notizie, statistica), Etoro, Bot, strumento di ricerca. Nessun pacchetto NuGet nell'applicazione. Test da 322 a 172, tutti verdi.
|
||||
- **Niente approvazioni manuali** (decisione dell'utente, D-20, ADR-0005): modalità `Paper` / `Demo` (default) / `Live`; coda delle approvazioni rimossa; il Live conserva `run.allowLive` e la frase `CONFERMO LIVE`.
|
||||
- **Interfaccia rifatta**: barra in alto con tre schede (Dashboard, Log, Impostazioni), stato, ambiente, ora nel fuso scelto, AVVIA; dashboard con i cinque numeri, la tabella dei basket, tre riquadri di contesto e l'attività. Tema nuovo. Test di rendering in PNG (`UiRenderTests`).
|
||||
- **Fuso orario** selezionabile (`ui.timeZone`, default `computer`, elenco dei fusi di Windows in Impostazioni, `ENCELADO_TIME_ZONE`).
|
||||
- **Bug corretto**: l'endpoint dei costi di eToro usa il campo `value`; markup e overnight risultavano 0 (D-24). Overnight osservato 0,9 pip/gamba/giorno.
|
||||
- **Apprendimento collegato al motore**: `LearningState` (logistica in ombra, MLP challenger, bandit, ciclo settimanale, `knowledge/`), previsione di volatilità per basket, feature dal ledger. Standardizzatore adattato all'insieme di addestramento prima del fit dell'MLP (difetto trovato dal test sul cerchio).
|
||||
- **Backtest completato**: test di falsificazione 5 (segnale invertito) e scenario di costi `api`; `docs/STRATEGY.md` con i numeri e il verdetto negativo.
|
||||
- Documenti: `STRATEGY.md`, `ML_AND_LEARNING.md`, `RUNBOOK.md`, `GLOSSARY.md`, `KNOWN_ISSUES.md`, ADR-0004, ADR-0005; aggiornati `ARCHITECTURE.md`, `RISK_RULES.md`, `QUESTIONS.md` (D-17…D-25), `DATA_SOURCES.md`, `LEDGER_SCHEMA.md`, `CLAUDE.md`, catena di rilascio.
|
||||
- Sessione di test autonoma in Demo avviata alle 12:56 UTC (4 ore, `--headless`): connessione stabile, nessun ingresso (ρ_W fra −0,13 e −0,42 contro la soglia −0,6; z massimo 1,91 contro 2,0).
|
||||
|
||||
## Prossimi passi
|
||||
|
||||
1. Lasciare girare il Demo per settimane; leggere `data/ledger/baskets.csv` e `knowledge/insights_*.md` prima di toccare qualsiasi parametro.
|
||||
2. Se il ledger mostra che ρ_W ≤ −0,6 non si verifica mai, proporre in `proposals.csv` una soglia diversa **con** una pre-registrazione, non cambiarla a mano.
|
||||
3. Spezzare `BasketEngine.cs` (~1900 righe) in quote poller, riconciliazione, snapshot.
|
||||
4. Aggiungere un lock di istanza (un solo bot per cartella di lavoro).
|
||||
5. Valutare una fonte per SNB e RBNZ che non sia Google News.
|
||||
|
||||
## Problemi aperti
|
||||
|
||||
- Backtest negativo: la strategia non regge i costi (`docs/STRATEGY.md`, `docs/KNOWN_ISSUES.md`).
|
||||
- Il conto reale vale 193,18 USD: il Live non è praticabile a prescindere.
|
||||
- Google News blocca le ricerche RSS via robots.txt; RBA risponde 403 a intermittenza; Fed 404 a tratti.
|
||||
- Il file di configurazione dell'utente porta ancora `allowDemoAuto` (avviso all'avvio; il ripristino dei valori di fabbrica lo toglie).
|
||||
@@ -0,0 +1,130 @@
|
||||
# Strategia: Correlation Baskets
|
||||
|
||||
Aggiornato: 2026-09-16. Questo documento dice come funziona la strategia e, con i numeri, **se regge**. La risposta sui dati disponibili è **no**: nessuna configurazione è profittevole al netto dei costi di eToro. Il modulo resta uno strumento di forward test in Demo; non c'è nessun risultato che giustifichi il reale.
|
||||
|
||||
## 1. Logica
|
||||
|
||||
Cinque basket di due coppie forex con una valuta in comune:
|
||||
|
||||
| Basket | Comune | Cross sintetico | Gambe |
|
||||
|---|---|---|---|
|
||||
| EURUSD / USDCHF | USD | EURCHF | stesso verso |
|
||||
| AUDUSD / USDCAD | USD | AUDCAD | stesso verso |
|
||||
| NZDUSD / EURNZD | NZD | EURUSD | stesso verso |
|
||||
| USDCAD / EURUSD | USD | EURCAD | stesso verso |
|
||||
| EURAUD / AUDCAD | AUD | EURCAD | stesso verso |
|
||||
|
||||
In tutti e cinque la valuta comune ha ruoli opposti nelle due coppie, quindi `X = ln A + ln B` è il logaritmo del cross e le due gambe si comprano (o si vendono) insieme; la correlazione attesa dei rendimenti è negativa.
|
||||
|
||||
**Segnale** (`ZScoreSynthetic`): `z = (X − media_W) / σ_W` su W = 100 barre M15. Ingresso quando `|z| ≥ z_in` (2,0 nel preset Moderate), venduto il cross se z > 0, comprato se z < 0. Modalità alternativa `PipDivergence`: divergenza in pip fra le due gambe dall'ultimo punto di allineamento.
|
||||
|
||||
**Cancelli all'ingresso** (§5.3): correlazione rolling `ρ_W ≤ −0,6`, semiperiodo fra 5 e 120 barre, forza di trend sotto soglia, blackout del calendario, fine settimana, cost gate (`TP ≥ 3 × costo`, spread ≤ 2 × mediana 24 h), massimo di basket aperti, un solo basket per cross sintetico, quote fresche, orologio allineato, nessun blocco attivo.
|
||||
|
||||
**Sizing** (vol-parity): unità inversamente proporzionali all'ATR di ogni gamba, rischio totale alla distanza dello stop = `riskPerBasketPct` dell'equity (0,5 % Moderate), esposizione minima di eToro 1000 USD per gamba, leva effettiva ≤ 10.
|
||||
|
||||
**Uscite** (§5.4): take-profit di basket in pip (10 nel Moderate) **oppure** rientro dello z sotto `z_out` (0,25), a seconda di `exitMode`; stop di basket a `|z| ≥ z_stop` (3,5) o perdita ≥ 1,5 % dell'equity; time-stop dopo 4 giorni; spread anomalo per 3 barre consecutive; correlazione rotta; kill-switch ed equity stop.
|
||||
|
||||
**Averaging** (§5.5): spento di fabbrica; `AddOnce` e `Grid` esistono solo per il test di falsificazione 2.
|
||||
|
||||
I preset (`strategy.json`):
|
||||
|
||||
| Preset | z_in | rischio/basket | basket max | TP pip | aggiunte | z_stop |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Conservative | 2,5 | 0,25 % | 2 | 8 | 0 | 3,0 |
|
||||
| Moderate | 2,0 | 0,50 % | 3 | 10 | 1 | 3,5 |
|
||||
| Aggressive | 1,5 | 1,00 % | 5 | 12 | 2 | 4,0 |
|
||||
|
||||
## 2. Dati e costi del backtest
|
||||
|
||||
- Tick MetaTrader 5 (UTC) dal 2018-12-12 al 2026-09-15, convertiti in barre M15 bid/ask (`backtest ticks`); ~192 000 barre per coppia, EURAUD solo 39 700 (parti del 2018, 2021, 2026).
|
||||
- Decisione alla chiusura della barra, esecuzione all'apertura della successiva sul lato giusto del book più 0,3 pip di slippage per gamba.
|
||||
- Due scenari di costo, entrambi assunzioni:
|
||||
- **etoro**: spread minimo per coppia = spread tipico pubblicato da eToro (EURUSD 1,0, USDCHF 1,5, AUDUSD 1,0, USDCAD 1,5, NZDUSD 2,5, EURNZD 5,0, EURAUD 3,0, AUDCAD 3,0 pip), overnight 0,3 pip/gamba/giorno;
|
||||
- **api**: spread dei tick senza pavimento (0,1-0,7 pip, come mostra l'API demo), overnight **0,9 pip/gamba/giorno** (0,91 USD/giorno per 10 000 EURUSD letti dall'endpoint dei costi il 2026-09-16).
|
||||
- Capitale iniziale 10 000 USD; equity stop al 9 % con ripartenza del picco (D-18), contando gli stop.
|
||||
- Niente calendario né notizie nel backtest: blackout e sentiment agiscono solo dal vivo.
|
||||
|
||||
## 3. Risultati
|
||||
|
||||
### 3.1 Baseline (strategy.json di fabbrica), costi etoro
|
||||
|
||||
| Preset | Basket | Win rate | Netto | Sharpe | Max DD | Costo medio | Break-even | Equity stop |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Conservative | 0 | — | 0 | — | — | — | — | 0 |
|
||||
| Moderate | 2 224 | 50 % | **−9 608 USD** | −3,38 | 96 % | 3,1 pip | −0,4 pip | 37 |
|
||||
| Aggressive | 2 219 | 53 % | **−9 802 USD** | −3,61 | 98 % | 3,4 pip | −0,2 pip | — |
|
||||
|
||||
Il Conservative non apre mai: con TP 8 pip il cost gate a 3× non passa mai (3 × 3,1 > 8). Il Moderate perde quasi tutto il capitale in 7,75 anni: 2 224 basket × ~4 USD di costo = il conto. Il break-even (il costo per basket che azzererebbe il P&L medio) è **−0,4 pip**: il segnale non produce nemmeno un pip lordo per basket.
|
||||
|
||||
### 3.2 Griglia (§9.1): 57 configurazioni, costi etoro
|
||||
|
||||
3 preset × W ∈ {60, 100, 150} × ρ_min ∈ {−0,5, −0,6, −0,7} × z_out ∈ {0,25, 0,5}, più le tre baseline. Risultato in `results/trials.csv` e `results/riepilogo_baskets.csv`:
|
||||
|
||||
- **nessuna configurazione con P&L netto positivo**;
|
||||
- la "migliore" per Sharpe è quella che non apre nulla (Sharpe 0);
|
||||
- PBO (CSCV, 16 blocchi) = 0,000 solo perché la selezione in-sample sceglie sempre la configurazione vuota: un numero degenere, non una prova di robustezza;
|
||||
- walk-forward "scegli il migliore degli ultimi 6 mesi, applicalo un mese" su 88 mesi: Sharpe −0,75, drawdown 13 %, PSR 0,005;
|
||||
- DSR di ogni prova: 0.
|
||||
|
||||
Motivi di non ingresso, in ordine: `no_signal`, `cost_gate`, `rho_low`, `half_life`. Il cancello ρ ≤ −0,6 è raro sulle barre M15: dal vivo il 2026-09-16 ρ_W è rimasta fra −0,13 e −0,42 per tutta la sessione.
|
||||
|
||||
### 3.3 Falsificazione (§9.2), entrambi gli scenari
|
||||
|
||||
`reports/falsificazione.csv` (etoro) e `reports/falsificazione_costi_api.csv` (api), preset Moderate:
|
||||
|
||||
| Test | Variante | Basket (etoro / api) | Win rate | Netto etoro | Netto api | Break-even etoro / api |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 segnale | ZScoreSynthetic | 2 224 / 2 076 | 50 % / 48 % | −9 608 | −9 802 | −0,4 / −0,5 pip |
|
||||
| 1 segnale | PipDivergence | 3 124 / 2 877 | 42 % / 43 % | −9 406 | −9 784 | −0,6 / −0,4 pip |
|
||||
| 2 averaging | AddOnce ×1,0 | 2 123 / 1 821 | 51 % / 48 % | −9 689 | −9 800 | +0,3 / +0,4 pip |
|
||||
| 2 averaging | AddOnce ×1,5 | 1 980 / 1 673 | 51 % / 47 % | −9 741 | −9 802 | +0,2 / +0,3 pip |
|
||||
| 3 stop | senza stop | 1 844 / 1 949 | 55 % / 54 % | −9 228 | −9 796 | −0,2 / −0,4 pip |
|
||||
| 4 cost gate | 2× | 2 135 / 2 052 | 48 % / 48 % | −9 800 | −9 800 | −0,7 / −0,6 pip |
|
||||
| 4 cost gate | 4× | 0 / 3 | — / 33 % | 0 | −81 | — / +3,8 pip |
|
||||
| 5 inverso | segnale invertito | 1 824 / 1 537 | 43 % / 39 % | −9 735 | −9 801 | **−2,5 / −2,9 pip** |
|
||||
|
||||
Letture:
|
||||
|
||||
- **Il segnale ha un contenuto, ma piccolo.** Invertirlo peggiora il break-even di circa 2-2,5 pip per basket (da −0,4 a −2,5). Quindi il verso del segnale vale ~2 pip; il costo medio di un basket è 3,1-3,2 pip. Non basta, in nessuno dei due scenari.
|
||||
- **L'averaging alza il break-even di ~0,7 pip** (compra i rientri) ma allunga la coda: il percentile 1 % delle perdite passa da −98 a −115 USD e il drawdown sale. Non cambia il segno del risultato.
|
||||
- **Senza stop** il win rate sale al 55 % e il netto migliora di 380 USD nello scenario etoro, ma la coda (1 %: −136 USD) e il drawdown restano quelli di un sistema che tiene le perdite aperte. Lo stop resta obbligatorio.
|
||||
- **Il cost gate non salva la strategia**: a 4× non apre quasi nulla, a 2× apre di più e perde di più. Il costo è il problema, ma non è l'unico: anche con lo spread a 0,1 pip (scenario api) l'overnight riporta il costo a 3,2 pip.
|
||||
- **PipDivergence** apre di più e perde di più (win rate 42 %).
|
||||
|
||||
### 3.4 Griglia con costi api
|
||||
|
||||
`results/trials_costi_api.csv` e `results/riepilogo_baskets_costi_api.csv` (57 prove):
|
||||
|
||||
- baseline Moderate: 2 076 basket, win rate 48 %, netto **−9 802 USD**, Sharpe −3,88, costo medio 3,2 pip;
|
||||
- **nessuna prova con P&L netto positivo**; la migliore per Sharpe (T013: Conservative, W 150, ρ_min −0,5) apre 8 basket in 7,75 anni e perde 45 USD (Sharpe −0,10, DSR 0);
|
||||
- PBO 0,001, di nuovo degenere (la selezione in-sample sceglie configurazioni quasi vuote);
|
||||
- walk-forward 6 m / 1 m: Sharpe −0,91, drawdown 13,5 %, PSR 0,000.
|
||||
|
||||
Lo scenario api sposta il costo dallo spread all'overnight senza cambiarne l'ordine di grandezza, perché un basket resta aperto in media più di un giorno (0,9 pip/gamba/giorno × 2 gambe × ~1,5 giorni ≈ 2,7 pip).
|
||||
|
||||
## 4. Verdetto
|
||||
|
||||
**Negativo.** Sui 7,75 anni disponibili la strategia perde in ogni configurazione provata, con entrambi i modelli di costo, e i test di falsificazione non trovano una variante che inverta il segno. Il segnale contiene circa 2 pip di informazione per basket contro 3 pip di costo.
|
||||
|
||||
Cosa ne segue:
|
||||
|
||||
1. Il bot **non va sul reale**. `run.allowLive` resta `false`; i cancelli di §9.4 non sono raggiungibili con questi numeri.
|
||||
2. Il Demo serve a **misurare** (spread reale in esecuzione, slippage, overnight effettivo, quanto spesso i cancelli si aprono), non a guadagnare. Ogni basket chiuso finisce nel ledger e nel dataset del meta-modello.
|
||||
3. Se qualcuno vuole cambiare un parametro, lo fa attraverso `knowledge/proposals.csv` e un forward test pre-registrato (`knowledge/preregistrazione.csv`), non ritoccando `strategy.json` dopo aver guardato i risultati: ogni prova in più abbassa il DSR di tutte le altre.
|
||||
|
||||
## 5. Cosa non è stato misurato
|
||||
|
||||
- L'effetto del blackout del calendario e del sentiment (assenti nel backtest).
|
||||
- Lo spread effettivo di esecuzione su eToro: l'API demo mostra 0,1 pip di mercato e markup 0; il ledger del Demo dirà se le esecuzioni lo confermano (slippage per gamba scritto a ogni ingresso).
|
||||
- EURAUD/AUDCAD su tutto il periodo (dati parziali).
|
||||
- Timeframe diversi da M15 e finestre oltre 150 barre.
|
||||
|
||||
## 6. Come rieseguire
|
||||
|
||||
```powershell
|
||||
backtest ticks --data "A:\Download\Trading" --out "%USERPROFILE%\Documents\Encelado\data\market"
|
||||
backtest baskets --data "%USERPROFILE%\Documents\Encelado\data\market" --out results [--costs api]
|
||||
backtest falsify --data "%USERPROFILE%\Documents\Encelado\data\market" --out reports [--costs api]
|
||||
```
|
||||
|
||||
Ogni esecuzione riscrive le tabelle; i numeri di questo documento vengono da quelle del 2026-09-16.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-0001 — Broker: eToro
|
||||
|
||||
Data: 2026-09-16. Stato: accettata.
|
||||
|
||||
## Contesto
|
||||
|
||||
La strategia opera otto coppie forex (EURUSD, USDCHF, AUDUSD, USDCAD, NZDUSD, EURNZD, EURAUD, AUDCAD) come CFD. Il bot aveva un adattatore Binance (ritirato: dal 1° luglio 2026 l'utente non può operare in USDT) e un adattatore cTrader mai collegato. Alpaca è esclusa perché non offre forex.
|
||||
|
||||
## Decisione
|
||||
|
||||
Il modulo basket opera su **eToro Public API** (`https://public-api.etoro.com`), autenticazione con `x-api-key` + `x-user-key` (chiavi diverse per demo e reale, ambiente sempre visibile in UI), `x-request-id` obbligatorio (usato anche come `referenceId` idempotente degli ordini).
|
||||
|
||||
Fatti verificati il 2026-09-16 sulla specifica OpenAPI servita dall'API (v1.379.0):
|
||||
|
||||
- Quote: `GET /api/v2/market-data/rates?instrumentIds=…` (batch, quota condivisa 120/min).
|
||||
- Candele: `GET /api/v1/market-data/instruments/{id}/history/candles/{asc|desc}/{FifteenMinutes}/{≤1000}` — senza data di partenza: **non pagina lo storico**.
|
||||
- Strumenti: `GET /api/v2/market-data/instruments?symbols=…`; eligibility `POST /api/v2/trading/info/{demo/}eligibility`; costi what-if `POST /api/v2/trading/info/{demo/}costs`.
|
||||
- Conto e posizioni: `GET /api/v1/trading/info/{demo/}pnl` (posizioni con P&L non realizzato, `credit`); saldi `GET /api/v1/balances`.
|
||||
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (20/min), esito con `GET /api/v2/trading/info/{demo/}orders:lookup?referenceId=…`; chiusura `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}` con esito in `GET /api/v1/trading/info/{demo|real}/close-orders/{orderId}`; SL/TP `PATCH /api/v2/trading/{demo/}positions/{id}`.
|
||||
- Storico chiusure: `GET /api/v1/trading/info/trade/{demo/}history?minDate=…`.
|
||||
|
||||
## Alternative
|
||||
|
||||
- **IC Markets / cTrader**: spread ECN più stretti e Open API con streaming, ma l'utente ha chiesto eToro e usa già l'API; l'adattatore resta nel repo per il motore `proba`.
|
||||
- **Alpaca**: niente forex.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Nessun order book, spread con markup, esecuzione solo a mercato (o MIT) con SL/TP nativi: il cost gate deve leggere spread e markup reali a ogni decisione.
|
||||
- Lo storico per il backtest viene dai tick MT5 dell'utente, non dall'API.
|
||||
- Ogni ordine nasce con uno stop nativo (richiesto per `sellShort` e per leva > 1); il bot gestisce comunque lo stop di basket.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-0002 — Storage su file per il modulo basket
|
||||
|
||||
Data: 2026-09-16. Stato: accettata.
|
||||
|
||||
## Contesto
|
||||
|
||||
Il repository ha già un database SQLite (`Encelado.Storage`, unica dipendenza NuGet a runtime) usato dal motore `proba` per barre, dataset, modelli e journal. La specifica del modulo basket chiede storage su file: CSV `;` con `motivazione`, JSONL append-only per ledger e notizie, JSON per modelli e stato, scritture atomiche, rotazione mensile, nessuna riga del ledger modificata.
|
||||
|
||||
## Decisione
|
||||
|
||||
Il modulo basket **non usa SQLite**. Tutto vive in file sotto `Documenti\Encelado\`:
|
||||
|
||||
```
|
||||
data/market/candles_<SYMBOL>_M15.csv barre M15 bid/ask (dallo strumento ticks e dal delta API)
|
||||
data/calendar/events.jsonl eventi economici (dedup title+date+country)
|
||||
data/news/news_YYYYMM.jsonl notizie RSS (dedup hash(link))
|
||||
data/ledger/decisions.jsonl ogni valutazione di ogni basket (append-only, rotazione mensile in decisions_YYYYMM.jsonl)
|
||||
data/ledger/baskets.csv una riga per basket chiuso (label, P&L, costi, slippage)
|
||||
data/models/logreg_v<N>.json, mlp_v<N>.json, bandit.json, state.json
|
||||
knowledge/calibration.csv, insights_YYYYWW.md, proposals.csv, forward_registry.csv, models_registry.csv, preregistrazione.csv
|
||||
reports/*.csv, results/trials.csv
|
||||
```
|
||||
|
||||
## Alternative
|
||||
|
||||
- Riusare SQLite: comodo per query, ma introduce un binario nativo nel percorso del modulo, contraddice la specifica e rende il ledger modificabile per errore.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Le tabelle si aprono in un foglio di calcolo così come sono; il ledger è verificabile riga per riga.
|
||||
- L'analisi (ricostruzione del dataset, calibrazione) rilegge i file: costa qualche secondo per centinaia di migliaia di righe, accettabile per un ciclo settimanale.
|
||||
@@ -0,0 +1,15 @@
|
||||
# ADR-0003 — Motore cTrader mantenuto selezionabile
|
||||
|
||||
Data: 2026-09-16 (mattina). Stato: **superata da ADR-0004** (stesso giorno, pomeriggio).
|
||||
|
||||
## Contesto
|
||||
|
||||
All'inizio del lavoro sui Correlation Baskets l'albero conteneva un motore probabilistico su cTrader (`proba`) non committato e non compilante. La regola «non toccare i comportamenti esistenti se non richiesto» suggeriva di rimetterlo in compilazione e lasciarlo selezionabile con `engine.strategy = "proba"`, con `"baskets"` come predefinito.
|
||||
|
||||
## Decisione (originaria)
|
||||
|
||||
Tenere entrambi i motori dietro `IEngine`, con la finestra che sceglie le pagine in base al motore configurato.
|
||||
|
||||
## Esito
|
||||
|
||||
Nel pomeriggio l'utente ha chiesto la rimozione di tutto ciò che riguarda le gestioni precedenti e, alla domanda esplicita, ha incluso cTrader e la ricerca (D-21). La decisione è registrata in ADR-0004; questo documento resta per la cronologia.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-0004 — Rimozione dei motori precedenti (Binance, cTrader/proba, ricerca)
|
||||
|
||||
Data: 2026-09-16. Stato: accettata. Sostituisce ADR-0003.
|
||||
|
||||
## Contesto
|
||||
|
||||
Il repository portava tre generazioni di codice: l'arbitraggio statistico su Binance Futures (con adattatore già rimosso), il motore probabilistico su cTrader con la sua pipeline di ricerca (SQLite, GBDT, RL, TA-Lib, backtest a coppie) e il modulo Correlation Baskets su eToro. ADR-0003 aveva tenuto il motore cTrader selezionabile per non toccare comportamenti esistenti. L'utente ha chiesto un rework completo che elimini «qualsiasi cosa legata a vecchie gestioni (binance, alpaca, ecc ecc)» e, alla domanda esplicita, ha scelto di rimuovere anche cTrader e la ricerca (D-21).
|
||||
|
||||
## Decisione
|
||||
|
||||
Restano solo `Encelado.Core` (basket, broker, notizie, statistica condivisa), `Encelado.Etoro`, `Encelado.Bot` e lo strumento `tools/Encelado.Backtest` con i tre comandi `ticks`, `baskets`, `falsify`. Sono stati eliminati i progetti `Encelado.CTrader` e `Encelado.Storage`, le cartelle `Core/Backtest`, `Indicators`, `Journal`, `Market`, `Portfolio`, `Research`, `Risk`, `Rl`, `Strategies`, quasi tutto `Ml` (restano `Classification` e `Pbo`) e `Statistics` (restano `Performance`, `Ols`, `Distributions`), il motore `ProbaEngine`, le pagine e i test relativi, il selettore `engine.strategy`, le sezioni di configurazione `ctrader`, `engine`, `strategy`, `risk`, `storage`, `symbols`. Nessun pacchetto NuGet resta nei progetti dell'applicazione.
|
||||
|
||||
Il codice rimosso è nella storia git (tag `v3.5.0` e commit `b39e08b`).
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Una sola strategia, una sola configurazione, una sola finestra: meno codice da capire e da testare (da 322 a 172 test, tutti sul modulo che gira).
|
||||
- Le conclusioni delle ricerche precedenti (StatArb su BTC non valida fuori campione, ProbaBot) restano solo nei documenti e nella memoria di lavoro; non sono più riproducibili da questo albero.
|
||||
- Un file di configurazione della versione precedente viene letto con avvisi mirati («sezione di una versione precedente») e il ripristino dei valori di fabbrica lo riscrive.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-0005 — Nessuna approvazione manuale dei singoli ordini
|
||||
|
||||
Data: 2026-09-16. Stato: accettata (decisione dell'utente, D-20).
|
||||
|
||||
## Contesto
|
||||
|
||||
La specifica prevedeva cinque modalità (`Paper`, `DemoApprove`, `DemoAuto`, `LiveApprove`, `LiveAuto`) con `DemoApprove` predefinita: ogni apertura, aggiunta e take-profit era una proposta che aspettava una persona per quindici minuti. Nella sessione di prova del 2026-09-16 il bot in `DemoApprove` non ha mai potuto operare senza qualcuno alla finestra, e il test lungo richiesto dall'utente («sperando di piazzare trade») non è possibile in quel modo. Alla domanda «posso usare DemoAuto per il test lungo?» l'utente ha risposto: «tutti gli Approve devono sparire, almeno per il momento. Il bot deve girare in completa autonomia aprendo e chiudendo le posizioni senza il mio consenso».
|
||||
|
||||
## Decisione
|
||||
|
||||
Le modalità diventano tre: `Paper`, `Demo` (predefinita) e `Live`. In tutte il bot esegue da solo le decisioni del decisore. La coda delle approvazioni (`ApprovalQueue`), i comandi `approve`/`reject`, il flag `run.allowDemoAuto` e la conferma all'avvio del demo automatico sono rimossi. I nomi precedenti (`DemoApprove`, `DemoAuto`, `LiveApprove`, `LiveAuto`) vengono ancora letti dal file di configurazione, mappati su `Demo`/`Live` con un avviso.
|
||||
|
||||
Restano i gate umani che non riguardano il singolo ordine: la frase `CONFERMO LIVE` all'avvio del reale (con `run.allowLive = true`), il kill-switch, il reset motivato dopo un equity stop, il cambio di preset a caldo.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Le regole «mai un ordine reale senza flag e conferma» restano vere a livello di sessione: il reale non parte senza flag e frase.
|
||||
- Le uscite protettive erano già automatiche in ogni modalità; ora lo sono anche le aperture e i take-profit.
|
||||
- Se in futuro servisse una revisione umana, va reintrodotta come modalità esplicita e non come default: la specifica originaria resta documentata qui.
|
||||
@@ -0,0 +1,14 @@
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
1_segnale;ZScoreSynthetic;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
1_segnale;PipDivergence;3124;0.4238;-9406.4611;-3.7746;0.9412;-75.4011;-31.0045;-0.6061;3.1477;0;0;3124 basket, win rate 42 %, netto -9406 USD, Sharpe -3.77, DD 94.1 %, 1% -75 USD, 5% -31 USD: perde al netto dei costi assunti
|
||||
2_averaging;Off x1.0;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
|
||||
2_averaging;Grid x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
2_averaging;Grid x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
3_stop;con stop;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
3_stop;senza stop;1844;0.5542;-9228.1048;-2.3634;0.9251;-136.115;-67.6782;-0.1898;3.1101;0;0;1844 basket, win rate 55 %, netto -9228 USD, Sharpe -2.36, DD 92.5 %, 1% -136 USD, 5% -68 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
|
||||
4_cost_gate;2x;2135;0.4843;-9800.3487;-3.7111;0.9803;-93.8542;-45.5499;-0.7129;3.5108;0;0;2135 basket, win rate 48 %, netto -9800 USD, Sharpe -3.71, DD 98.0 %, 1% -94 USD, 5% -46 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;3x;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;4x;0;;0;0;0;;;;;0.5;0;0 basket, win rate NaN, netto 0 USD, Sharpe 0.00, DD 0.0 %, 1% NaN USD, 5% NaN USD: perde al netto dei costi assunti
|
||||
5_inverso;segnale invertito;1824;0.4331;-9735.0255;-4.0007;0.974;-82.2238;-45.0301;-2.5283;3.1747;0;0;1824 basket, win rate 43 %, netto -9735 USD, Sharpe -4.00, DD 97.4 %, 1% -82 USD, 5% -45 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
|
||||
|
@@ -0,0 +1,14 @@
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
1_segnale;ZScoreSynthetic;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
1_segnale;PipDivergence;2877;0.4282;-9783.9713;-4.2544;0.9786;-74.479;-33.8654;-0.4263;3.1921;0;0;2877 basket, win rate 43 %, netto -9784 USD, Sharpe -4.25, DD 97.9 %, 1% -74 USD, 5% -34 USD: perde al netto dei costi assunti
|
||||
2_averaging;Off x1.0;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
|
||||
2_averaging;Grid x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
2_averaging;Grid x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
3_stop;con stop;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
3_stop;senza stop;1949;0.5387;-9795.9761;-3.054;0.9799;-107.4238;-51.7478;-0.3737;3.1928;0;0;1949 basket, win rate 54 %, netto -9796 USD, Sharpe -3.05, DD 98.0 %, 1% -107 USD, 5% -52 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
|
||||
4_cost_gate;2x;2052;0.4771;-9800.0224;-3.7234;0.9802;-93.1449;-44.9303;-0.6448;3.4791;0;0;2052 basket, win rate 48 %, netto -9800 USD, Sharpe -3.72, DD 98.0 %, 1% -93 USD, 5% -45 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;3x;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;4x;3;0.3333;-81.1762;-0.3256;0.0081;-72.0723;-72.0723;3.8259;2.4592;0.0228;0;3 basket, win rate 33 %, netto -81 USD, Sharpe -0.33, DD 0.8 %, 1% -72 USD, 5% -72 USD: perde al netto dei costi assunti
|
||||
5_inverso;segnale invertito;1537;0.3852;-9800.9519;-4.3499;0.9801;-83.2267;-48.4287;-2.8935;3.2095;0;0;1537 basket, win rate 39 %, netto -9801 USD, Sharpe -4.35, DD 98.0 %, 1% -83 USD, 5% -48 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
voce;valore;motivazione
|
||||
periodo;2018-12-12 → 2026-09-15;barre M15 dai tick MT5, spread minimo = tipico eToro, slippage 0.3 pip/gamba, overnight 0.3 pip/gamba/giorno
|
||||
prove;57;ogni configurazione provata conta nel Sharpe deflazionato
|
||||
baseline Moderate;Sharpe -3.38, netto -9608 USD, 2224 basket, win rate 50 %;la configurazione di fabbrica così com'è
|
||||
migliore;BASE-CON: Sharpe 0.00, DSR 0.000;non distinguibile dalla selezione fra le prove
|
||||
PBO;0.000;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
|
||||
walk-forward;Sharpe -0.75, DD 13.2 %, PSR 0.005;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
|
||||
verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
|
||||
|
@@ -0,0 +1,8 @@
|
||||
voce;valore;motivazione
|
||||
periodo;2018-12-12 → 2026-09-16;barre M15 dai tick MT5, spread minimo = nessuno (spread dei tick), slippage 0.3 pip/gamba, overnight 0.9 pip/gamba/giorno
|
||||
prove;57;ogni configurazione provata conta nel Sharpe deflazionato
|
||||
baseline Moderate;Sharpe -3.88, netto -9802 USD, 2076 basket, win rate 48 %;la configurazione di fabbrica così com'è
|
||||
migliore;T013: Sharpe -0.10, DSR 0.000;non distinguibile dalla selezione fra le prove
|
||||
PBO;0.001;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
|
||||
walk-forward;Sharpe -0.91, DD 13.5 %, PSR 0.000;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
|
||||
verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
|
||||
|
@@ -0,0 +1,58 @@
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
BASE-CON;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
BASE-MOD;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2224;0.5018;-9608.2555;-3.38;0.9612;-0.3966;3.1231;-98.1631;-50.2921;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
BASE-AGG;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;2219;0.5273;-9801.7437;-3.605;0.9808;-0.154;3.4299;-107.4078;-54.9455;0;0;2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T001;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T002;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T003;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T004;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T005;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T006;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T007;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T008;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T009;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T010;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T011;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T012;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T013;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T014;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T015;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T016;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T017;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T018;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T019;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.5;3;1;2273;0.5095;-9776.971;-3.676;0.9777;-0.5181;3.1978;-100.8336;-48.8858;0;0;2273 basket, win rate 51 %, netto -9777 USD, Sharpe -3.68, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T020;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.5;3;1;2361;0.5078;-9780.3682;-3.8412;0.9781;-0.4561;3.1972;-93.6221;-45.8502;0;0;2361 basket, win rate 51 %, netto -9780 USD, Sharpe -3.84, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T021;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.6;3;1;2114;0.5019;-9777.7272;-3.7077;0.9778;-0.9027;3.1945;-97.6587;-50.0165;0;0;2114 basket, win rate 50 %, netto -9778 USD, Sharpe -3.71, DD 97.8 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T022;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.6;3;1;2186;0.4973;-9775.8854;-3.9248;0.9777;-0.7664;3.1947;-91.6099;-48.0941;0;0;2186 basket, win rate 50 %, netto -9776 USD, Sharpe -3.92, DD 97.8 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T023;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.7;3;1;1904;0.4806;-9758.7378;-3.9609;0.9759;-1.3315;3.1716;-104.8435;-53.09;0;0;1904 basket, win rate 48 %, netto -9759 USD, Sharpe -3.96, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
|
||||
T024;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.7;3;1;1943;0.4668;-9761.2755;-4.0828;0.9761;-1.3364;3.1729;-99.5743;-52.3411;0;0;1943 basket, win rate 47 %, netto -9761 USD, Sharpe -4.08, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
|
||||
T025;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.5;3;1;2386;0.4987;-9698.3332;-3.4922;0.9703;-0.5028;3.1298;-97.1191;-49.2531;0;0;2386 basket, win rate 50 %, netto -9698 USD, Sharpe -3.49, DD 97.0 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T026;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.5;3;1;2325;0.4886;-9733.1429;-3.7736;0.9737;-0.716;3.1401;-89.9765;-47.1863;0;0;2325 basket, win rate 49 %, netto -9733 USD, Sharpe -3.77, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T027;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2224;0.5018;-9608.2555;-3.38;0.9612;-0.3966;3.1231;-98.1631;-50.2921;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T028;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.6;3;1;2232;0.4928;-9626.41;-3.5471;0.963;-0.4681;3.1283;-92.7272;-48.5099;0;0;2232 basket, win rate 49 %, netto -9626 USD, Sharpe -3.55, DD 96.3 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T029;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.7;3;1;1620;0.4969;-9097.1212;-3.0336;0.9104;-0.7071;3.1249;-110.3729;-57.6683;0;0;1620 basket, win rate 50 %, netto -9097 USD, Sharpe -3.03, DD 91.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T030;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.7;3;1;1659;0.4846;-9151.1206;-3.1928;0.9158;-0.6745;3.125;-105.9072;-54.6317;0;0;1659 basket, win rate 48 %, netto -9151 USD, Sharpe -3.19, DD 91.6 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T031;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.5;3;1;1724;0.518;-8946.7157;-2.9015;0.8957;-0.8274;3.1056;-96.5951;-58.2571;0;0;1724 basket, win rate 52 %, netto -8947 USD, Sharpe -2.90, DD 89.6 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T032;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.5;3;1;1750;0.5114;-8984.9522;-3.0214;0.8994;-0.7697;3.1053;-94.7592;-56.1468;0;0;1750 basket, win rate 51 %, netto -8985 USD, Sharpe -3.02, DD 89.9 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T033;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.6;3;1;1524;0.523;-8546.383;-2.6559;0.856;-0.5673;3.1088;-108.0414;-60.4233;0;0;1524 basket, win rate 52 %, netto -8546 USD, Sharpe -2.66, DD 85.6 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T034;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.6;3;1;1555;0.5125;-8644.9455;-2.7834;0.8655;-0.5618;3.1095;-104.0531;-58.2323;0;0;1555 basket, win rate 51 %, netto -8645 USD, Sharpe -2.78, DD 86.5 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T035;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.7;3;1;1057;0.5232;-7365.987;-2.1831;0.7384;-0.7041;3.1237;-120.7347;-71.8169;0;0;1057 basket, win rate 52 %, netto -7366 USD, Sharpe -2.18, DD 73.8 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T036;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.7;3;1;1080;0.513;-7548.9926;-2.318;0.7562;-0.8988;3.124;-116.8562;-67.6427;0;0;1080 basket, win rate 51 %, netto -7549 USD, Sharpe -2.32, DD 75.6 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T037;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.5;3;1;2324;0.503;-9801.8989;-3.7439;0.9802;0.1247;3.453;-94.1066;-44.0792;0;0;2324 basket, win rate 50 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T038;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.5;3;1;2505;0.5022;-9801.8568;-3.8586;0.9802;0.2991;3.4527;-85.3644;-37.9877;0;0;2505 basket, win rate 50 %, netto -9802 USD, Sharpe -3.86, DD 98.0 %, costo medio 3.5 pip, break-even 0.3 pip: perde al netto dei costi
|
||||
T039;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.6;3;1;2206;0.4946;-9801.6714;-3.7449;0.9802;-0.122;3.4445;-95.2361;-46.4834;0;0;2206 basket, win rate 49 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T040;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.6;3;1;2382;0.5008;-9801.8364;-3.8898;0.9802;0.1249;3.4433;-91.5065;-39.2958;0;0;2382 basket, win rate 50 %, netto -9802 USD, Sharpe -3.89, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T041;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.7;3;1;2066;0.4782;-9800.3225;-3.9569;0.9801;-0.3695;3.4129;-106.8203;-50.7168;0;0;2066 basket, win rate 48 %, netto -9800 USD, Sharpe -3.96, DD 98.0 %, costo medio 3.4 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T042;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.7;3;1;2228;0.478;-9800.5097;-4.0478;0.9802;-0.0551;3.4114;-99.1796;-46.4296;0;0;2228 basket, win rate 48 %, netto -9801 USD, Sharpe -4.05, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T043;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.5;3;1;2333;0.5362;-9800.5583;-3.6212;0.9807;-0.1462;3.455;-114.2527;-52.729;0;0;2333 basket, win rate 54 %, netto -9801 USD, Sharpe -3.62, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T044;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.5;3;1;2418;0.5434;-9801.6954;-3.7353;0.9807;-0.0542;3.4505;-106.0593;-47.7018;0;0;2418 basket, win rate 54 %, netto -9802 USD, Sharpe -3.74, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T045;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;2219;0.5273;-9801.7437;-3.605;0.9808;-0.154;3.4299;-107.4078;-54.9455;0;0;2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T046;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.6;3;1;2299;0.5385;-9800.1165;-3.8043;0.9805;-0.0498;3.4277;-102.5053;-49.1087;0;0;2299 basket, win rate 54 %, netto -9800 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T047;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.7;3;1;2034;0.5103;-9801.176;-3.5533;0.9805;-0.7064;3.3793;-110.5845;-56.0264;0;0;2034 basket, win rate 51 %, netto -9801 USD, Sharpe -3.55, DD 98.1 %, costo medio 3.4 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T048;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.7;3;1;2111;0.5216;-9801.5062;-3.6808;0.9805;-0.5194;3.3807;-102.1236;-50.7364;0;0;2111 basket, win rate 52 %, netto -9802 USD, Sharpe -3.68, DD 98.1 %, costo medio 3.4 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T049;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.5;3;1;2535;0.5582;-9800.4562;-3.2691;0.9804;-0.1898;3.4255;-122.7526;-63.8002;0;0;2535 basket, win rate 56 %, netto -9800 USD, Sharpe -3.27, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T050;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.5;3;1;2652;0.5618;-9800.054;-3.34;0.9804;-0.12;3.4243;-117.3288;-56.7329;0;0;2652 basket, win rate 56 %, netto -9800 USD, Sharpe -3.34, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T051;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.6;3;1;2611;0.558;-9800.3025;-3.3139;0.9804;0.0259;3.3951;-122.3324;-61.2879;0;0;2611 basket, win rate 56 %, netto -9800 USD, Sharpe -3.31, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
T052;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.6;3;1;2722;0.5643;-9800.3917;-3.4336;0.9804;0.1646;3.3925;-117.9914;-53.3356;0;0;2722 basket, win rate 56 %, netto -9800 USD, Sharpe -3.43, DD 98.0 %, costo medio 3.4 pip, break-even 0.2 pip: perde al netto dei costi
|
||||
T053;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.7;3;1;2549;0.5457;-9799.5834;-3.5684;0.9806;-0.0172;3.3539;-130.1295;-56.5296;0;0;2549 basket, win rate 55 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T054;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.7;3;1;2602;0.5473;-9802.9899;-3.6937;0.9808;-0.0068;3.3538;-125.0607;-49.82;0;0;2602 basket, win rate 55 %, netto -9803 USD, Sharpe -3.69, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
|
@@ -0,0 +1,58 @@
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
BASE-CON;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
BASE-MOD;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2076;0.4769;-9801.9491;-3.8807;0.9804;-0.4892;3.2085;-96.1599;-51.6818;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
BASE-AGG;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;1995;0.5143;-9800.4836;-3.7229;0.9806;-0.1782;3.4269;-113.7756;-55.2381;0;0;1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T001;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.5;3;1;8;0.5;-69.5629;-0.2771;0.0086;4.9254;2.5879;-80.2036;-80.2036;0.1196;0;8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
|
||||
T002;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.5;3;1;8;0.375;-110.4189;-0.3764;0.0127;3.8879;2.5879;-80.2036;-80.2036;0.0455;0;8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
|
||||
T003;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.6;3;1;8;0.5;-69.5629;-0.2771;0.0086;4.9254;2.5879;-80.2036;-80.2036;0.1196;0;8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
|
||||
T004;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.6;3;1;8;0.375;-110.4189;-0.3764;0.0127;3.8879;2.5879;-80.2036;-80.2036;0.0455;0;8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
|
||||
T005;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.7;3;1;3;0.3333;-16.5569;-0.2092;0.0024;10.6949;2.5283;-24.5014;-24.5014;0.1936;0;3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
|
||||
T006;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.7;3;1;3;0.3333;-16.5569;-0.2092;0.0024;10.6949;2.5283;-24.5014;-24.5014;0.1936;0;3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
|
||||
T007;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.5;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T008;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.5;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T009;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T010;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T011;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.7;3;1;3;0.3333;-31.767;-0.2049;0.0048;5.9618;2.5284;-38.4174;-38.4174;0.2014;0;3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
|
||||
T012;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.7;3;1;3;0.3333;-31.767;-0.2049;0.0048;5.9618;2.5284;-38.4174;-38.4174;0.2014;0;3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
|
||||
T013;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.5;3;1;8;0.625;-44.5609;-0.1017;0.0143;0.1426;2.3801;-111.7539;-111.7539;0.3732;0;8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T014;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.5;3;1;8;0.625;-44.5609;-0.1017;0.0143;0.1426;2.3801;-111.7539;-111.7539;0.3732;0;8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T015;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.6;3;1;6;0.5;-113.611;-0.2917;0.0152;-4.221;2.629;-111.7539;-111.7539;0.1381;0;6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
|
||||
T016;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.6;3;1;6;0.5;-113.611;-0.2917;0.0152;-4.221;2.629;-111.7539;-111.7539;0.1381;0;6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
|
||||
T017;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.7;3;1;4;0.5;-100.5194;-0.2632;0.0139;-8.8621;2.6129;-111.7035;-111.7035;0.1631;0;4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
|
||||
T018;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.7;3;1;4;0.5;-100.5194;-0.2632;0.0139;-8.8621;2.6129;-111.7035;-111.7035;0.1631;0;4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
|
||||
T019;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.5;3;1;2018;0.4792;-9800.6546;-4.1396;0.9801;-0.7742;3.2141;-103.5455;-51.4822;0;0;2018 basket, win rate 48 %, netto -9801 USD, Sharpe -4.14, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T020;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.5;3;1;2192;0.4886;-9803.1597;-4.2704;0.9803;-0.5484;3.2139;-97.7234;-46.9665;0;0;2192 basket, win rate 49 %, netto -9803 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T021;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.6;3;1;1972;0.4787;-9801.7849;-4.0332;0.9802;-0.9003;3.2155;-100.0295;-49.8798;0;0;1972 basket, win rate 48 %, netto -9802 USD, Sharpe -4.03, DD 98.0 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T022;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.6;3;1;2069;0.4819;-9803.6976;-4.2507;0.9804;-0.782;3.2167;-93.6889;-48.8346;0;0;2069 basket, win rate 48 %, netto -9804 USD, Sharpe -4.25, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T023;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.7;3;1;2055;0.4818;-9801.1624;-4.0098;0.9801;-0.5217;3.2143;-102.1531;-50.7697;0;0;2055 basket, win rate 48 %, netto -9801 USD, Sharpe -4.01, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T024;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.7;3;1;2132;0.4855;-9801.1437;-4.1302;0.9801;-0.4595;3.2148;-98.965;-47.3869;0;0;2132 basket, win rate 49 %, netto -9801 USD, Sharpe -4.13, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T025;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.5;3;1;2101;0.4731;-9801.082;-3.7918;0.9804;-0.5503;3.2078;-94.4597;-50.6244;0;0;2101 basket, win rate 47 %, netto -9801 USD, Sharpe -3.79, DD 98.0 %, costo medio 3.2 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T026;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.5;3;1;2140;0.4738;-9801.717;-3.9388;0.9804;-0.5081;3.2089;-80.9489;-48.5339;0;0;2140 basket, win rate 47 %, netto -9802 USD, Sharpe -3.94, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T027;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2076;0.4769;-9801.9491;-3.8807;0.9804;-0.4892;3.2085;-96.1599;-51.6818;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T028;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.6;3;1;2108;0.4782;-9801.8954;-4.0225;0.9803;-0.4629;3.2091;-87.7027;-50.0178;0;0;2108 basket, win rate 48 %, netto -9802 USD, Sharpe -4.02, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T029;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.7;3;1;1984;0.4582;-9797.6241;-4.1086;0.9799;-0.798;3.1973;-92.7908;-47.5743;0;0;1984 basket, win rate 46 %, netto -9798 USD, Sharpe -4.11, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T030;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.7;3;1;2040;0.4574;-9797.1369;-4.2728;0.9799;-0.7345;3.1987;-87.6021;-45.6211;0;0;2040 basket, win rate 46 %, netto -9797 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T031;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.5;3;1;2238;0.4915;-9733.8654;-3.7948;0.9738;-0.5971;3.1445;-90.112;-47.5574;0;0;2238 basket, win rate 49 %, netto -9734 USD, Sharpe -3.79, DD 97.4 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T032;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.5;3;1;2247;0.4887;-9738.5622;-3.9701;0.9742;-0.6768;3.1479;-89.2127;-45.6445;0;0;2247 basket, win rate 49 %, netto -9739 USD, Sharpe -3.97, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T033;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.6;3;1;2090;0.4856;-9697.0682;-3.7782;0.97;-0.7223;3.1429;-95.5498;-51.6315;0;0;2090 basket, win rate 49 %, netto -9697 USD, Sharpe -3.78, DD 97.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T034;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.6;3;1;2120;0.4863;-9681.6883;-3.8261;0.9684;-0.5759;3.1425;-96.173;-48.981;0;0;2120 basket, win rate 49 %, netto -9682 USD, Sharpe -3.83, DD 96.8 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T035;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.7;3;1;1616;0.4901;-9304.7978;-3.3946;0.9309;-0.9725;3.1466;-103.2808;-55.1427;0;0;1616 basket, win rate 49 %, netto -9305 USD, Sharpe -3.39, DD 93.1 %, costo medio 3.1 pip, break-even -1.0 pip: perde al netto dei costi
|
||||
T036;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.7;3;1;1650;0.4818;-9315.3955;-3.4957;0.9319;-0.9219;3.1469;-103.8865;-53.903;0;0;1650 basket, win rate 48 %, netto -9315 USD, Sharpe -3.50, DD 93.2 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T037;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.5;3;1;2222;0.5018;-9800.1789;-3.493;0.9801;0.1139;3.4884;-95.5877;-43.0006;0;0;2222 basket, win rate 50 %, netto -9800 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T038;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.5;3;1;2319;0.5071;-9801.3643;-3.6642;0.9802;0.1721;3.4888;-90.1871;-38.1818;0;0;2319 basket, win rate 51 %, netto -9801 USD, Sharpe -3.66, DD 98.0 %, costo medio 3.5 pip, break-even 0.2 pip: perde al netto dei costi
|
||||
T039;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.6;3;1;2068;0.4971;-9802.4311;-3.6774;0.9803;-0.3555;3.4622;-96.564;-45.9072;0;0;2068 basket, win rate 50 %, netto -9802 USD, Sharpe -3.68, DD 98.0 %, costo medio 3.5 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T040;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.6;3;1;2261;0.5042;-9801.8015;-3.7059;0.9802;-0.0458;3.4572;-87.7666;-43.2972;0;0;2261 basket, win rate 50 %, netto -9802 USD, Sharpe -3.71, DD 98.0 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T041;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.7;3;1;2032;0.4818;-9803.4939;-3.8824;0.9804;-0.2081;3.3942;-102.0523;-51.4273;0;0;2032 basket, win rate 48 %, netto -9803 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T042;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.7;3;1;2223;0.4876;-9799.9736;-4.122;0.9801;0.1232;3.3884;-96.1687;-46.1443;0;0;2223 basket, win rate 49 %, netto -9800 USD, Sharpe -4.12, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T043;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.5;3;1;2161;0.5298;-9800.4196;-3.5742;0.9806;-0.0636;3.461;-110.6802;-51.9967;0;0;2161 basket, win rate 53 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T044;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.5;3;1;2289;0.5382;-9801.341;-3.7496;0.9806;-0.0314;3.4567;-99.7287;-47.5057;0;0;2289 basket, win rate 54 %, netto -9801 USD, Sharpe -3.75, DD 98.1 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T045;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;1995;0.5143;-9800.4836;-3.7229;0.9806;-0.1782;3.4269;-113.7756;-55.2381;0;0;1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T046;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.6;3;1;2092;0.5268;-9801.2355;-3.8187;0.9806;-0.1043;3.4231;-103.851;-49.8169;0;0;2092 basket, win rate 53 %, netto -9801 USD, Sharpe -3.82, DD 98.1 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T047;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.7;3;1;1790;0.4955;-9801.26;-3.7117;0.9805;-1.011;3.3543;-107.8876;-56.2754;0;0;1790 basket, win rate 50 %, netto -9801 USD, Sharpe -3.71, DD 98.1 %, costo medio 3.4 pip, break-even -1.0 pip: perde al netto dei costi
|
||||
T048;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.7;3;1;1972;0.5137;-9802.9285;-3.7993;0.9806;-0.6154;3.3518;-97.0256;-50.7654;0;0;1972 basket, win rate 51 %, netto -9803 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T049;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.5;3;1;2267;0.5483;-9800.1872;-3.3236;0.9804;-0.2425;3.4352;-127.609;-57.2805;0;0;2267 basket, win rate 55 %, netto -9800 USD, Sharpe -3.32, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T050;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.5;3;1;2289;0.547;-9800.6111;-3.4919;0.9804;-0.2567;3.4319;-121.7571;-55.2384;0;0;2289 basket, win rate 55 %, netto -9801 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.4 pip, break-even -0.3 pip: perde al netto dei costi
|
||||
T051;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.6;3;1;2280;0.55;-9799.948;-3.3521;0.9804;-0.177;3.383;-116.3317;-59.2741;0;0;2280 basket, win rate 55 %, netto -9800 USD, Sharpe -3.35, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T052;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.6;3;1;2412;0.5547;-9800.0702;-3.4091;0.9804;0.0354;3.3835;-114.6115;-56.6657;0;0;2412 basket, win rate 55 %, netto -9800 USD, Sharpe -3.41, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
T053;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.7;3;1;2298;0.5361;-9802.0629;-3.5954;0.9808;-0.121;3.3223;-127.3343;-57.6739;0;0;2298 basket, win rate 54 %, netto -9802 USD, Sharpe -3.60, DD 98.1 %, costo medio 3.3 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T054;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.7;3;1;2441;0.5453;-9800.0711;-3.6641;0.9805;0.0417;3.3249;-123.3318;-50.6626;0;0;2441 basket, win rate 55 %, netto -9800 USD, Sharpe -3.66, DD 98.1 %, costo medio 3.3 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
|
@@ -1,158 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Encelado.Binance</RootNamespace>
|
||||
<AssemblyName>Encelado.Binance</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Encelado.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,155 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
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>Most klines one request may return.</summary>
|
||||
public const int MaxKlinesPerRequest = 1500;
|
||||
|
||||
/// <summary>
|
||||
/// Historical klines for one symbol, oldest first: the last <paramref name="limit"/>
|
||||
/// <b>closed</b> bars, or everything from <paramref name="startUtc"/> onwards.
|
||||
/// <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>
|
||||
/// <para>
|
||||
/// Binance serves at most 1500 klines per request, and a z-score window of 1500 bars
|
||||
/// needs more closed bars than that. Asking for 1500 used to yield 1499 closed bars,
|
||||
/// leaving every strategy one bar short of ready after each calibration — for up to a
|
||||
/// full bar interval. The history is therefore paged: consecutive requests advance a
|
||||
/// cursor from the earliest bar needed until the present.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<List<Bar>> GetKlinesAsync(
|
||||
string symbol,
|
||||
TimeFrame timeFrame,
|
||||
int limit,
|
||||
DateTime? startUtc,
|
||||
CancellationToken ct)
|
||||
{
|
||||
long bucketMs = timeFrame.Seconds() * 1000L;
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
long currentBucketOpen = nowMs - (nowMs % bucketMs);
|
||||
|
||||
int wanted = Math.Clamp(limit, 1, 50_000);
|
||||
|
||||
// Two extra buckets of slack: one for the forming bar that is dropped, one for
|
||||
// the boundary the clock may have just crossed.
|
||||
long cursor = startUtc is { } start
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(start, DateTimeKind.Utc)).ToUnixTimeMilliseconds()
|
||||
: currentBucketOpen - ((wanted + 2) * bucketMs);
|
||||
|
||||
List<Bar> bars = new(wanted + 2);
|
||||
|
||||
while (cursor < currentBucketOpen)
|
||||
{
|
||||
int batch = Math.Min(MaxKlinesPerRequest, wanted + 2 - bars.Count);
|
||||
if (batch <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
StringBuilder query = new(96);
|
||||
query.Append("symbol=").Append(symbol);
|
||||
query.Append("&interval=").Append(timeFrame.ToBinance());
|
||||
query.Append("&startTime=").Append(cursor.ToString(CultureInfo.InvariantCulture));
|
||||
query.Append("&limit=").Append(batch.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
using JsonDocument doc = await _http.GetAsync("fapi/v1/klines", query.ToString(), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int received = 0;
|
||||
long lastOpen = cursor;
|
||||
|
||||
foreach (JsonElement k in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
received++;
|
||||
long openMs = k.ArrayInt64(0);
|
||||
lastOpen = openMs;
|
||||
|
||||
if (openMs >= currentBucketOpen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bars.Add(ParseKline(k));
|
||||
}
|
||||
|
||||
// A short page means the exchange has nothing further back; a full one means
|
||||
// there may be more, and the cursor moves past the last bar seen.
|
||||
if (received == 0 || lastOpen + bucketMs <= cursor)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = lastOpen + bucketMs;
|
||||
|
||||
if (received < batch)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only the most recent `wanted` when the slack over-fetched.
|
||||
if (startUtc is null && bars.Count > wanted)
|
||||
{
|
||||
bars.RemoveRange(0, bars.Count - wanted);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Decode(payload);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// A truncated or malformed frame costs one message, never the connection.
|
||||
// The receive loop would swallow this too, but the decoder owning its own
|
||||
// failure mode is what lets it be tested without a socket.
|
||||
Log($"[{Name}] frame non decodificabile ({payload.Length} byte): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Decode(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
// 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>
|
||||
/// <para>
|
||||
/// The bar's fields live inside a nested <c>k</c> object, and the first version of
|
||||
/// this decoder skipped that object along with every other unwanted value. The bot
|
||||
/// ran for days receiving millions of book updates and not one closed bar, and only
|
||||
/// kept deciding because the REST backstop refetched the bars it never saw. The
|
||||
/// nested object is now descended into explicitly, and a regression test feeds the
|
||||
/// decoder a verbatim frame so this cannot happen quietly again.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void DecodeKline(ref Utf8JsonReader reader)
|
||||
{
|
||||
int symbolId = -1;
|
||||
KlineFields k = default;
|
||||
|
||||
// Positioned on the event's StartObject. Every property is matched by name so
|
||||
// ordering never matters; unwanted values are skipped whole, nested or not.
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reader.ValueTextEquals("s"u8))
|
||||
{
|
||||
reader.Read();
|
||||
symbolId = _symbols.Resolve(reader.ValueSpan);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reader.ValueTextEquals("k"u8))
|
||||
{
|
||||
reader.Read();
|
||||
if (reader.TokenType == JsonTokenType.StartObject)
|
||||
{
|
||||
DecodeKlineBody(ref reader, ref k);
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Skip();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
reader.Read();
|
||||
reader.Skip();
|
||||
}
|
||||
|
||||
if (!k.Closed || symbolId < 0 || k.Close <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Bar bar = new(
|
||||
DateTimeOffset.FromUnixTimeMilliseconds(k.OpenTime).UtcDateTime,
|
||||
k.Open, k.High, k.Low, k.Close, k.Volume,
|
||||
k.Volume > 0 ? k.QuoteVolume / k.Volume : 0,
|
||||
k.Trades,
|
||||
k.TakerBuy);
|
||||
|
||||
OnBar?.Invoke(symbolId, _symbols.Name(symbolId), bar);
|
||||
}
|
||||
|
||||
/// <summary>The fields of one kline, filled in whatever order Binance sends them.</summary>
|
||||
private struct KlineFields
|
||||
{
|
||||
public long OpenTime;
|
||||
public double Open;
|
||||
public double High;
|
||||
public double Low;
|
||||
public double Close;
|
||||
public double Volume;
|
||||
public double QuoteVolume;
|
||||
public double TakerBuy;
|
||||
public int Trades;
|
||||
public bool Closed;
|
||||
}
|
||||
|
||||
/// <summary>Reads the <c>k</c> object, positioned on its StartObject, through its EndObject.</summary>
|
||||
private static void DecodeKlineBody(ref Utf8JsonReader reader, ref KlineFields k)
|
||||
{
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reader.ValueTextEquals("t"u8)) { reader.Read(); k.OpenTime = reader.GetInt64(); }
|
||||
else if (reader.ValueTextEquals("o"u8)) { reader.Read(); k.Open = Number(ref reader); }
|
||||
else if (reader.ValueTextEquals("h"u8)) { reader.Read(); k.High = Number(ref reader); }
|
||||
else if (reader.ValueTextEquals("l"u8)) { reader.Read(); k.Low = Number(ref reader); }
|
||||
else if (reader.ValueTextEquals("c"u8)) { reader.Read(); k.Close = Number(ref reader); }
|
||||
else if (reader.ValueTextEquals("v"u8)) { reader.Read(); k.Volume = Number(ref reader); }
|
||||
else if (reader.ValueTextEquals("q"u8)) { reader.Read(); k.QuoteVolume = Number(ref reader); }
|
||||
else if (reader.ValueTextEquals("V"u8)) { reader.Read(); k.TakerBuy = Number(ref reader); }
|
||||
else if (reader.ValueTextEquals("n"u8)) { reader.Read(); k.Trades = reader.GetInt32(); }
|
||||
else if (reader.ValueTextEquals("x"u8)) { reader.Read(); k.Closed = reader.TokenType == JsonTokenType.True; }
|
||||
else { reader.Read(); reader.Skip(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds one frame straight into the decoder, exactly as the socket would. Exists so
|
||||
/// the tests can hand the decoder a verbatim Binance payload without a connection.
|
||||
/// </summary>
|
||||
internal void Feed(ReadOnlySpan<byte> utf8Frame) => OnMessage(utf8Frame, isText: true);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
namespace Encelado.Binance.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a symbol's UTF-8 bytes to a stable integer id and a single interned string
|
||||
/// instance. The market-data decoder resolves symbols straight from the receive
|
||||
/// buffer, so a busy tape does not allocate one string per tick.
|
||||
/// </summary>
|
||||
public sealed class SymbolTable
|
||||
{
|
||||
private readonly Dictionary<string, int> _ids;
|
||||
private readonly Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> _lookup;
|
||||
private readonly List<string> _names = [];
|
||||
|
||||
public SymbolTable(IEnumerable<string> symbols)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
_ids = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string s in symbols)
|
||||
{
|
||||
string symbol = s.Trim();
|
||||
if (symbol.Length > 0 && _ids.TryAdd(symbol, _names.Count))
|
||||
{
|
||||
_names.Add(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
_lookup = _ids.GetAlternateLookup<ReadOnlySpan<char>>();
|
||||
}
|
||||
|
||||
public int Count => _names.Count;
|
||||
|
||||
public IReadOnlyList<string> Names => _names;
|
||||
|
||||
/// <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 (BTCUSDT and the like), so a stack
|
||||
// buffer covers every real case without touching the heap.
|
||||
if (utf8.Length is 0 or > 32)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
Span<char> chars = stackalloc char[32];
|
||||
for (int i = 0; i < utf8.Length; i++)
|
||||
{
|
||||
byte b = utf8[i];
|
||||
if (b > 127)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
chars[i] = (char)b;
|
||||
}
|
||||
|
||||
return _lookup.TryGetValue(chars[..utf8.Length], out int id) ? id : -1;
|
||||
}
|
||||
|
||||
public int Resolve(string symbol) => _ids.TryGetValue(symbol, out int id) ? id : -1;
|
||||
|
||||
public string Name(int id) => (uint)id < (uint)_names.Count ? _names[id] : string.Empty;
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
using System.Buffers;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Encelado.Binance.Streaming;
|
||||
|
||||
public enum ChannelState : byte
|
||||
{
|
||||
Disconnected = 0,
|
||||
Connecting,
|
||||
Authenticating,
|
||||
Live,
|
||||
Faulted,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-lived WebSocket with authentication, resubscribe-on-reconnect and capped
|
||||
/// exponential backoff. Subclasses only implement the handshake and the message
|
||||
/// decoder; the reconnect loop, frame reassembly and buffer pooling live here.
|
||||
/// </summary>
|
||||
public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable
|
||||
{
|
||||
private const int InitialBufferSize = 64 * 1024;
|
||||
private const int MaxBufferSize = 8 * 1024 * 1024;
|
||||
|
||||
private readonly SemaphoreSlim _sendGate = new(1, 1);
|
||||
private ClientWebSocket? _socket;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
private int _consecutiveFailures;
|
||||
private int _rejections;
|
||||
private volatile string? _rejection;
|
||||
|
||||
public string Name { get; } = name;
|
||||
|
||||
/// <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;
|
||||
|
||||
public bool IsLive => State == ChannelState.Live;
|
||||
|
||||
/// <summary>Number of times the channel has (re)established a live session.</summary>
|
||||
public int ConnectCount { get; private set; }
|
||||
|
||||
public DateTime LastMessageUtc { get; private set; }
|
||||
|
||||
/// <summary>Diagnostics sink. Set by the host so channel events land in the bot log.</summary>
|
||||
public Action<string, Exception?>? OnLog { get; set; }
|
||||
|
||||
/// <summary>Raised whenever the channel transitions to or away from <see cref="ChannelState.Live"/>.</summary>
|
||||
public Action<bool>? OnLiveChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Why the server is refusing this channel, or null when nothing has refused it.
|
||||
/// Survives across reconnects so the UI can explain a channel that keeps bouncing.
|
||||
/// </summary>
|
||||
public string? RejectionReason => _rejection;
|
||||
|
||||
/// <summary>
|
||||
/// 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. 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 counted against it, and refuses itself forever. Closing
|
||||
/// immediately, and backing off past the server's own timeout, is what breaks that.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
protected void Reject(string reason)
|
||||
{
|
||||
_rejection = reason;
|
||||
Interlocked.Increment(ref _rejections);
|
||||
|
||||
// Aborting rather than closing politely: a graceful close needs a round trip the
|
||||
// server has already decided not to complete.
|
||||
try
|
||||
{
|
||||
_socket?.Abort();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Raced with the reconnect loop disposing it. Nothing left to abort.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the refusal once a session actually comes up.</summary>
|
||||
private void Accept()
|
||||
{
|
||||
_rejection = null;
|
||||
Interlocked.Exchange(ref _rejections, 0);
|
||||
Interlocked.Exchange(ref _consecutiveFailures, 0);
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
if (_loop is not null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
_loop = Task.Run(() => RunAsync(_cts.Token), CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_cts is not null)
|
||||
{
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _loop.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on shutdown.
|
||||
}
|
||||
|
||||
_loop = null;
|
||||
}
|
||||
|
||||
SetState(ChannelState.Disconnected);
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
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 -> {Redact(Uri)}", null);
|
||||
|
||||
SetState(ChannelState.Authenticating);
|
||||
await OnOpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
ConnectCount++;
|
||||
|
||||
// The failure counter is NOT reset here. Opening a socket and sending
|
||||
// the handshake proves nothing: the server can still refuse the session
|
||||
// a moment later. Resetting at this point was the bug behind an endless
|
||||
// reconnect loop — every attempt looked like a success, so the backoff
|
||||
// never grew past its first step and the client hammered a connection
|
||||
// limit every three seconds indefinitely. It is reset in Accept(),
|
||||
// called when the channel actually reaches Live.
|
||||
await ReceiveLoopAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_consecutiveFailures++;
|
||||
SetState(ChannelState.Faulted);
|
||||
|
||||
// A socket the server aborted after refusing us is the expected outcome
|
||||
// of Reject(), not a separate fault worth its own alarming line.
|
||||
if (_rejection is null)
|
||||
{
|
||||
OnLog?.Invoke($"[{Name}] connection failed ({_consecutiveFailures}): {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetState(ChannelState.Disconnected);
|
||||
DisposeSocket();
|
||||
|
||||
// The session ended without ever going live, so the attempt failed even
|
||||
// if no exception was thrown — a refusal followed by a clean server
|
||||
// close looks exactly like that.
|
||||
if (_rejection is not null)
|
||||
{
|
||||
_consecutiveFailures++;
|
||||
}
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
TimeSpan delay = BackoffDelay(_consecutiveFailures);
|
||||
|
||||
if (_rejection is { } reason)
|
||||
{
|
||||
OnLog?.Invoke(
|
||||
$"[{Name}] rifiutato dal server ({_rejections}x): {reason} — nuovo tentativo fra {delay.TotalSeconds:F0}s",
|
||||
null);
|
||||
}
|
||||
else
|
||||
{
|
||||
OnLog?.Invoke($"[{Name}] reconnecting in {delay.TotalSeconds:F1}s", null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(InitialBufferSize);
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested && _socket is { State: WebSocketState.Open })
|
||||
{
|
||||
int offset = 0;
|
||||
ValueWebSocketReceiveResult result;
|
||||
|
||||
do
|
||||
{
|
||||
if (offset == buffer.Length)
|
||||
{
|
||||
if (buffer.Length >= MaxBufferSize)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"[{Name}] message exceeded {MaxBufferSize / (1024 * 1024)} MB.");
|
||||
}
|
||||
|
||||
byte[] bigger = ArrayPool<byte>.Shared.Rent(buffer.Length * 2);
|
||||
Buffer.BlockCopy(buffer, 0, bigger, 0, offset);
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
buffer = bigger;
|
||||
}
|
||||
|
||||
result = await _socket.ReceiveAsync(buffer.AsMemory(offset), ct).ConfigureAwait(false);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
OnLog?.Invoke(
|
||||
$"[{Name}] server closed: {_socket.CloseStatus} {_socket.CloseStatusDescription}", null);
|
||||
return;
|
||||
}
|
||||
|
||||
offset += result.Count;
|
||||
}
|
||||
while (!result.EndOfMessage);
|
||||
|
||||
LastMessageUtc = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
OnMessage(buffer.AsSpan(0, offset), result.MessageType == WebSocketMessageType.Text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A malformed frame must never take the channel down.
|
||||
OnLog?.Invoke($"[{Name}] message handler threw: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
protected async ValueTask SendAsync(ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
ClientWebSocket? socket = _socket;
|
||||
if (socket is not { State: WebSocketState.Open })
|
||||
{
|
||||
throw new InvalidOperationException($"[{Name}] cannot send: socket is {socket?.State.ToString() ?? "null"}.");
|
||||
}
|
||||
|
||||
await _sendGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
protected virtual void ConfigureSocket(ClientWebSocketOptions socketOptions)
|
||||
{
|
||||
}
|
||||
|
||||
protected void SetState(ChannelState state)
|
||||
{
|
||||
if (State == state)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool wasLive = State == ChannelState.Live;
|
||||
State = state;
|
||||
bool isLive = state == ChannelState.Live;
|
||||
|
||||
// Reaching Live is the only evidence that a connection attempt worked, so it is
|
||||
// the only place the backoff is allowed to reset.
|
||||
if (isLive)
|
||||
{
|
||||
Accept();
|
||||
}
|
||||
|
||||
if (wasLive != isLive)
|
||||
{
|
||||
OnLiveChanged?.Invoke(isLive);
|
||||
}
|
||||
}
|
||||
|
||||
protected void Log(string message, Exception? ex = null) => OnLog?.Invoke(message, ex);
|
||||
|
||||
/// <summary>
|
||||
/// 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. 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 timeout.
|
||||
/// Otherwise the client competes with its own dying socket and can never win.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private TimeSpan BackoffDelay(int failures)
|
||||
{
|
||||
if (failures <= 0)
|
||||
{
|
||||
return TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
double seconds = Math.Min(60, Math.Pow(2, Math.Min(failures, 6)));
|
||||
|
||||
if (_rejection is not null)
|
||||
{
|
||||
seconds = Math.Max(seconds, ServerAuthTimeout.TotalSeconds * 1.5);
|
||||
}
|
||||
|
||||
return TimeSpan.FromSeconds(seconds + (Random.Shared.NextDouble() * 1.5));
|
||||
}
|
||||
|
||||
private void DisposeSocket()
|
||||
{
|
||||
ClientWebSocket? socket = Interlocked.Exchange(ref _socket, null);
|
||||
socket?.Dispose();
|
||||
}
|
||||
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
_cts?.Dispose();
|
||||
_sendGate.Dispose();
|
||||
DisposeSocket();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,39 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
SeedStrategyFile(Config.Run.StrategyPath);
|
||||
Ui.UiClock.Zone = Config.Ui.ResolveTimeZone(out _);
|
||||
|
||||
// --headless: no window, the same engine, the same information as text on the
|
||||
// console, commands from standard input. For a VPS or for a long unattended test.
|
||||
if (e.Args.Any(static a => a.Equals("--headless", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Config.Logging.Console = true;
|
||||
Log.Initialize(Config.Logging);
|
||||
ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
Thread worker = new(() =>
|
||||
{
|
||||
int code;
|
||||
try
|
||||
{
|
||||
code = Baskets.HeadlessRunner.RunAsync(Config, e.Args).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("headless: errore fatale", ex);
|
||||
code = 1;
|
||||
}
|
||||
|
||||
Dispatcher.Invoke(() => Shutdown(code));
|
||||
})
|
||||
{
|
||||
IsBackground = false,
|
||||
Name = "headless",
|
||||
};
|
||||
worker.Start();
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Initialize(Config.Logging);
|
||||
|
||||
// Created here rather than via StartupUri: the config must load first, and a
|
||||
@@ -53,6 +86,30 @@ public partial class App : Application
|
||||
window.Show();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the factory <c>strategy.json</c> beside the configuration the first time,
|
||||
/// like the configuration itself: it is the operator's file from then on.
|
||||
/// </summary>
|
||||
public static void SeedStrategyFile(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
File.WriteAllText(path + ".tmp", Core.Baskets.BasketStrategyConfig.DefaultJson);
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
SeedNote = (SeedNote is null ? string.Empty : SeedNote + " · ") + $"strategy.json di fabbrica creato in {path}";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
SeedNote = $"impossibile creare {path}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the configuration lives, and how it gets there the first time.
|
||||
/// <para>
|
||||
@@ -97,7 +154,7 @@ public partial class App : Application
|
||||
return target;
|
||||
}
|
||||
|
||||
/// <summary>What happened at first run, for the log; null when the file already existed.</summary>
|
||||
/// <summary>What happened at first run, for the log; null when the files already existed.</summary>
|
||||
public static string? SeedNote { get; private set; }
|
||||
|
||||
private static void OnDispatcherException(object sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
using Encelado.Bot.Engine;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>Calendar and sentiment features for one basket at one instant (§7).</summary>
|
||||
public sealed record BasketContextFeatures(
|
||||
int MinutesToNextHigh,
|
||||
int MinutesSinceLastHigh,
|
||||
double SurpriseLast,
|
||||
double NetSentimentDiff1h,
|
||||
double NetSentimentDiff4h,
|
||||
double NetSentimentDiff24h,
|
||||
double HawkishDiff,
|
||||
double RiskOff,
|
||||
int NewsCount,
|
||||
string NextEventLabel)
|
||||
{
|
||||
public static readonly BasketContextFeatures Unknown =
|
||||
new(int.MaxValue, int.MaxValue, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, 0, "—");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the engine asks for calendar and news features. Phase 3 fills this with the
|
||||
/// FairEconomy calendar and the RSS sentiment; until then it answers "unknown", which
|
||||
/// the decider treats as "no blackout, no sentiment" and the ledger records as null.
|
||||
/// </summary>
|
||||
public interface IContextProvider
|
||||
{
|
||||
/// <summary>Features for a basket whose two non-shared currencies are <paramref name="longCurrency"/> and <paramref name="shortCurrency"/>, plus the shared one.</summary>
|
||||
BasketContextFeatures For(string longCurrency, string shortCurrency, string commonCurrency, DateTime nowUtc);
|
||||
|
||||
/// <summary>Whether the weekly opening happened less than <paramref name="openDelayMinutes"/> ago.</summary>
|
||||
bool JustOpened(DateTime nowUtc, int openDelayMinutes);
|
||||
|
||||
/// <summary>The panel's view: sentiment rows, next events and status lines.</summary>
|
||||
ContextRow Row(DateTime nowUtc);
|
||||
|
||||
Task RefreshAsync(DateTime nowUtc, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>The provider before the feeds exist: everything unknown, nothing blocked.</summary>
|
||||
public sealed class EmptyContextProvider : IContextProvider
|
||||
{
|
||||
public BasketContextFeatures For(string longCurrency, string shortCurrency, string commonCurrency, DateTime nowUtc) => BasketContextFeatures.Unknown;
|
||||
|
||||
public bool JustOpened(DateTime nowUtc, int openDelayMinutes) =>
|
||||
nowUtc.DayOfWeek == DayOfWeek.Sunday && nowUtc.Hour >= 22 && (nowUtc - new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 22, 0, 0, DateTimeKind.Utc)).TotalMinutes < openDelayMinutes;
|
||||
|
||||
public ContextRow Row(DateTime nowUtc) => new([], [], "non disponibile", "non disponibile", "non disponibile", "feed non attivi", "feed non attivi");
|
||||
|
||||
public Task RefreshAsync(DateTime nowUtc, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Bot.Engine;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.News;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>One feed: where it lives, what it is, how often it may be asked.</summary>
|
||||
public sealed record FeedSource(string Name, Uri Url, string Kind, string Currency = "")
|
||||
{
|
||||
public string CacheName => Name.Replace(' ', '_').ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The free feeds of §7, fetched politely: an explicit User-Agent, one request per minute
|
||||
/// per source at most, <c>robots.txt</c> honoured, exponential backoff on errors, and a
|
||||
/// copy of the last good body on disk so a restart does not start blind.
|
||||
/// </summary>
|
||||
public sealed class FeedFetcher : IDisposable
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly string _cacheDir;
|
||||
private readonly Dictionary<string, DateTime> _lastRequest = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _failures = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, (DateTime At, bool Allowed)> _robots = new(StringComparer.Ordinal);
|
||||
|
||||
public FeedFetcher(string cacheDir, string userAgent)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDir);
|
||||
_cacheDir = cacheDir;
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
_client = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
|
||||
_client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
_client.DefaultRequestHeaders.Accept.ParseAdd("application/rss+xml, application/xml, text/xml, application/json;q=0.9, */*;q=0.5");
|
||||
}
|
||||
|
||||
public TimeSpan MinInterval { get; init; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>Fetches the feed if its interval has passed; returns the body (fresh or cached) or null.</summary>
|
||||
public async Task<(string? Body, bool Fresh)> FetchAsync(FeedSource source, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
string cachePath = Path.Combine(_cacheDir, source.CacheName + (source.Kind == "calendar-json" ? ".json" : ".xml"));
|
||||
|
||||
if (_lastRequest.TryGetValue(source.Name, out DateTime last))
|
||||
{
|
||||
int failures = _failures.GetValueOrDefault(source.Name);
|
||||
TimeSpan wait = failures == 0 ? MinInterval : TimeSpan.FromMinutes(Math.Min(120, 2 << Math.Min(6, failures)));
|
||||
if (nowUtc - last < wait)
|
||||
{
|
||||
return (ReadCache(cachePath), false);
|
||||
}
|
||||
}
|
||||
|
||||
_lastRequest[source.Name] = nowUtc;
|
||||
|
||||
if (!await AllowedByRobotsAsync(source.Url, ct).ConfigureAwait(false))
|
||||
{
|
||||
Log.Warn($"feed {source.Name}: robots.txt non consente {source.Url.AbsolutePath}; uso solo la cache");
|
||||
_failures[source.Name] = 10;
|
||||
return (ReadCache(cachePath), false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _client.GetAsync(source.Url, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
int n = _failures.GetValueOrDefault(source.Name) + 1;
|
||||
_failures[source.Name] = n;
|
||||
|
||||
// The first two failures are worth a warning; after that the source is
|
||||
// backing off (up to two hours) and the line would only repeat itself.
|
||||
if (n <= 2)
|
||||
{
|
||||
Log.Warn($"feed {source.Name}: HTTP {(int)response.StatusCode}{(n == 2 ? " (secondo errore: ritento con attese crescenti, uso la cache)" : string.Empty)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug($"feed {source.Name}: HTTP {(int)response.StatusCode} (errore {n}, cache)");
|
||||
}
|
||||
return (ReadCache(cachePath), false);
|
||||
}
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
_failures[source.Name] = 0;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(cachePath + ".tmp", body, new UTF8Encoding(false));
|
||||
File.Move(cachePath + ".tmp", cachePath, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"feed {source.Name}: cache non scritta ({ex.Message})");
|
||||
}
|
||||
|
||||
return (body, true);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
_failures[source.Name] = _failures.GetValueOrDefault(source.Name) + 1;
|
||||
Log.Warn($"feed {source.Name}: {ex.Message}");
|
||||
return (ReadCache(cachePath), false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadCache(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? File.ReadAllText(path) : null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A minimal robots.txt reader: the <c>User-agent: *</c> group's Disallow lines, cached a day per host.</summary>
|
||||
private async Task<bool> AllowedByRobotsAsync(Uri url, CancellationToken ct)
|
||||
{
|
||||
string host = url.GetLeftPart(UriPartial.Authority);
|
||||
if (_robots.TryGetValue(host + url.AbsolutePath, out (DateTime At, bool Allowed) cached) && DateTime.UtcNow - cached.At < TimeSpan.FromDays(1))
|
||||
{
|
||||
return cached.Allowed;
|
||||
}
|
||||
|
||||
bool allowed = true;
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _client.GetAsync(new Uri(host + "/robots.txt"), ct).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string text = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
allowed = RobotsAllows(text, url.AbsolutePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
// No robots.txt reachable: assume allowed, like every crawler does.
|
||||
}
|
||||
|
||||
_robots[host + url.AbsolutePath] = (DateTime.UtcNow, allowed);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/// <summary>Applies the <c>User-agent: *</c> group: the longest matching Allow/Disallow wins.</summary>
|
||||
public static bool RobotsAllows(string robots, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(robots);
|
||||
bool inStar = false;
|
||||
string? bestRule = null;
|
||||
bool bestAllow = true;
|
||||
foreach (string raw in robots.Split('\n'))
|
||||
{
|
||||
string line = raw.Split('#')[0].Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int colon = line.IndexOf(':', StringComparison.Ordinal);
|
||||
if (colon < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = line[..colon].Trim().ToLowerInvariant();
|
||||
string value = line[(colon + 1)..].Trim();
|
||||
if (key == "user-agent")
|
||||
{
|
||||
inStar = value == "*";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inStar || value.Length == 0 || (key != "disallow" && key != "allow"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string pattern = value.TrimEnd('*');
|
||||
if (path.StartsWith(pattern, StringComparison.Ordinal) && (bestRule is null || pattern.Length > bestRule.Length))
|
||||
{
|
||||
bestRule = pattern;
|
||||
bestAllow = key == "allow";
|
||||
}
|
||||
}
|
||||
|
||||
return bestAllow;
|
||||
}
|
||||
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The calendar and the news, kept on disk (append-only, deduplicated) and in memory,
|
||||
/// and turned into the features the decider and the panel read.
|
||||
/// </summary>
|
||||
public sealed class FeedContextProvider : IContextProvider, IDisposable
|
||||
{
|
||||
private readonly FeedFetcher _fetcher;
|
||||
private readonly string _calendarDir;
|
||||
private readonly string _newsDir;
|
||||
private readonly List<FeedSource> _sources;
|
||||
private readonly SentimentEngine _sentiment = new();
|
||||
private readonly Dictionary<string, CalendarEvent> _events = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _newsHashes = new(StringComparer.Ordinal);
|
||||
private readonly Lock _gate = new();
|
||||
private DateTime _calendarUpdatedUtc = DateTime.MinValue;
|
||||
private DateTime _newsUpdatedUtc = DateTime.MinValue;
|
||||
private int _newsToday;
|
||||
private string _calendarState = "non ancora letto";
|
||||
private string _newsState = "non ancora letti";
|
||||
|
||||
public FeedContextProvider(string dataDir, string userAgent)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDir);
|
||||
_calendarDir = Path.Combine(dataDir, "calendar");
|
||||
_newsDir = Path.Combine(dataDir, "news");
|
||||
Directory.CreateDirectory(_calendarDir);
|
||||
Directory.CreateDirectory(_newsDir);
|
||||
_fetcher = new FeedFetcher(Path.Combine(dataDir, "cache"), userAgent);
|
||||
_sources = DefaultSources();
|
||||
LoadFromDisk();
|
||||
}
|
||||
|
||||
/// <summary>The feeds verified on 2026-09-16 (see docs/DATA_SOURCES.md). SNB and RBNZ have no reachable feed and are covered by Google News queries.</summary>
|
||||
public static List<FeedSource> DefaultSources() =>
|
||||
[
|
||||
new("FairEconomy calendar", new Uri("https://nfs.faireconomy.media/ff_calendar_thisweek.json"), "calendar-json"),
|
||||
new("FXStreet", new Uri("https://www.fxstreet.com/rss/news"), "rss"),
|
||||
new("ForexLive", new Uri("https://www.forexlive.com/feed/"), "rss"),
|
||||
new("Fed", new Uri("https://www.federalreserve.gov/feeds/press_all.xml"), "rss", "USD"),
|
||||
new("ECB", new Uri("https://www.ecb.europa.eu/rss/press.html"), "rss", "EUR"),
|
||||
new("BoE", new Uri("https://www.bankofengland.co.uk/rss/news"), "rss", "GBP"),
|
||||
new("RBA", new Uri("https://www.rba.gov.au/rss/rss-cb-media-releases.xml"), "rss", "AUD"),
|
||||
new("BoC", new Uri("https://www.bankofcanada.ca/content_type/press-releases/feed/"), "rss", "CAD"),
|
||||
new("Google News EURUSD", new Uri("https://news.google.com/rss/search?q=EURUSD&hl=en-US&gl=US&ceid=US:en"), "rss"),
|
||||
new("Google News SNB", new Uri("https://news.google.com/rss/search?q=%22Swiss+National+Bank%22&hl=en-US&gl=US&ceid=US:en"), "rss", "CHF"),
|
||||
new("Google News RBNZ", new Uri("https://news.google.com/rss/search?q=RBNZ&hl=en-US&gl=US&ceid=US:en"), "rss", "NZD"),
|
||||
new("Google News RBA", new Uri("https://news.google.com/rss/search?q=%22Reserve+Bank+of+Australia%22&hl=en-US&gl=US&ceid=US:en"), "rss", "AUD"),
|
||||
new("Google News forex", new Uri("https://news.google.com/rss/search?q=forex+dollar&hl=en-US&gl=US&ceid=US:en"), "rss"),
|
||||
];
|
||||
|
||||
public IReadOnlyList<CalendarEvent> Events
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return [.. _events.Values.OrderBy(static e => e.TimeUtc)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshAsync(DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
foreach (FeedSource source in _sources)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
(string? body, bool fresh) = await _fetcher.FetchAsync(source, nowUtc, ct).ConfigureAwait(false);
|
||||
if (body is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (source.Kind == "calendar-json")
|
||||
{
|
||||
int added = AddEvents(CalendarParser.ParseJson(body));
|
||||
if (fresh)
|
||||
{
|
||||
_calendarUpdatedUtc = nowUtc;
|
||||
_calendarState = string.Create(CultureInfo.InvariantCulture, $"aggiornato {nowUtc:HH:mm} UTC, {_events.Count} eventi in memoria, {added} nuovi");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
List<NewsItem> items = RssParser.Parse(body, source.Name);
|
||||
int added = AddNews(items, source, nowUtc);
|
||||
if (fresh)
|
||||
{
|
||||
_newsUpdatedUtc = nowUtc;
|
||||
_newsToday += added;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Xml.XmlException or JsonException or FormatException)
|
||||
{
|
||||
Log.Warn($"feed {source.Name}: contenuto non leggibile ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
_sentiment.Forget(nowUtc);
|
||||
_newsState = string.Create(CultureInfo.InvariantCulture, $"{_sentiment.Count} notizie nelle ultime 30 h da {_sources.Count - 1} feed, ultimo aggiornamento {(_newsUpdatedUtc == DateTime.MinValue ? "—" : _newsUpdatedUtc.ToString("HH:mm", CultureInfo.InvariantCulture) + " UTC")}");
|
||||
}
|
||||
|
||||
private int AddEvents(List<CalendarEvent> events)
|
||||
{
|
||||
int added = 0;
|
||||
List<CalendarEvent> fresh = [];
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (CalendarEvent e in events)
|
||||
{
|
||||
if (_events.TryGetValue(e.Key, out CalendarEvent? old))
|
||||
{
|
||||
// Actuals arrive after the release: keep the newest picture, same key.
|
||||
if (old.Actual != e.Actual || old.Forecast != e.Forecast)
|
||||
{
|
||||
_events[e.Key] = e;
|
||||
fresh.Add(e);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
_events[e.Key] = e;
|
||||
fresh.Add(e);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
AppendJsonl(Path.Combine(_calendarDir, "events.jsonl"), fresh.Select(static e => SerializeEvent(e)));
|
||||
return added;
|
||||
}
|
||||
|
||||
private int AddNews(List<NewsItem> items, FeedSource source, DateTime nowUtc)
|
||||
{
|
||||
List<string> lines = [];
|
||||
int added = 0;
|
||||
foreach (NewsItem item in items)
|
||||
{
|
||||
if (item.PublishedUtc > nowUtc.AddHours(1) || nowUtc - item.PublishedUtc > TimeSpan.FromDays(3))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
NewsItem tagged = source.Currency.Length > 0 && !item.Summary.Contains(source.Currency, StringComparison.Ordinal)
|
||||
? item with { Summary = item.Summary + " " + source.Currency }
|
||||
: item;
|
||||
|
||||
if (_sentiment.Add(tagged) is { } scored && _newsHashes.Add(tagged.Hash))
|
||||
{
|
||||
added++;
|
||||
lines.Add(SerializeNews(scored));
|
||||
}
|
||||
}
|
||||
|
||||
AppendJsonl(Path.Combine(_newsDir, $"news_{nowUtc:yyyyMM}.jsonl"), lines);
|
||||
return added;
|
||||
}
|
||||
|
||||
private static void AppendJsonl(string path, IEnumerable<string> lines)
|
||||
{
|
||||
try
|
||||
{
|
||||
using StreamWriter w = new(path, append: true, new UTF8Encoding(false));
|
||||
foreach (string line in lines)
|
||||
{
|
||||
w.WriteLine(line);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"{Path.GetFileName(path)} non scritto: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeEvent(CalendarEvent e)
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("title", e.Title);
|
||||
w.WriteString("country", e.Currency);
|
||||
w.WriteString("date", e.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("impact", e.Impact.ToString());
|
||||
w.WriteString("forecast", e.Forecast);
|
||||
w.WriteString("previous", e.Previous);
|
||||
w.WriteString("actual", e.Actual);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
private static string SerializeNews(ScoredItem s)
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("hash", s.Item.Hash);
|
||||
w.WriteString("published", s.Item.PublishedUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("source", s.Item.Source);
|
||||
w.WriteString("title", s.Item.Title);
|
||||
w.WriteString("summary", s.Item.Summary.Length > 600 ? s.Item.Summary[..600] : s.Item.Summary);
|
||||
w.WriteString("link", s.Item.Link);
|
||||
w.WriteStartArray("currencies");
|
||||
foreach (string c in s.Currencies)
|
||||
{
|
||||
w.WriteStringValue(c);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WriteStartObject("scores");
|
||||
w.WriteNumber("net", Math.Round(s.Net, 4));
|
||||
w.WriteNumber("hawkish", Math.Round(s.Hawkish, 4));
|
||||
w.WriteNumber("riskOff", Math.Round(s.RiskOff, 4));
|
||||
w.WriteEndObject();
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Restores this week's events and the last 30 hours of news from the append-only files.</summary>
|
||||
private void LoadFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
string eventsPath = Path.Combine(_calendarDir, "events.jsonl");
|
||||
if (File.Exists(eventsPath))
|
||||
{
|
||||
foreach (string line in File.ReadLines(eventsPath))
|
||||
{
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(line);
|
||||
JsonElement r = doc.RootElement;
|
||||
DateTime t = DateTime.Parse(r.GetProperty("date").GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
if (DateTime.UtcNow - t > TimeSpan.FromDays(14))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CalendarEvent e = new(r.GetProperty("title").GetString() ?? string.Empty, r.GetProperty("country").GetString() ?? string.Empty, t,
|
||||
Enum.TryParse(r.GetProperty("impact").GetString(), out EventImpact impact) ? impact : EventImpact.Unknown,
|
||||
r.GetProperty("forecast").GetString() ?? string.Empty, r.GetProperty("previous").GetString() ?? string.Empty, r.GetProperty("actual").GetString() ?? string.Empty);
|
||||
_events[e.Key] = e;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
foreach (string month in new[] { now.ToString("yyyyMM", CultureInfo.InvariantCulture), now.AddMonths(-1).ToString("yyyyMM", CultureInfo.InvariantCulture) })
|
||||
{
|
||||
string newsPath = Path.Combine(_newsDir, $"news_{month}.jsonl");
|
||||
if (!File.Exists(newsPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string line in File.ReadLines(newsPath))
|
||||
{
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(line);
|
||||
JsonElement r = doc.RootElement;
|
||||
DateTime t = DateTime.Parse(r.GetProperty("published").GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
if (now - t > TimeSpan.FromHours(30))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
NewsItem item = new(t, r.GetProperty("source").GetString() ?? string.Empty, r.GetProperty("title").GetString() ?? string.Empty, r.GetProperty("summary").GetString() ?? string.Empty, r.GetProperty("link").GetString() ?? string.Empty);
|
||||
_newsHashes.Add(item.Hash);
|
||||
_sentiment.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
_calendarState = $"{_events.Count} eventi ripresi da disco";
|
||||
_newsState = $"{_sentiment.Count} notizie riprese da disco";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException or FormatException)
|
||||
{
|
||||
Log.Warn($"feed: archivio su disco non leggibile ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IContextProvider
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public BasketContextFeatures For(string longCurrency, string shortCurrency, string commonCurrency, DateTime nowUtc)
|
||||
{
|
||||
HashSet<string> currencies = new(StringComparer.Ordinal) { longCurrency, shortCurrency, commonCurrency };
|
||||
IReadOnlyList<CalendarEvent> events = Events;
|
||||
int toNext = CalendarFeatures.MinutesToNextHigh(events, currencies, nowUtc, out CalendarEvent? next);
|
||||
int sinceLast = CalendarFeatures.MinutesSinceLastHigh(events, currencies, nowUtc, out CalendarEvent? last);
|
||||
double surprise = last?.Surprise ?? double.NaN;
|
||||
|
||||
CurrencySentiment l = _sentiment.For(longCurrency, nowUtc);
|
||||
CurrencySentiment s = _sentiment.For(shortCurrency, nowUtc);
|
||||
string label = next is null ? "nessun evento ad alto impatto noto"
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{next.Currency} {next.TimeUtc:HH:mm} {next.Title} ({(toNext < 90 ? $"fra {toNext} min" : $"fra {toNext / 60.0:0.0} h")})");
|
||||
|
||||
return new BasketContextFeatures(toNext, sinceLast, surprise,
|
||||
l.Net1h - s.Net1h, l.Net4h - s.Net4h, l.Net24h - s.Net24h, l.Hawkish4h - s.Hawkish4h, _sentiment.RiskOff(nowUtc), l.Count24h + s.Count24h, label);
|
||||
}
|
||||
|
||||
public bool JustOpened(DateTime nowUtc, int openDelayMinutes)
|
||||
{
|
||||
if (nowUtc.DayOfWeek != DayOfWeek.Sunday || nowUtc.Hour < 21)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTime open = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, 21, 0, 0, DateTimeKind.Utc);
|
||||
return (nowUtc - open).TotalMinutes < openDelayMinutes + 60;
|
||||
}
|
||||
|
||||
public ContextRow Row(DateTime nowUtc)
|
||||
{
|
||||
List<SentimentRow> rows = [];
|
||||
foreach (string c in new[] { "USD", "EUR", "CHF", "AUD", "NZD", "CAD", "GBP", "JPY" })
|
||||
{
|
||||
CurrencySentiment s = _sentiment.For(c, nowUtc);
|
||||
rows.Add(new SentimentRow(c, s.Net1h, s.Net4h, s.Net24h, s.Hawkish4h, s.RiskOff4h, s.Count24h));
|
||||
}
|
||||
|
||||
List<CalendarRow> next = [.. Events.Where(e => e.Impact == EventImpact.High && e.TimeUtc >= nowUtc.AddMinutes(-30)).OrderBy(static e => e.TimeUtc).Take(5)
|
||||
.Select(static e => new CalendarRow(e.TimeUtc, e.Currency, e.Title, e.Impact.ToString(), e.Forecast, e.Previous))];
|
||||
|
||||
return new ContextRow(rows, next, "non disponibile", "non disponibile", "non disponibile", _calendarState, _newsState);
|
||||
}
|
||||
|
||||
public void Dispose() => _fetcher.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Engine;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Baskets;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>
|
||||
/// The bot without a window: the same supervisor and engine, the log on the console,
|
||||
/// a status line every minute and commands from standard input. For a VPS, a service,
|
||||
/// or a long unattended test.
|
||||
/// <para>
|
||||
/// Commands: <c>status</c>, <c>close <basket></c>, <c>kill</c>, <c>preset <nome></c>,
|
||||
/// <c>reset <motivazione></c>, <c>stop</c>. Arguments: <c>--headless</c>,
|
||||
/// <c>--confirm-live "CONFERMO LIVE"</c>, <c>--minutes N</c> (stop by itself after N minutes).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class HeadlessRunner
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool AttachConsole(int processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool AllocConsole();
|
||||
|
||||
private const int AttachParentProcess = -1;
|
||||
|
||||
public static async Task<int> RunAsync(BotConfig config, string[] args)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(args);
|
||||
|
||||
if (OperatingSystem.IsWindows() && !AttachConsole(AttachParentProcess))
|
||||
{
|
||||
AllocConsole();
|
||||
}
|
||||
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Encelado headless — configurazione {App.ConfigPath}");
|
||||
|
||||
foreach (string warning in App.ConfigWarnings)
|
||||
{
|
||||
Log.Warn($"configurazione: {warning}");
|
||||
}
|
||||
|
||||
if (App.SeedNote is { } seeded)
|
||||
{
|
||||
Log.Warn(seeded);
|
||||
}
|
||||
|
||||
// Keys: environment, then the encrypted store. Never asked for on the console.
|
||||
if (!EtoroKeyStore.Resolve(config, out string origin))
|
||||
{
|
||||
Log.Error("nessuna chiave eToro: inseriscile una volta dalla finestra (avvio senza --headless) oppure con ETORO_API_KEY e ETORO_USER_KEY", null);
|
||||
return 3;
|
||||
}
|
||||
|
||||
Log.Info($"chiavi eToro: {origin}");
|
||||
|
||||
ExecutionMode mode = config.Run.Mode;
|
||||
if (mode.IsLive() && !HasLivePhrase(args))
|
||||
{
|
||||
Log.Error($"la modalità Live richiede l'argomento --confirm-live \"{Ui.PromptWindow.LivePhrase}\"", null);
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (mode.IsLive())
|
||||
{
|
||||
Log.Warn("avvio in Live sul conto REALE confermato da riga di comando");
|
||||
}
|
||||
|
||||
int minutes = 0;
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals("--minutes", StringComparison.OrdinalIgnoreCase) && int.TryParse(args[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int m))
|
||||
{
|
||||
minutes = m;
|
||||
}
|
||||
}
|
||||
|
||||
await using BotSupervisor supervisor = new(config) { StartConfirmed = true };
|
||||
using CancellationTokenSource stopping = new();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
Log.Info("Ctrl+C: arresto");
|
||||
stopping.Cancel();
|
||||
};
|
||||
|
||||
CommandResult started = await supervisor.StartAsync().ConfigureAwait(false);
|
||||
if (!started.Ok)
|
||||
{
|
||||
Log.Error($"avvio fallito: {started.Message}", null);
|
||||
return 5;
|
||||
}
|
||||
|
||||
Log.Info($"bot avviato in {mode}. Comandi: status, close <basket>, kill, preset <nome>, reset <motivazione>, stop");
|
||||
if (minutes > 0)
|
||||
{
|
||||
Log.Info($"arresto automatico fra {minutes} minuti");
|
||||
stopping.CancelAfter(TimeSpan.FromMinutes(minutes));
|
||||
}
|
||||
|
||||
Task input = Task.Run(() => ReadCommandsAsync(supervisor, stopping), stopping.Token);
|
||||
DateTime lastStatus = DateTime.MinValue;
|
||||
|
||||
try
|
||||
{
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(1000, stopping.Token).ConfigureAwait(false);
|
||||
if (supervisor.State is BotState.Faulted or BotState.Stopped)
|
||||
{
|
||||
Log.Warn("il motore si è fermato");
|
||||
break;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - lastStatus >= TimeSpan.FromSeconds(config.Run.StatusSeconds))
|
||||
{
|
||||
lastStatus = DateTime.UtcNow;
|
||||
PrintStatus(supervisor.Snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Stop requested.
|
||||
}
|
||||
|
||||
await supervisor.StopAsync().ConfigureAwait(false);
|
||||
await Log.FlushAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool HasLivePhrase(string[] args)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals("--confirm-live", StringComparison.OrdinalIgnoreCase) && args[i + 1] == Ui.PromptWindow.LivePhrase)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task ReadCommandsAsync(BotSupervisor supervisor, CancellationTokenSource stopping)
|
||||
{
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
string? line;
|
||||
try
|
||||
{
|
||||
line = await Console.In.ReadLineAsync(stopping.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (line is null)
|
||||
{
|
||||
// No console attached (a service): keep running until cancelled.
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stopping.Token).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
string[] parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string arg = parts.Length > 1 ? parts[1].Trim() : string.Empty;
|
||||
CommandResult result;
|
||||
switch (parts[0].ToLowerInvariant())
|
||||
{
|
||||
case "stop" or "quit" or "exit":
|
||||
stopping.Cancel();
|
||||
return;
|
||||
case "status":
|
||||
PrintStatus(supervisor.Snapshot());
|
||||
continue;
|
||||
case "close":
|
||||
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Close, arg, "chiusura manuale da console"), CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case "kill":
|
||||
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, string.Empty, "kill-switch da console"), CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case "preset":
|
||||
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.SetPreset, arg, "cambio preset da console"), CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case "reset":
|
||||
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.ResetEquityStop, string.Empty, arg), CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
default:
|
||||
Console.WriteLine("comandi: status, close <basket>, kill, preset <nome>, reset <motivazione>, stop");
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine((result.Ok ? "ok: " : "NO: ") + result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintStatus(BotSnapshot s)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
|
||||
$"── {DateTime.UtcNow:HH:mm:ss} UTC · {s.Mode} · preset {s.Preset} · API {s.ApiState} {(double.IsFinite(s.ApiLatencyMs) ? s.ApiLatencyMs.ToString("0") + " ms" : "—")} · skew {s.ClockSkewSeconds:+0.0;-0.0} s"));
|
||||
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
|
||||
$" BALANCE {s.Balance:N2} EQUITY {s.Equity:N2} TOTAL {s.OpenPnl:+0.00;-0.00} ({s.OpenPnlPct:P2}) TODAY {s.TodayPnl:+0.00;-0.00} ({s.TodayPnlPct:P2}) DD {s.DrawdownPct:P2} basket {s.OpenBaskets}/{s.MaxBaskets}") +
|
||||
(s.Halted ? $" BLOCCO: {s.HaltReason}" : string.Empty) +
|
||||
(s.EntriesBlockedReason is { Length: > 0 } blocked ? $" entrate bloccate: {blocked}" : string.Empty));
|
||||
Console.WriteLine($" {"Coppie",-14} {"(n)",3} {"$",9} {"%",7} {"Pips",6} {"TP",3} {"ρ",6} {"z",6} {"HL",4} {"Costo",5} {"p_ML",6} Stato");
|
||||
foreach (BasketRow b in s.Baskets)
|
||||
{
|
||||
Console.WriteLine($" {b.Name,-14} {b.OpenLegs,3} {b.PnlDisplay,9} {b.PnlPctDisplay,7} {b.PipsDisplay,6} {b.TpDisplay,3} {b.RhoDisplay,6} {b.ZDisplay,6} {b.HalfLifeDisplay,4} {b.CostDisplay,5} {b.PMlDisplay,6} {(b.Enabled ? b.State : "OFF")} {b.Tooltip}");
|
||||
}
|
||||
|
||||
if (s.Quotes.Count > 0)
|
||||
{
|
||||
Console.WriteLine(" " + string.Join(" ", s.Quotes.Select(static q => $"{q.Symbol} {q.BidDisplay}/{q.AskDisplay} ({q.SpreadDisplay})")));
|
||||
}
|
||||
|
||||
if (s.Context is { } c)
|
||||
{
|
||||
Console.WriteLine($" vol: {c.VolForecast} · ML: {c.MlState} · bandit: {c.BanditProposal} · calendario: {c.CalendarState} · notizie: {c.NewsState}");
|
||||
foreach (CalendarRow ev in c.NextEvents.Take(5))
|
||||
{
|
||||
Console.WriteLine($" evento {ev.TimeLocal} {ev.Currency} {ev.Title} ({ev.InMinutes})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Baskets;
|
||||
using Encelado.Core.Baskets.Learning;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>
|
||||
/// The learning stack at runtime (§8): the shadow logistic model that scores every
|
||||
/// entry and learns from every close, the challenger MLP, the preset bandit, the
|
||||
/// activation state, and the weekly cycle that rebuilds everything from the ledger and
|
||||
/// writes the knowledge base. Nothing here changes a live parameter: proposals go to
|
||||
/// <c>knowledge/proposals.csv</c>.
|
||||
/// </summary>
|
||||
public sealed class LearningState : IDisposable
|
||||
{
|
||||
private readonly string _modelsDir;
|
||||
private readonly string _knowledgeDir;
|
||||
private readonly Ledger _ledger;
|
||||
private readonly BasketStrategyConfig _cfg;
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Dictionary<string, double[]> _entryFeatures = new(StringComparer.Ordinal);
|
||||
private readonly List<(double P, int Label)> _recent = [];
|
||||
private readonly List<int> _lastOutcomes = [];
|
||||
private OnlineLogistic _logistic;
|
||||
private SmallMlp? _challenger;
|
||||
private ThompsonBandit _bandit;
|
||||
private bool _active;
|
||||
private string _champion = "logistica";
|
||||
private int _version;
|
||||
private DateTime _lastCycleUtc = DateTime.MinValue;
|
||||
private string _lastReport = "nessun ciclo eseguito";
|
||||
private string _banditProposal = "—";
|
||||
private double _rollingAuc = double.NaN;
|
||||
|
||||
public LearningState(string dataDir, string knowledgeDir, Ledger ledger, BasketStrategyConfig cfg)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDir);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(knowledgeDir);
|
||||
ArgumentNullException.ThrowIfNull(ledger);
|
||||
ArgumentNullException.ThrowIfNull(cfg);
|
||||
_modelsDir = Path.Combine(dataDir, "models");
|
||||
_knowledgeDir = knowledgeDir;
|
||||
_ledger = ledger;
|
||||
_cfg = cfg;
|
||||
Directory.CreateDirectory(_modelsDir);
|
||||
Directory.CreateDirectory(knowledgeDir);
|
||||
_logistic = new OnlineLogistic(LearningFeatures.Count);
|
||||
_bandit = new ThompsonBandit(3, 42);
|
||||
Load();
|
||||
SeedOutcomesFromLedger();
|
||||
}
|
||||
|
||||
public bool Active => _active;
|
||||
|
||||
public int Version => _version;
|
||||
|
||||
public DateTime LastCycleUtc => _lastCycleUtc;
|
||||
|
||||
/// <summary>Mean of the last ten labels, or NaN before there are any.</summary>
|
||||
public double LastOutcomes
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _lastOutcomes.Count == 0 ? double.NaN : _lastOutcomes.TakeLast(10).Average();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The champion's probability for a candidate entry. NaN before any basket has been seen.</summary>
|
||||
public double Predict(double[] features)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(features);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_logistic.Seen == 0 && _challenger is null)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
return _champion == "mlp16" && _challenger is not null ? _challenger.Predict(features) : _logistic.Predict(features);
|
||||
}
|
||||
}
|
||||
|
||||
public void RememberEntry(string basketId, double[] features)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_entryFeatures[basketId] = features;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A basket closed: the shadow model learns, the rolling AUC is refreshed, the bandit is rewarded.</summary>
|
||||
public void Observe(string basketId, int label, int volContext, PresetName preset)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lastOutcomes.Add(label);
|
||||
if (_lastOutcomes.Count > 100)
|
||||
{
|
||||
_lastOutcomes.RemoveAt(0);
|
||||
}
|
||||
|
||||
_bandit.Reward(volContext, preset, label == 1);
|
||||
if (!_entryFeatures.Remove(basketId, out double[]? features))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double p = _logistic.Predict(features);
|
||||
_recent.Add((p, label));
|
||||
if (_recent.Count > 200)
|
||||
{
|
||||
_recent.RemoveAt(0);
|
||||
}
|
||||
|
||||
_logistic.Update(features, label);
|
||||
_challenger?.Update(features, label);
|
||||
|
||||
_rollingAuc = ModelEvaluator.RollingAuc(_recent, 100);
|
||||
if (_active && double.IsFinite(_rollingAuc) && _rollingAuc < ModelEvaluator.DeactivationAuc && _recent.Count >= 100)
|
||||
{
|
||||
_active = false;
|
||||
Log.Warn(string.Create(CultureInfo.InvariantCulture, $"meta-modello: AUC mobile {_rollingAuc:F3} sotto {ModelEvaluator.DeactivationAuc:F2}: torna in shadow mode"));
|
||||
AppendRegistry("models_registry.csv", "versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione",
|
||||
string.Create(CultureInfo.InvariantCulture, $"v{_version};{DateTime.UtcNow:O};{_champion};{_logistic.Seen};{_rollingAuc:F3};;;shadow;AUC mobile su 100 basket sotto {ModelEvaluator.DeactivationAuc:F2}"));
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The bandit's proposal for the volatility context, as text and as a preset.</summary>
|
||||
public (PresetName Preset, string Text) Propose(int volContext)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
(PresetName preset, bool explored, double[] means) = _bandit.Propose(volContext);
|
||||
_banditProposal = string.Create(CultureInfo.InvariantCulture,
|
||||
$"propone {preset.ToString().ToUpperInvariant()}{(explored ? " (esplorazione)" : string.Empty)} nel terzile di vol {volContext} — medie CONS {means[0]:0.00}, MOD {means[1]:0.00}, AGG {means[2]:0.00}; {_bandit.Choices} scelte");
|
||||
return (preset, _banditProposal);
|
||||
}
|
||||
}
|
||||
|
||||
public string BanditText => _banditProposal;
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
string auc = double.IsFinite(_rollingAuc) ? _rollingAuc.ToString("0.000", CultureInfo.InvariantCulture) : "n/d";
|
||||
return $"{_champion} v{_version} {(_active ? "ATTIVA (gate)" : "in ombra")}: {_logistic.Seen} basket visti, AUC mobile {auc}; {_lastReport}";
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Weekly cycle (§8.7)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public bool CycleDue(DateTime nowUtc) => nowUtc - _lastCycleUtc >= TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Rebuilds the dataset from the ledger, retrains and evaluates walk-forward, refreshes the knowledge base.</summary>
|
||||
public void RunCycle(DateTime nowUtc)
|
||||
{
|
||||
List<LabelledBasket> rows = BuildDataset();
|
||||
lock (_gate)
|
||||
{
|
||||
_lastCycleUtc = nowUtc;
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
_lastReport = "ledger senza basket chiusi: niente da addestrare";
|
||||
Save();
|
||||
return;
|
||||
}
|
||||
|
||||
(ModelReport l1, OnlineLogistic retrained) = ModelEvaluator.EvaluateLogistic(rows, _cfg.MlMinProbability);
|
||||
(ModelReport l2, SmallMlp? mlp) = ModelEvaluator.EvaluateMlp(rows, _cfg.MlMinProbability);
|
||||
|
||||
_version++;
|
||||
_logistic = retrained;
|
||||
_challenger = mlp;
|
||||
bool wasActive = _active;
|
||||
_active = l1.PassesActivation;
|
||||
_lastReport = l1.Summary;
|
||||
|
||||
File.WriteAllText(Path.Combine(_modelsDir, $"logreg_v{_version}.json"), Wrap(retrained.ToJson(), rows, nowUtc));
|
||||
if (mlp is not null)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_modelsDir, $"mlp_v{_version}.json"), Wrap(mlp.ToJson(), rows, nowUtc));
|
||||
}
|
||||
|
||||
AppendRegistry("models_registry.csv", "versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione",
|
||||
string.Create(CultureInfo.InvariantCulture, $"v{_version};{nowUtc:O};logistica;{rows.Count};{l1.Auc:F3};{l1.Brier:F3};{l1.LogLoss:F3};{(_active ? "champion attivo" : "champion in ombra")};{l1.Motivazione}"));
|
||||
if (mlp is not null)
|
||||
{
|
||||
bool challengerWins = double.IsFinite(l2.Auc) && double.IsFinite(l1.Auc) && l2.Auc >= l1.Auc + 0.01;
|
||||
AppendRegistry("models_registry.csv", "versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione",
|
||||
string.Create(CultureInfo.InvariantCulture, $"v{_version};{nowUtc:O};mlp16;{rows.Count};{l2.Auc:F3};{l2.Brier:F3};{l2.LogLoss:F3};challenger;{(challengerWins ? "batte la logistica per AUC di almeno 0,01: promozione solo con il P&L del forward test" : l2.Motivazione)}"));
|
||||
}
|
||||
|
||||
WriteCalibration(rows);
|
||||
WriteInsights(nowUtc, rows, l1, l2);
|
||||
WriteProposals(nowUtc, rows, l1);
|
||||
if (_active != wasActive)
|
||||
{
|
||||
Log.Warn(_active ? "meta-modello ATTIVATO come gate degli ingressi" : "meta-modello in shadow mode");
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
Log.Info($"ciclo settimanale di apprendimento eseguito su {rows.Count} basket: {_lastReport}");
|
||||
}
|
||||
|
||||
private static string Wrap(string modelJson, List<LabelledBasket> rows, DateTime nowUtc)
|
||||
{
|
||||
using JsonDocument model = JsonDocument.Parse(modelJson);
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("trained_on_until", rows.Count > 0 ? rows.Max(static r => r.ClosedUtc).ToString("O", CultureInfo.InvariantCulture) : string.Empty);
|
||||
w.WriteString("trained_at", nowUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteNumber("rows", rows.Count);
|
||||
w.WriteString("dataset_hash", DatasetHash(rows));
|
||||
w.WriteStartArray("features");
|
||||
foreach (string f in LearningFeatures.Names)
|
||||
{
|
||||
w.WriteStringValue(f);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WritePropertyName("model");
|
||||
model.RootElement.WriteTo(w);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
private static string DatasetHash(List<LabelledBasket> rows)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
foreach (LabelledBasket r in rows)
|
||||
{
|
||||
sb.Append(r.BasketId).Append(':').Append(r.Label).Append(';');
|
||||
}
|
||||
|
||||
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())))[..16].ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Joins the <c>ingresso</c> rows of the decisions ledger (features at entry) with the closed baskets (labels).</summary>
|
||||
public List<LabelledBasket> BuildDataset()
|
||||
{
|
||||
Dictionary<string, BasketOutcomeRow> outcomes = new(StringComparer.Ordinal);
|
||||
foreach (BasketOutcomeRow r in _ledger.ReadBaskets())
|
||||
{
|
||||
outcomes[r.BasketId] = r;
|
||||
}
|
||||
|
||||
Dictionary<string, (double[] Features, DateTime Ts)> entries = new(StringComparer.Ordinal);
|
||||
string dir = Path.GetDirectoryName(_ledger.DecisionsPath)!;
|
||||
foreach (string file in Directory.GetFiles(dir, "decisions*.jsonl").OrderBy(static f => f, StringComparer.Ordinal))
|
||||
{
|
||||
foreach (string line in Ledger.ReadLines(file))
|
||||
{
|
||||
try
|
||||
{
|
||||
double[]? f = LearningFeatures.FromLedgerLine(line, out string id, out DateTime ts);
|
||||
if (f is not null && id.Length > 0)
|
||||
{
|
||||
entries[id] = (f, ts);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A malformed line is skipped, never repaired in place.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<LabelledBasket> rows = [];
|
||||
foreach ((string id, BasketOutcomeRow o) in outcomes)
|
||||
{
|
||||
if (!entries.TryGetValue(id, out (double[] Features, DateTime Ts) e))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new LabelledBasket(id, o.OpenedUtc, o.ClosedUtc, e.Features, o.Label, o.PnlNetUsd, o.Basket, o.Preset, e.Features[25]));
|
||||
}
|
||||
|
||||
rows.Sort(static (a, b) => a.OpenedUtc.CompareTo(b.OpenedUtc));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private void WriteCalibration(List<LabelledBasket> rows)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine(CalibrationTables.Header);
|
||||
foreach (CalibrationTables.Row r in CalibrationTables.Build(rows))
|
||||
{
|
||||
sb.AppendLine(r.ToCsv());
|
||||
}
|
||||
|
||||
Atomic(Path.Combine(_knowledgeDir, "calibration.csv"), sb.ToString());
|
||||
}
|
||||
|
||||
private void WriteInsights(DateTime nowUtc, List<LabelledBasket> rows, ModelReport l1, ModelReport l2)
|
||||
{
|
||||
int week = System.Globalization.ISOWeek.GetWeekOfYear(nowUtc);
|
||||
string path = Path.Combine(_knowledgeDir, $"insights_{nowUtc.Year}{week:00}.md");
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"# Insight settimanali — {nowUtc:yyyy-MM-dd} (settimana {week})");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Basket chiusi nel ledger: **{rows.Count}**, win rate {rows.Average(static r => r.Label):P0}, P&L netto {rows.Sum(static r => r.PnlNetUsd):F2} USD.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Cosa ha funzionato e cosa no (calibrazione, livello 0)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| dimensione | bucket | n | win rate | P&L medio |");
|
||||
sb.AppendLine("|---|---|---|---|---|");
|
||||
foreach (CalibrationTables.Row r in CalibrationTables.Build(rows).Where(static r => r.Count >= 5))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"| {r.Dimension} | {r.Bucket} | {r.Count} | {r.WinRate:P0} | {r.MeanPnl:F2} |");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Meta-modello (livelli 1 e 2, walk-forward)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("- " + l1.Summary);
|
||||
sb.AppendLine("- " + l2.Summary);
|
||||
if (l1.Calibration.Count > 0)
|
||||
{
|
||||
sb.AppendLine("- calibrazione logistica: " + ModelEvaluator.DescribeCalibration(l1.Calibration));
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Bandit (livello 3)");
|
||||
sb.AppendLine();
|
||||
for (int c = 0; c < 3; c++)
|
||||
{
|
||||
sb.AppendLine("- " + _bandit.Describe(c));
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Parametri suggeriti");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Nessun parametro live viene cambiato da questo file: le proposte sono in `proposals.csv` e passano dal forward test pre-registrato.");
|
||||
Atomic(path, sb.ToString());
|
||||
}
|
||||
|
||||
private void WriteProposals(DateTime nowUtc, List<LabelledBasket> rows, ModelReport l1)
|
||||
{
|
||||
string path = Path.Combine(_knowledgeDir, "proposals.csv");
|
||||
bool isNew = !File.Exists(path);
|
||||
StringBuilder sb = new();
|
||||
if (isNew)
|
||||
{
|
||||
sb.AppendLine("data;origine;parametro;valore_attuale;valore_proposto;evidenza;stato;motivazione");
|
||||
}
|
||||
|
||||
// The one proposal the evidence can support at this stage: whether the meta-model gate is worth turning on.
|
||||
sb.AppendLine(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{nowUtc:O};ciclo settimanale;mlMinProbability gate;{(_active ? "attivo" : "ombra")};{(l1.PassesActivation ? "attivo" : "ombra")};AUC {l1.Auc:F3} su {rows.Count} basket, P&L filtrato {l1.PnlFiltered:F0} contro {l1.PnlAll:F0};proposta;{l1.Motivazione}"));
|
||||
|
||||
// Per-basket evidence: a basket that loses over 30+ closes is a candidate for disabling.
|
||||
foreach (IGrouping<string, LabelledBasket> g in rows.GroupBy(static r => r.Basket))
|
||||
{
|
||||
int n = g.Count();
|
||||
double pnl = g.Sum(static r => r.PnlNetUsd);
|
||||
if (n >= 30 && pnl < 0)
|
||||
{
|
||||
sb.AppendLine(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{nowUtc:O};ciclo settimanale;baskets[{g.Key}].enabled;true;false;{n} basket, P&L netto {pnl:F0} USD, win rate {g.Average(static r => r.Label):P0};proposta;il basket perde in modo persistente: da valutare nel forward test prima di disattivarlo"));
|
||||
}
|
||||
}
|
||||
|
||||
File.AppendAllText(path, sb.ToString(), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private void AppendRegistry(string file, string header, string line)
|
||||
{
|
||||
string path = Path.Combine(_knowledgeDir, file);
|
||||
bool isNew = !File.Exists(path);
|
||||
File.AppendAllText(path, (isNew ? header + Environment.NewLine : string.Empty) + line + Environment.NewLine, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static void Atomic(string path, string content)
|
||||
{
|
||||
File.WriteAllText(path + ".tmp", content, new UTF8Encoding(false));
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private string StatePath => Path.Combine(_modelsDir, "learning_state.json");
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_modelsDir, "logreg_current.json"), _logistic.ToJson());
|
||||
if (_challenger is not null)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_modelsDir, "mlp_current.json"), _challenger.ToJson());
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(_modelsDir, "bandit.json"), _bandit.ToJson());
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteBoolean("active", _active);
|
||||
w.WriteString("champion", _champion);
|
||||
w.WriteNumber("version", _version);
|
||||
w.WriteString("lastCycleUtc", _lastCycleUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("lastReport", _lastReport);
|
||||
w.WriteStartArray("recent");
|
||||
foreach ((double p, int label) in _recent)
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteNumber("p", Math.Round(p, 6));
|
||||
w.WriteNumber("label", label);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WriteStartObject("entries");
|
||||
foreach ((string id, double[] f) in _entryFeatures)
|
||||
{
|
||||
w.WriteStartArray(id);
|
||||
foreach (double v in f)
|
||||
{
|
||||
w.WriteNumberValue(double.IsFinite(v) ? v : 0);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
File.WriteAllBytes(StatePath + ".tmp", ms.ToArray());
|
||||
File.Move(StatePath + ".tmp", StatePath, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"stato dell'apprendimento non salvato: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
string logreg = Path.Combine(_modelsDir, "logreg_current.json");
|
||||
if (File.Exists(logreg))
|
||||
{
|
||||
_logistic = OnlineLogistic.FromJson(File.ReadAllText(logreg));
|
||||
}
|
||||
|
||||
string mlp = Path.Combine(_modelsDir, "mlp_current.json");
|
||||
if (File.Exists(mlp))
|
||||
{
|
||||
_challenger = SmallMlp.FromJson(File.ReadAllText(mlp));
|
||||
}
|
||||
|
||||
string bandit = Path.Combine(_modelsDir, "bandit.json");
|
||||
if (File.Exists(bandit))
|
||||
{
|
||||
_bandit = ThompsonBandit.FromJson(File.ReadAllText(bandit));
|
||||
}
|
||||
|
||||
if (File.Exists(StatePath))
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(File.ReadAllBytes(StatePath));
|
||||
JsonElement r = doc.RootElement;
|
||||
_active = r.TryGetProperty("active", out JsonElement a) && a.GetBoolean();
|
||||
_champion = r.TryGetProperty("champion", out JsonElement c) ? c.GetString() ?? "logistica" : "logistica";
|
||||
_version = r.TryGetProperty("version", out JsonElement v) ? v.GetInt32() : 0;
|
||||
_lastReport = r.TryGetProperty("lastReport", out JsonElement lr) ? lr.GetString() ?? string.Empty : string.Empty;
|
||||
if (r.TryGetProperty("lastCycleUtc", out JsonElement lc) && DateTime.TryParse(lc.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t))
|
||||
{
|
||||
_lastCycleUtc = t;
|
||||
}
|
||||
|
||||
if (r.TryGetProperty("recent", out JsonElement recent))
|
||||
{
|
||||
foreach (JsonElement e in recent.EnumerateArray())
|
||||
{
|
||||
_recent.Add((e.GetProperty("p").GetDouble(), e.GetProperty("label").GetInt32()));
|
||||
}
|
||||
}
|
||||
|
||||
if (r.TryGetProperty("entries", out JsonElement entries))
|
||||
{
|
||||
foreach (JsonProperty p in entries.EnumerateObject())
|
||||
{
|
||||
_entryFeatures[p.Name] = [.. p.Value.EnumerateArray().Select(static x => x.GetDouble())];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException or InvalidOperationException)
|
||||
{
|
||||
Log.Warn($"stato dell'apprendimento non leggibile ({ex.Message}): riparto da zero");
|
||||
_logistic = new OnlineLogistic(LearningFeatures.Count);
|
||||
_challenger = null;
|
||||
_bandit = new ThompsonBandit(3, 42);
|
||||
_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SeedOutcomesFromLedger()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (BasketOutcomeRow r in _ledger.ReadBaskets().TakeLast(100))
|
||||
{
|
||||
_lastOutcomes.Add(r.Label);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The ledger may be absent on a first run.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Save();
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Baskets;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>One closed basket as <c>baskets.csv</c> records it.</summary>
|
||||
public sealed record BasketOutcomeRow(
|
||||
string BasketId,
|
||||
string RunId,
|
||||
string Basket,
|
||||
string Mode,
|
||||
string Preset,
|
||||
DateTime OpenedUtc,
|
||||
DateTime ClosedUtc,
|
||||
bool BuyCross,
|
||||
double EntryZ,
|
||||
double ExitZ,
|
||||
double PnlGrossUsd,
|
||||
double PnlNetUsd,
|
||||
double PipsGross,
|
||||
double CostPips,
|
||||
double CostUsd,
|
||||
double SlippagePips,
|
||||
int Adds,
|
||||
int BarsHeld,
|
||||
string ExitReason,
|
||||
double EquityAtEntry,
|
||||
double PMlAtEntry,
|
||||
string Motivazione)
|
||||
{
|
||||
public int Label => PnlNetUsd > 0 ? 1 : 0;
|
||||
|
||||
public const string Header =
|
||||
"basket_id;run_id;basket;mode;preset;opened_utc;closed_utc;buy_cross;entry_z;exit_z;pnl_gross_usd;pnl_net_usd;pips_gross;cost_pips;cost_usd;slippage_pips;adds;bars_held;exit_reason;equity_at_entry;p_ml_at_entry;label;durata_min;motivazione";
|
||||
|
||||
public string ToCsv() => string.Join(';',
|
||||
[
|
||||
BasketId, RunId, Basket, Mode, Preset,
|
||||
OpenedUtc.ToString("O", CultureInfo.InvariantCulture), ClosedUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
BuyCross ? "1" : "0", N(EntryZ), N(ExitZ), N(PnlGrossUsd), N(PnlNetUsd), N(PipsGross), N(CostPips), N(CostUsd), N(SlippagePips),
|
||||
Adds.ToString(CultureInfo.InvariantCulture), BarsHeld.ToString(CultureInfo.InvariantCulture), ExitReason, N(EquityAtEntry), N(PMlAtEntry),
|
||||
Label.ToString(CultureInfo.InvariantCulture), ((ClosedUtc - OpenedUtc).TotalMinutes).ToString("0", CultureInfo.InvariantCulture),
|
||||
Motivazione.Replace(';', ',').Replace('\n', ' ').Replace('\r', ' '),
|
||||
]);
|
||||
|
||||
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.######", CultureInfo.InvariantCulture) : string.Empty;
|
||||
|
||||
public static BasketOutcomeRow? Parse(string line)
|
||||
{
|
||||
string[] f = line.Split(';');
|
||||
if (f.Length < 24 || f[0] == "basket_id")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new BasketOutcomeRow(f[0], f[1], f[2], f[3], f[4], T(f[5]), T(f[6]), f[7] == "1", D(f[8]), D(f[9]), D(f[10]), D(f[11]), D(f[12]), D(f[13]), D(f[14]), D(f[15]),
|
||||
int.Parse(f[16], CultureInfo.InvariantCulture), int.Parse(f[17], CultureInfo.InvariantCulture), f[18], D(f[19]), D(f[20]), f[23]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
static DateTime T(string s) => DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
static double D(string s) => s.Length == 0 ? double.NaN : double.Parse(s, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The history of information and decisions (§8.1): <c>decisions.jsonl</c> gets one
|
||||
/// line for <b>every</b> evaluation of every basket, with the features as they were at
|
||||
/// that moment; <c>baskets.csv</c> gets one row per closed basket. Both are append-only:
|
||||
/// a correction is a new line with <c>evento = correzione</c>, never an edit.
|
||||
/// </summary>
|
||||
public sealed class Ledger : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly Lock _gate = new();
|
||||
private StreamWriter? _decisions;
|
||||
private StreamWriter? _baskets;
|
||||
private string _decisionsMonth = string.Empty;
|
||||
|
||||
public Ledger(string directory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
_directory = directory;
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
public string DecisionsPath => Path.Combine(_directory, "decisions.jsonl");
|
||||
|
||||
public string BasketsPath => Path.Combine(_directory, "baskets.csv");
|
||||
|
||||
/// <summary>Appends one evaluation. Never throws into the engine.</summary>
|
||||
public void Decision(
|
||||
string runId, string mode, string preset, string configHash, BasketContext ctx, BasketDecision d,
|
||||
string evento, string? basketId, string? motivazioneExtra = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string line = Serialize(runId, mode, preset, configHash, ctx, d, evento, basketId, motivazioneExtra);
|
||||
lock (_gate)
|
||||
{
|
||||
RotateIfNeeded(ctx.TimeUtc);
|
||||
_decisions ??= Open(DecisionsPath);
|
||||
_decisions.WriteLine(line);
|
||||
_decisions.Flush();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"ledger: riga di decisione non scritta ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
public void Basket(BasketOutcomeRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
try
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
bool isNew = !File.Exists(BasketsPath) || new FileInfo(BasketsPath).Length == 0;
|
||||
_baskets ??= Open(BasketsPath);
|
||||
if (isNew)
|
||||
{
|
||||
_baskets.WriteLine(BasketOutcomeRow.Header);
|
||||
}
|
||||
|
||||
_baskets.WriteLine(row.ToCsv());
|
||||
_baskets.Flush();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"ledger: riga di basket non scritta ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A correction is a new line, never an edit of an old one.</summary>
|
||||
public void Correction(string runId, string basketId, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("ts", DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("run_id", runId);
|
||||
w.WriteString("evento", "correzione");
|
||||
w.WriteString("basket_id", basketId);
|
||||
w.WriteString("motivazione", what);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_decisions ??= Open(DecisionsPath);
|
||||
_decisions.WriteLine(Encoding.UTF8.GetString(ms.ToArray()));
|
||||
_decisions.Flush();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"ledger: correzione non scritta ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads every closed basket, oldest first: the training set.</summary>
|
||||
public List<BasketOutcomeRow> ReadBaskets()
|
||||
{
|
||||
List<BasketOutcomeRow> rows = [];
|
||||
foreach (string line in ReadLines(BasketsPath))
|
||||
{
|
||||
if (BasketOutcomeRow.Parse(line) is { } r)
|
||||
{
|
||||
rows.Add(r);
|
||||
}
|
||||
}
|
||||
|
||||
rows.Sort(static (a, b) => a.OpenedUtc.CompareTo(b.OpenedUtc));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>The current month's decision lines, oldest first.</summary>
|
||||
public List<string> ReadDecisionLines() => ReadLines(DecisionsPath);
|
||||
|
||||
/// <summary>Reads a file the ledger may still hold open for appending.</summary>
|
||||
public static List<string> ReadLines(string path)
|
||||
{
|
||||
List<string> lines = [];
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return lines;
|
||||
}
|
||||
|
||||
using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using StreamReader reader = new(stream, Encoding.UTF8);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
if (line.Length > 0)
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>Monthly rotation: the current file is moved to <c>decisions_YYYYMM.jsonl</c> when the month changes.</summary>
|
||||
private void RotateIfNeeded(DateTime now)
|
||||
{
|
||||
string month = now.ToString("yyyyMM", CultureInfo.InvariantCulture);
|
||||
if (_decisionsMonth.Length == 0)
|
||||
{
|
||||
_decisionsMonth = File.Exists(DecisionsPath) ? MonthOfFirstLine() ?? month : month;
|
||||
}
|
||||
|
||||
if (_decisionsMonth == month)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_decisions?.Dispose();
|
||||
_decisions = null;
|
||||
if (File.Exists(DecisionsPath))
|
||||
{
|
||||
string aside = Path.Combine(_directory, $"decisions_{_decisionsMonth}.jsonl");
|
||||
File.Move(DecisionsPath, aside, overwrite: false);
|
||||
}
|
||||
|
||||
_decisionsMonth = month;
|
||||
}
|
||||
|
||||
private string? MonthOfFirstLine()
|
||||
{
|
||||
try
|
||||
{
|
||||
using StreamReader r = new(DecisionsPath);
|
||||
string? first = r.ReadLine();
|
||||
if (first is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(first);
|
||||
string ts = doc.RootElement.GetProperty("ts").GetString() ?? string.Empty;
|
||||
return DateTime.TryParse(ts, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t)
|
||||
? t.ToString("yyyyMM", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static StreamWriter Open(string path)
|
||||
{
|
||||
FileStream stream = new(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 1 << 14);
|
||||
return new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = false };
|
||||
}
|
||||
|
||||
/// <summary>The JSON line of one evaluation: every feature named in §8.1, plus the decision.</summary>
|
||||
public static string Serialize(string runId, string mode, string preset, string configHash, BasketContext ctx, BasketDecision d, string evento, string? basketId, string? extra)
|
||||
{
|
||||
BasketEvaluation e = d.Evaluation;
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("ts", ctx.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("run_id", runId);
|
||||
w.WriteString("config_hash", configHash);
|
||||
w.WriteString("basket", ctx.Name);
|
||||
w.WriteString("basket_id", basketId ?? string.Empty);
|
||||
w.WriteString("cross", ctx.Cross.Symbol);
|
||||
w.WriteString("mode", mode);
|
||||
w.WriteString("preset", preset);
|
||||
w.WriteString("evento", evento);
|
||||
w.WriteString("decision", d.Kind.ToString());
|
||||
w.WriteBoolean("buy_cross", d.BuyCross);
|
||||
Num(w, "z", e.Z);
|
||||
Num(w, "z_in_eff", e.ZInEffective);
|
||||
Num(w, "D_pips", e.DPips);
|
||||
Num(w, "rho_W", e.RhoW);
|
||||
Num(w, "rho_20", e.RhoShort);
|
||||
Num(w, "halfLife", e.HalfLife);
|
||||
Num(w, "atrA", e.AtrPipsA);
|
||||
Num(w, "atrB", e.AtrPipsB);
|
||||
Num(w, "sigmaX", e.SigmaX);
|
||||
Num(w, "ewmaVolX", e.EwmaVolX);
|
||||
Num(w, "sigmaForecast", ctx.SigmaForecast);
|
||||
Num(w, "sigmaAverage30d", ctx.SigmaAverage30d);
|
||||
Num(w, "costPips", e.CostPips);
|
||||
Num(w, "breakEvenWinRate", e.BreakEvenWinRate);
|
||||
Num(w, "spreadA", e.SpreadPipsA);
|
||||
Num(w, "spreadB", e.SpreadPipsB);
|
||||
Num(w, "markupA", ctx.MarkupPipsA);
|
||||
Num(w, "markupB", ctx.MarkupPipsB);
|
||||
Num(w, "hourSin", e.HourSin);
|
||||
Num(w, "hourCos", e.HourCos);
|
||||
w.WriteNumber("dow", e.DayOfWeek);
|
||||
IntOrNull(w, "minutesToNextHigh", ctx.MinutesToNextHigh);
|
||||
IntOrNull(w, "minutesSinceLastHigh", ctx.MinutesSinceLastHigh);
|
||||
Num(w, "surpriseLast", ctx.SurpriseLast);
|
||||
Num(w, "netSentDiff_1h", ctx.NetSentimentDiff1h);
|
||||
Num(w, "netSentDiff_4h", ctx.NetSentimentDiff4h);
|
||||
Num(w, "netSentDiff_24h", ctx.NetSentimentDiff24h);
|
||||
Num(w, "hawkishDiff", ctx.HawkishDiff);
|
||||
Num(w, "riskOff", ctx.RiskOff);
|
||||
w.WriteNumber("newsCount", ctx.NewsCount);
|
||||
Num(w, "regimeTrend", e.TrendStrength);
|
||||
Num(w, "lastNOutcomes", ctx.LastOutcomes);
|
||||
Num(w, "p_ML", ctx.PMl);
|
||||
w.WriteBoolean("mlActive", ctx.MlActive);
|
||||
Num(w, "equity", ctx.Equity);
|
||||
w.WriteNumber("openBaskets", ctx.OpenBaskets);
|
||||
Num(w, "priceA", e.PriceA);
|
||||
Num(w, "priceB", e.PriceB);
|
||||
Num(w, "pipsOpen", e.PipsOpen);
|
||||
Num(w, "pnlOpenUsd", e.PnlOpenUsd);
|
||||
w.WriteNumber("barsHeld", e.BarsHeld);
|
||||
if (d.Sizing is { Ok: true } s)
|
||||
{
|
||||
Num(w, "unitsA", s.UnitsA);
|
||||
Num(w, "unitsB", s.UnitsB);
|
||||
Num(w, "notionalUsd", s.NotionalUsdA + s.NotionalUsdB);
|
||||
Num(w, "lossAtStopUsd", s.LossAtStopUsd);
|
||||
Num(w, "effectiveLeverage", s.EffectiveLeverage);
|
||||
}
|
||||
|
||||
w.WriteStartArray("reasonCodes");
|
||||
foreach (string c in d.ReasonCodes)
|
||||
{
|
||||
w.WriteStringValue(c);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WriteString("motivazione", extra is null ? d.Motivazione : $"{d.Motivazione} — {extra}");
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
static void Num(Utf8JsonWriter w, string name, double v)
|
||||
{
|
||||
if (double.IsFinite(v))
|
||||
{
|
||||
w.WriteNumber(name, Math.Round(v, 8));
|
||||
}
|
||||
else
|
||||
{
|
||||
w.WriteNull(name);
|
||||
}
|
||||
}
|
||||
|
||||
static void IntOrNull(Utf8JsonWriter w, string name, int v)
|
||||
{
|
||||
if (v == int.MaxValue)
|
||||
{
|
||||
w.WriteNull(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
w.WriteNumber(name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_decisions?.Dispose();
|
||||
_baskets?.Dispose();
|
||||
_decisions = null;
|
||||
_baskets = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,296 +1,168 @@
|
||||
using Encelado.Binance;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Risk;
|
||||
using Encelado.Core.Strategies;
|
||||
using Encelado.Core.Baskets;
|
||||
using Encelado.Etoro;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>Where the Binance credentials in use actually came from.</summary>
|
||||
public enum CredentialSource
|
||||
{
|
||||
None = 0,
|
||||
ConfigFile,
|
||||
Environment,
|
||||
SavedStore,
|
||||
Interactive,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything the bot reads at startup. The file in Documents carries the operator's
|
||||
/// choices; the secrets never do — they come from the encrypted store or the
|
||||
/// environment and are filled into <see cref="Etoro"/> at runtime. The strategy's own
|
||||
/// numbers live in <c>strategy.json</c>, next to this file.
|
||||
/// </summary>
|
||||
public sealed class BotConfig
|
||||
{
|
||||
public BinanceOptions Binance { get; set; } = new();
|
||||
/// <summary>How to reach eToro; the keys are filled in at runtime from the encrypted store or the environment.</summary>
|
||||
public EtoroOptions Etoro { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
/// <summary>Execution mode and working folders.</summary>
|
||||
public RunOptions Run { get; set; } = new();
|
||||
|
||||
public EngineOptions Engine { get; set; } = new();
|
||||
|
||||
public RiskLimits Risk { get; set; } = new();
|
||||
/// <summary>What the window looks like: the time zone it shows.</summary>
|
||||
public UiOptions Ui { get; set; } = new();
|
||||
|
||||
public LoggingOptions Logging { get; set; } = new();
|
||||
|
||||
public StorageOptions Storage { get; set; } = new();
|
||||
|
||||
public MlOptions Ml { get; set; } = new();
|
||||
|
||||
public List<PairConfig> Pairs { get; set; } = [];
|
||||
|
||||
public IEnumerable<PairConfig> EnabledPairs => Pairs.Where(static p => p.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()
|
||||
{
|
||||
Binance.Validate();
|
||||
Risk.Validate();
|
||||
Engine.Validate();
|
||||
Etoro.Validate();
|
||||
Run.Validate();
|
||||
Ui.Validate();
|
||||
Logging.Validate();
|
||||
Storage.Validate();
|
||||
Ml.Validate();
|
||||
|
||||
List<PairConfig> enabled = [.. EnabledPairs];
|
||||
if (enabled.Count == 0)
|
||||
{
|
||||
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 (PairConfig pair in enabled)
|
||||
{
|
||||
pair.Validate();
|
||||
|
||||
if (!seen.Add(pair.Name))
|
||||
{
|
||||
throw new InvalidOperationException($"La coppia '{pair.Name}' è configurata più di una volta.");
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One tradeable pair: two Binance futures symbols and the parameters the statistical
|
||||
/// model uses on them.
|
||||
/// Execution mode and folders. Relative folders resolve against the configuration's own
|
||||
/// directory (<c>Documenti\Encelado</c>), so data, knowledge and reports sit next to the
|
||||
/// file that governs them.
|
||||
/// </summary>
|
||||
public sealed class PairConfig
|
||||
public sealed class RunOptions
|
||||
{
|
||||
/// <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><c>Paper</c>, <c>Demo</c> (default) or <c>Live</c>. The bot trades by itself in every mode.</summary>
|
||||
public string ExecutionMode { get; set; } = "Demo";
|
||||
|
||||
/// <summary>The hedge leg.</summary>
|
||||
public string SymbolB { get; set; } = string.Empty;
|
||||
/// <summary>Required, together with the typed phrase <c>CONFERMO LIVE</c> at start, for <c>Live</c>.</summary>
|
||||
public bool AllowLive { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
/// <summary>Seconds between two quote polls (2-30; each poll is one request for all instruments).</summary>
|
||||
public int PollSeconds { get; set; } = 3;
|
||||
|
||||
public Dictionary<string, double> Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Seconds between two status lines in the log and on the console.</summary>
|
||||
public int StatusSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>Stable identity, used as a dictionary key and shown in the UI.</summary>
|
||||
public string Name => $"{Normalize(SymbolA)}/{Normalize(SymbolB)}";
|
||||
/// <summary>Close every open basket when the bot stops. Off: the baskets stay on the account with their native stops.</summary>
|
||||
public bool CloseOnShutdown { get; set; }
|
||||
|
||||
public StrategyParameters ToStrategyParameters() => new(Parameters);
|
||||
public string StrategyFile { get; set; } = "strategy.json";
|
||||
|
||||
/// <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 string DataDirectory { get; set; } = "data";
|
||||
|
||||
public string KnowledgeDirectory { get; set; } = "knowledge";
|
||||
|
||||
public string ReportsDirectory { get; set; } = "reports";
|
||||
|
||||
public double PaperStartingBalance { get; set; } = 10_000;
|
||||
|
||||
public double PaperSlippagePips { get; set; } = 0.3;
|
||||
|
||||
/// <summary>Set by the loader to the configuration file's folder.</summary>
|
||||
public string BaseDirectory { get; set; } = AppContext.BaseDirectory;
|
||||
|
||||
public ExecutionMode Mode => ExecutionModeExtensions.TryParse(ExecutionMode, out ExecutionMode m) ? m : Core.Baskets.ExecutionMode.Demo;
|
||||
|
||||
public string Resolve(string relativeOrAbsolute) =>
|
||||
Path.IsPathRooted(relativeOrAbsolute) ? relativeOrAbsolute : Path.Combine(BaseDirectory, relativeOrAbsolute);
|
||||
|
||||
public string StrategyPath => Resolve(StrategyFile);
|
||||
|
||||
public string DataPath => Resolve(DataDirectory);
|
||||
|
||||
public string KnowledgePath => Resolve(KnowledgeDirectory);
|
||||
|
||||
public string ReportsPath => Resolve(ReportsDirectory);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
string a = Normalize(SymbolA);
|
||||
string b = Normalize(SymbolB);
|
||||
|
||||
if (a.Length == 0 || b.Length == 0)
|
||||
if (!ExecutionModeExtensions.TryParse(ExecutionMode, out ExecutionMode mode))
|
||||
{
|
||||
throw new InvalidOperationException("Una coppia ha un simbolo vuoto: servono sia symbolA sia symbolB.");
|
||||
throw new InvalidOperationException("run.executionMode deve essere Paper, Demo oppure Live.");
|
||||
}
|
||||
|
||||
if (a.Equals(b, StringComparison.OrdinalIgnoreCase))
|
||||
if (mode == Core.Baskets.ExecutionMode.Backtest)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"La coppia '{a}/{b}' ha le due gambe uguali: lo spread sarebbe costante a zero.");
|
||||
throw new InvalidOperationException("run.executionMode 'Backtest' non è una modalità del bot: il backtest si lancia dallo strumento di ricerca.");
|
||||
}
|
||||
|
||||
// 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());
|
||||
if (mode.IsLive() && !AllowLive)
|
||||
{
|
||||
throw new InvalidOperationException("la modalità Live richiede run.allowLive = true (e la frase CONFERMO LIVE all'avvio).");
|
||||
}
|
||||
|
||||
if (PollSeconds is < 2 or > 30)
|
||||
{
|
||||
throw new InvalidOperationException("run.pollSeconds deve essere fra 2 e 30.");
|
||||
}
|
||||
|
||||
if (StatusSeconds is < 10 or > 3600)
|
||||
{
|
||||
throw new InvalidOperationException("run.statusSeconds deve essere fra 10 e 3600.");
|
||||
}
|
||||
|
||||
if (PaperStartingBalance is <= 0 or > 1e9)
|
||||
{
|
||||
throw new InvalidOperationException("run.paperStartingBalance deve essere positivo.");
|
||||
}
|
||||
|
||||
if (PaperSlippagePips is < 0 or > 10)
|
||||
{
|
||||
throw new InvalidOperationException("run.paperSlippagePips deve essere fra 0 e 10.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EngineOptions
|
||||
/// <summary>What the window shows and how. Nothing here changes what the bot does.</summary>
|
||||
public sealed class UiOptions
|
||||
{
|
||||
/// <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>Historical bars pulled at startup to fill the z-score window.</summary>
|
||||
public int WarmupBars { get; set; } = 500;
|
||||
/// <summary>The name that means "the computer's own time zone".</summary>
|
||||
public const string ComputerZone = "computer";
|
||||
|
||||
/// <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.
|
||||
/// <c>computer</c> (the Windows setting), <c>UTC</c>, a Windows id such as
|
||||
/// <c>W. Europe Standard Time</c> or an IANA id such as <c>Europe/Rome</c>. Only the
|
||||
/// screen is affected: the log file carries offsets, the ledger is UTC.
|
||||
/// </summary>
|
||||
public int CalibrationBars { get; set; } = 500;
|
||||
public string TimeZone { get; set; } = ComputerZone;
|
||||
|
||||
/// <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>The zone the window renders times in; never throws, the computer's zone is the fallback.</summary>
|
||||
public TimeZoneInfo ResolveTimeZone(out string? warning)
|
||||
{
|
||||
warning = null;
|
||||
string id = (TimeZone ?? string.Empty).Trim();
|
||||
if (id.Length == 0 || id.Equals(ComputerZone, StringComparison.OrdinalIgnoreCase) || id.Equals("local", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TimeZoneInfo.Local;
|
||||
}
|
||||
|
||||
/// <summary><c>market</c> or <c>limit</c>. A marketable limit caps slippage.</summary>
|
||||
public string EntryOrderType { get; set; } = "limit";
|
||||
if (id.Equals("utc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TimeZoneInfo.Utc;
|
||||
}
|
||||
|
||||
/// <summary>How far through the touch a marketable limit is priced, in basis points.</summary>
|
||||
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; }
|
||||
|
||||
/// <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 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; } = 15;
|
||||
|
||||
/// <summary>Close every open pair when the bot shuts down.</summary>
|
||||
public bool CloseOnShutdown { get; set; }
|
||||
|
||||
/// <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.FiveMinutes;
|
||||
|
||||
public bool UseLimitEntries =>
|
||||
EntryOrderType.Trim().Equals("limit", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public TimeSpan RecalibrateEvery => TimeSpan.FromHours(Math.Max(0.25, RecalibrateHours));
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||
{
|
||||
warning = $"ui.timeZone '{id}' non è un fuso orario conosciuto: uso quello del computer ({TimeZoneInfo.Local.Id})";
|
||||
return TimeZoneInfo.Local;
|
||||
}
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!TimeFrameExtensions.TryParse(TimeFrame, out TimeFrame tf))
|
||||
{
|
||||
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 deve essere 'limit' oppure 'market'.");
|
||||
}
|
||||
|
||||
if (WarmupBars is < 50 or > 20_000)
|
||||
{
|
||||
throw new InvalidOperationException("engine.warmupBars deve essere fra 50 e 20000.");
|
||||
}
|
||||
|
||||
if (CalibrationBars is < 100 or > 1500)
|
||||
{
|
||||
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 deve essere almeno 5.");
|
||||
}
|
||||
|
||||
if (StatusSeconds < 10)
|
||||
{
|
||||
throw new InvalidOperationException("engine.statusSeconds deve essere almeno 10.");
|
||||
}
|
||||
|
||||
if (ExplainSeconds < 1)
|
||||
{
|
||||
throw new InvalidOperationException("engine.explainSeconds deve essere almeno 1.");
|
||||
}
|
||||
TimeZone ??= ComputerZone;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,75 +179,42 @@ public sealed class LoggingOptions
|
||||
public bool Console { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Folder that holds every output file. Relative paths resolve against the folder
|
||||
/// the configuration file lives in — <c>Documenti\Encelado</c> by default — so
|
||||
/// everything the operator owns sits in one place. An absolute path is used as given.
|
||||
/// Folder that holds the log files. Relative paths resolve against the folder the
|
||||
/// configuration file lives in — <c>Documenti\Encelado</c> by default. An absolute
|
||||
/// path is used as given.
|
||||
/// </summary>
|
||||
public string Directory { get; set; } = "logs";
|
||||
|
||||
/// <summary>
|
||||
/// Where relative paths are anchored. Set by the loader to the configuration file's
|
||||
/// own folder; falls back to the executable's folder when a configuration was never
|
||||
/// loaded from disk (tests, defaults built in memory).
|
||||
/// </summary>
|
||||
/// <summary>Set by the loader to the configuration file's own folder.</summary>
|
||||
public string BaseDirectory { get; set; } = AppContext.BaseDirectory;
|
||||
|
||||
/// <summary>Application log file name. Empty disables file logging.</summary>
|
||||
public string File { get; set; } = "encelado.log";
|
||||
|
||||
/// <summary>One <c>;</c>-separated row per order event. Empty disables it.</summary>
|
||||
public string TradeJournal { get; set; } = "trades.csv";
|
||||
|
||||
/// <summary>
|
||||
/// 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>.
|
||||
/// </summary>
|
||||
public string ExecutionLog { get; set; } = "executions.csv";
|
||||
|
||||
/// <summary>Rotate the application log once it passes this size. 0 disables rotation.</summary>
|
||||
public int MaxFileSizeMb { get; set; } = 32;
|
||||
|
||||
/// <summary>How many rotated application logs to keep.</summary>
|
||||
public int MaxFiles { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 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.</summary>
|
||||
/// <summary>Lines kept in the activity strip of the dashboard.</summary>
|
||||
public int StatusLines { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <summary>Lines kept by the log page: the memory ceiling for the in-app log. The file on disk stays complete.</summary>
|
||||
public int BufferedLines { get; set; } = 5_000;
|
||||
|
||||
/// <summary>Absolute path of the log directory, created on demand.</summary>
|
||||
public string ResolveDirectory()
|
||||
{
|
||||
string directory = string.IsNullOrWhiteSpace(Directory) ? "logs" : Directory;
|
||||
return Path.IsPathRooted(directory)
|
||||
? directory
|
||||
: Path.Combine(BaseDirectory, directory);
|
||||
return Path.IsPathRooted(directory) ? directory : Path.Combine(BaseDirectory, directory);
|
||||
}
|
||||
|
||||
/// <summary>Absolute path of a file inside the log directory, or null when disabled.</summary>
|
||||
public string? ResolvePath(string? fileName) =>
|
||||
string.IsNullOrWhiteSpace(fileName)
|
||||
? null
|
||||
: Path.IsPathRooted(fileName)
|
||||
? fileName
|
||||
: Path.Combine(ResolveDirectory(), fileName);
|
||||
: Path.IsPathRooted(fileName) ? fileName : Path.Combine(ResolveDirectory(), fileName);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
@@ -394,8 +233,6 @@ public sealed class LoggingOptions
|
||||
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 deve essere fra 100 e 200000.");
|
||||
@@ -403,110 +240,7 @@ public sealed class LoggingOptions
|
||||
|
||||
if (BufferedLines < StatusLines)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"logging.bufferedLines deve essere >= logging.statusLines: la pagina Log non può " +
|
||||
"tenere meno storia della striscia di stato.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the meta-model is used at decision time.
|
||||
/// <para>
|
||||
/// The model never proposes a trade: the statistical rule does that. The model may
|
||||
/// only veto an entry whose predicted probability of paying off is too low, and shrink
|
||||
/// the size of one it is lukewarm about. With no champion in the database these
|
||||
/// settings do nothing and the bot trades the rule as before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MlOptions
|
||||
{
|
||||
/// <summary>Consult the champion model before an entry. Needs the database.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Entries below this predicted probability are refused.</summary>
|
||||
public double MinProbability { get; set; } = 0.55;
|
||||
|
||||
/// <summary>Scale the position by <c>2Φ(z)−1</c> of the probability instead of all-or-nothing.</summary>
|
||||
public bool SizeByProbability { get; set; } = true;
|
||||
|
||||
/// <summary>The smallest fraction of the risk engine's size the model may leave.</summary>
|
||||
public double MinSizeFraction { get; set; } = 0.25;
|
||||
|
||||
/// <summary>How many scored entries between two drift checks.</summary>
|
||||
public int DriftCheckBars { get; set; } = 50;
|
||||
|
||||
/// <summary>PSI at or above which a feature counts as drifted.</summary>
|
||||
public double DriftPsiAlert { get; set; } = 0.25;
|
||||
|
||||
/// <summary>
|
||||
/// Drifted features needed to suspend the model until the next recalibration reloads
|
||||
/// it. 0 never suspends: drift is logged and recorded but the model keeps its say.
|
||||
/// </summary>
|
||||
public int DriftAlertsToSuspend { get; set; } = 3;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MinProbability is < 0.5 or > 0.99)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"ml.minProbability deve essere fra 0.5 e 0.99: sotto 0.5 il modello direbbe sì a " +
|
||||
"un ingresso che ritiene più probabilmente perdente.");
|
||||
}
|
||||
|
||||
if (MinSizeFraction is < 0.05 or > 1)
|
||||
{
|
||||
throw new InvalidOperationException("ml.minSizeFraction deve essere fra 0.05 e 1.");
|
||||
}
|
||||
|
||||
if (DriftCheckBars < 10)
|
||||
{
|
||||
throw new InvalidOperationException("ml.driftCheckBars deve essere almeno 10.");
|
||||
}
|
||||
|
||||
if (DriftPsiAlert is <= 0 or > 5)
|
||||
{
|
||||
throw new InvalidOperationException("ml.driftPsiAlert deve essere fra 0 e 5.");
|
||||
}
|
||||
|
||||
if (DriftAlertsToSuspend < 0)
|
||||
{
|
||||
throw new InvalidOperationException("ml.driftAlertsToSuspend non può essere negativo.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Where the local database lives, and whether it is used at all.</summary>
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Write every journal table to the database as well as to CSV, and read models,
|
||||
/// datasets and the champion from it. Off means CSV only and no machine learning.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Path of the SQLite file. Empty selects <c>%ProgramData%\Encelado\encelado.db</c>:
|
||||
/// a machine-wide folder, deliberately distinct from Documents (where the
|
||||
/// configuration lives and gets synced) and from the per-user application data
|
||||
/// (where the credentials live and nothing else should).
|
||||
/// </summary>
|
||||
public string DatabasePath { get; set; } = string.Empty;
|
||||
|
||||
public string ResolvePath() =>
|
||||
string.IsNullOrWhiteSpace(DatabasePath)
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"Encelado",
|
||||
"encelado.db")
|
||||
: Path.GetFullPath(DatabasePath);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(DatabasePath) &&
|
||||
DatabasePath.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
|
||||
{
|
||||
throw new InvalidOperationException("storage.databasePath contiene caratteri non validi.");
|
||||
throw new InvalidOperationException("logging.bufferedLines deve essere >= logging.statusLines.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,9 @@ namespace Encelado.Bot.Configuration;
|
||||
/// 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.
|
||||
/// from object defaults would silently throw all of that away. Restoring means restoring
|
||||
/// the document, not just the numbers. A test asserts that this string and the shipped
|
||||
/// file are identical, so the two cannot drift apart unnoticed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ConfigDefaults
|
||||
@@ -24,12 +21,9 @@ public static class ConfigDefaults
|
||||
|
||||
/// <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>
|
||||
/// moving whatever was there to a timestamped backup beside it. The backup is not
|
||||
/// optional: restoring defaults throws away every tuned number and every note the
|
||||
/// operator wrote in the file, and that is a decision people make by accident.
|
||||
/// </summary>
|
||||
/// <returns>The path of the backup, or null when there was no file to back up.</returns>
|
||||
public static string? Restore(string configPath)
|
||||
@@ -61,31 +55,24 @@ public static class ConfigDefaults
|
||||
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>
|
||||
/// <summary>Loads the factory values into <paramref name="config"/> in memory, without touching the disk.</summary>
|
||||
public static void ApplyTo(BotConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
BotConfig factory = Parse();
|
||||
|
||||
config.Engine = factory.Engine;
|
||||
config.Risk = factory.Risk;
|
||||
config.Run = factory.Run;
|
||||
config.Ui = factory.Ui;
|
||||
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
|
||||
// Keys 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;
|
||||
config.Etoro.Environment = factory.Etoro.Environment;
|
||||
config.Etoro.BaseUrl = factory.Etoro.BaseUrl;
|
||||
config.Etoro.RequestTimeoutSeconds = factory.Etoro.RequestTimeoutSeconds;
|
||||
config.Etoro.FillTimeoutSeconds = factory.Etoro.FillTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>The factory configuration as a parsed object. Reparsed on each call.</summary>
|
||||
@@ -105,238 +92,64 @@ public static class ConfigDefaults
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A leftover in the temp folder is not worth failing over.
|
||||
// A leftover in the temp folder is harmless.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The factory <c>encelado.json</c>, verbatim.</summary>
|
||||
/// <summary>The factory <c>encelado.json</c>, byte for byte what ships in <c>config/</c>.</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.",
|
||||
"_commento": "Configurazione di Encelado — Correlation Baskets su eToro (CFD forex). Le chiavi con il prefisso _ sono documentazione e vengono ignorate. I parametri della strategia (basket, preset, soglie, rischio) stanno in strategy.json accanto a questo file. Le chiavi API non stanno qui: si inseriscono dalla finestra e vivono cifrate in %LOCALAPPDATA%\\Encelado\\etoro.dat, oppure nelle variabili d'ambiente ETORO_API_KEY e ETORO_USER_KEY.",
|
||||
|
||||
"_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: per questo la configurazione di fabbrica punta alla TESTNET, dove gli ordini partono davvero ma i soldi sono finti. Lo strumento è in tools/Encelado.Backtest: 'backtest basket --data <cartella>' rifà 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
|
||||
"etoro": {
|
||||
"_note": "eToro Public API. environment = demo oppure real: chiavi e rotte sono diverse, e l'ambiente attivo è sempre visibile nella finestra.",
|
||||
"environment": "demo",
|
||||
"baseUrl": "https://public-api.etoro.com",
|
||||
"requestTimeoutSeconds": 20,
|
||||
"_fillTimeoutSeconds": "Quanto attendere l'esito di un ordine (eToro lo lavora in modo asincrono) prima di trattarlo come non confermato e riconciliare. È anche il timeout della seconda gamba (leg-risk).",
|
||||
"fillTimeoutSeconds": 5
|
||||
},
|
||||
|
||||
"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": "Calcola e registra tutto, non invia nessun ordine. Spento di fabbrica: in testnet i soldi sono finti e la prova più vicina alla produzione è quella con gli ordini veri sul conto di prova — eseguiti, rifiutati, parzialmente riempiti, con le stesse regole di lotto e di margine. Accendilo per osservare una configurazione nuova senza toccare nemmeno il conto di prova. Sul conto reale resta comunque la conferma esplicita all'avvio.",
|
||||
"dryRun": false,
|
||||
|
||||
"_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,
|
||||
"run": {
|
||||
"_executionMode": "Paper = simulatore locale sopra le quotazioni reali (nessun ordine sul conto). Demo = conto demo di eToro: ordini veri, denaro virtuale, il bot apre e chiude da solo. Live = conto reale: richiede allowLive = true e la frase CONFERMO LIVE a ogni avvio. Nessuna modalità chiede l'approvazione dei singoli ordini (decisione D-20).",
|
||||
"executionMode": "Demo",
|
||||
"allowLive": false,
|
||||
"_pollSeconds": "Secondi fra due letture delle quotazioni (una richiesta per tutti gli strumenti). 3 s = 20 richieste al minuto su una quota di 120: resta spazio per candele e costi.",
|
||||
"pollSeconds": 3,
|
||||
"_statusSeconds": "Ogni quanti secondi il bot scrive una riga di stato nel log (e sulla console in headless).",
|
||||
"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": "true = fermare il bot chiude i basket aperti a mercato. false = restano sul conto con gli stop nativi sul server, senza nessuno che applichi il take-profit o lo stop di basket finché il bot non riparte.",
|
||||
"closeOnShutdown": false,
|
||||
"logEveryBar": true
|
||||
"strategyFile": "strategy.json",
|
||||
"_cartelle": "Relative alla cartella di questo file: data (mercato, calendario, notizie, ledger, modelli), knowledge (calibrazione, proposte, registri), reports.",
|
||||
"dataDirectory": "data",
|
||||
"knowledgeDirectory": "knowledge",
|
||||
"reportsDirectory": "reports",
|
||||
"_paper": "Solo per executionMode = Paper: saldo iniziale del simulatore e slippage per gamba oltre lo spread reale del momento.",
|
||||
"paperStartingBalance": 10000,
|
||||
"paperSlippagePips": 0.3
|
||||
},
|
||||
|
||||
"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
|
||||
"ui": {
|
||||
"_timeZone": "Fuso orario con cui la finestra mostra gli orari. 'computer' = quello di Windows; 'UTC'; oppure un id di Windows (es. 'W. Europe Standard Time') o IANA (es. 'Europe/Rome'). Il file di log porta l'offset, il ledger è in UTC: cambiare questo valore non tocca nessun file.",
|
||||
"timeZone": "computer"
|
||||
},
|
||||
|
||||
"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": "trace, debug, info, warn, error, none. 'info' basta: ogni rifiuto che impedisce un ordine viene scritto a questo livello o sopra, con il basket e il motivo esatto.",
|
||||
"level": "info",
|
||||
|
||||
"_directory": "Dove salvare tutti gli output. Relativa alla cartella della configurazione (Documenti\\Encelado), oppure un percorso assoluto. Si cambia anche da Impostazioni.",
|
||||
"directory": "logs",
|
||||
|
||||
"console": false,
|
||||
"_directory": "Cartella dei log, relativa a questo file se non è assoluta.",
|
||||
"directory": "logs",
|
||||
"file": "encelado.log",
|
||||
"_rotazione": "Superata maxFileSizeMb il file viene ruotato (encelado.1.log, encelado.2.log…) e ne restano maxFiles.",
|
||||
"maxFileSizeMb": 32,
|
||||
"maxFiles": 10,
|
||||
|
||||
"_analysis": "Tutte le tabelle usano il separatore ; e hanno una colonna motivazione che spiega in italiano il perché della riga. 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; trades.csv una riga per ogni evento d'ordine. decisions ed executions si uniscono su decisionId.",
|
||||
"tradeJournal": "trades.csv",
|
||||
"decisionLog": "decisions.csv",
|
||||
"executionLog": "executions.csv",
|
||||
|
||||
"logMarketData": false,
|
||||
"_righe": "statusLines = righe della striscia di attività nella dashboard; bufferedLines = righe tenute in memoria dalla pagina Log (il file su disco resta completo).",
|
||||
"statusLines": 200,
|
||||
"bufferedLines": 5000
|
||||
},
|
||||
|
||||
"storage": {
|
||||
"_note": "Il database locale (SQLite): barre canoniche, dataset, modelli, validazioni, journal e drift. Vuoto = %ProgramData%\\Encelado\\encelado.db, una cartella di sistema separata sia da Documenti (dove sta la configurazione) sia dai dati utente (dove stanno le chiavi).",
|
||||
"enabled": true,
|
||||
"databasePath": ""
|
||||
},
|
||||
|
||||
"ml": {
|
||||
"_note": "Il meta-modello (meta-labeling): un classificatore addestrato sugli esiti dei segnali passati che dice se un ingresso proposto dalla regola statistica ha probabilità di ripagare i costi. Non propone mai operazioni: può solo rifiutarne una o ridurne la size. Si addestra con lo strumento tools/Encelado.Backtest (comandi dataset, train) e vive nel database locale come 'campione' della coppia; senza campione queste voci non fanno nulla.",
|
||||
"enabled": true,
|
||||
"_minProbability": "Sotto questa probabilità predetta l'ingresso viene rifiutato. 0.55 significa: opero solo quando il modello vede un vantaggio, anche piccolo, rispetto a lanciare una moneta.",
|
||||
"minProbability": 0.55,
|
||||
"_sizeByProbability": "Con true la size è proporzionale alla convinzione del modello (2·Φ(z)−1, López de Prado), fra minSizeFraction e 1; con false è tutto o niente.",
|
||||
"sizeByProbability": true,
|
||||
"minSizeFraction": 0.25,
|
||||
"_drift": "Ogni driftCheckBars segnali valutati le feature live vengono confrontate con quelle di addestramento (PSI e Kolmogorov-Smirnov). Una feature con PSI ≥ driftPsiAlert è 'in deriva'; con driftAlertsToSuspend feature in deriva il modello viene sospeso fino alla ricalibrazione successiva e il bot torna alla sola regola statistica. 0 = mai sospendere, solo registrare.",
|
||||
"driftCheckBars": 50,
|
||||
"driftPsiAlert": 0.25,
|
||||
"driftAlertsToSuspend": 3
|
||||
},
|
||||
|
||||
"_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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Encelado.Core.Baskets;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
@@ -8,8 +9,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 < local overlay < 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 live in
|
||||
/// the encrypted store or in the environment, never in the committed config.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ConfigLoader
|
||||
@@ -20,6 +21,12 @@ public static class ConfigLoader
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Sections of previous versions of the program, named so the warning reads like an upgrade rather than a typo.</summary>
|
||||
private static readonly HashSet<string> LegacySections = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"binance", "alpaca", "pairs", "ml", "ctrader", "engine", "strategy", "risk", "storage", "symbols",
|
||||
};
|
||||
|
||||
public static BotConfig Load(string path, out List<string> warnings)
|
||||
{
|
||||
warnings = [];
|
||||
@@ -31,6 +38,7 @@ public static class ConfigLoader
|
||||
if (!string.IsNullOrEmpty(folder))
|
||||
{
|
||||
config.Logging.BaseDirectory = folder;
|
||||
config.Run.BaseDirectory = folder;
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
@@ -45,7 +53,7 @@ public static class ConfigLoader
|
||||
ConfigDefaults.ApplyTo(config);
|
||||
}
|
||||
|
||||
// A sibling *.local.json overlays secrets and machine-specific overrides.
|
||||
// A sibling *.local.json overlays machine-specific overrides.
|
||||
string localPath = Path.ChangeExtension(path, null) + ".local.json";
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
@@ -55,6 +63,13 @@ public static class ConfigLoader
|
||||
}
|
||||
|
||||
ApplyEnvironment(config);
|
||||
|
||||
config.Ui.ResolveTimeZone(out string? zoneWarning);
|
||||
if (zoneWarning is not null)
|
||||
{
|
||||
warnings.Add(zoneWarning);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -74,121 +89,102 @@ public static class ConfigLoader
|
||||
|
||||
switch (section.Name.ToLowerInvariant())
|
||||
{
|
||||
case "binance":
|
||||
ReadBinance(config, section.Value, warnings);
|
||||
case "etoro":
|
||||
ReadEtoro(config, section.Value, warnings);
|
||||
break;
|
||||
case "engine":
|
||||
ReadEngine(config, section.Value, warnings);
|
||||
case "run":
|
||||
ReadRun(config, section.Value, warnings);
|
||||
break;
|
||||
case "risk":
|
||||
ReadRisk(config, section.Value, warnings);
|
||||
case "ui":
|
||||
ReadUi(config, section.Value, warnings);
|
||||
break;
|
||||
case "logging":
|
||||
ReadLogging(config, section.Value, warnings);
|
||||
break;
|
||||
case "pairs":
|
||||
ReadPairs(config, section.Value, warnings);
|
||||
break;
|
||||
case "storage":
|
||||
ReadStorage(config, section.Value, warnings);
|
||||
break;
|
||||
case "ml":
|
||||
ReadMl(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($"sezione sconosciuta '{section.Name}'");
|
||||
if (LegacySections.Contains(section.Name))
|
||||
{
|
||||
warnings.Add(
|
||||
$"la sezione '{section.Name}' appartiene a una versione precedente ed è stata ignorata. " +
|
||||
"Da Impostazioni → Ripristina i valori predefiniti riscrivi il file nel formato attuale.");
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add($"sezione sconosciuta '{section.Name}'");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadBinance(BotConfig config, JsonElement e, List<string> warnings)
|
||||
private static void ReadEtoro(BotConfig config, JsonElement e, List<string> warnings)
|
||||
{
|
||||
foreach (JsonProperty p in Properties(e, "binance", warnings))
|
||||
Etoro.EtoroOptions o = config.Etoro;
|
||||
foreach (JsonProperty p in Properties(e, "etoro", warnings))
|
||||
{
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
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;
|
||||
case "environment": o.Environment = Str(p); break;
|
||||
case "baseurl": o.BaseUrl = Str(p); break;
|
||||
case "requesttimeoutseconds": o.RequestTimeoutSeconds = Int(p); break;
|
||||
case "filltimeoutseconds": o.FillTimeoutSeconds = Int(p); break;
|
||||
case "useragent": o.UserAgent = Str(p); break;
|
||||
|
||||
// Accepted for a *.local.json overlay, never written by the app.
|
||||
case "apikey": o.ApiKey = Str(p); break;
|
||||
case "userkey": o.UserKey = Str(p); break;
|
||||
default: warnings.Add($"chiave sconosciuta 'etoro.{p.Name}'"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadEngine(BotConfig config, JsonElement e, List<string> warnings)
|
||||
private static void ReadRun(BotConfig config, JsonElement e, List<string> warnings)
|
||||
{
|
||||
EngineOptions o = config.Engine;
|
||||
foreach (JsonProperty p in Properties(e, "engine", warnings))
|
||||
RunOptions o = config.Run;
|
||||
foreach (JsonProperty p in Properties(e, "run", warnings))
|
||||
{
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
case "timeframe": o.TimeFrame = Str(p); break;
|
||||
case "warmupbars": o.WarmupBars = Int(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 "executionmode":
|
||||
o.ExecutionMode = Str(p);
|
||||
if (ExecutionModeExtensions.IsLegacyName(o.ExecutionMode))
|
||||
{
|
||||
ExecutionModeExtensions.TryParse(o.ExecutionMode, out ExecutionMode mapped);
|
||||
warnings.Add($"run.executionMode '{o.ExecutionMode}' è di una versione precedente: letto come {mapped}. Le approvazioni manuali non esistono più (D-20).");
|
||||
o.ExecutionMode = mapped.ToString();
|
||||
}
|
||||
|
||||
break;
|
||||
case "allowlive": o.AllowLive = Bool(p); break;
|
||||
case "pollseconds": o.PollSeconds = 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;
|
||||
case "logeverybar": o.LogEveryBar = Bool(p); break;
|
||||
default: warnings.Add($"chiave sconosciuta 'engine.{p.Name}'"); break;
|
||||
case "strategyfile": o.StrategyFile = Str(p); break;
|
||||
case "datadirectory": o.DataDirectory = Str(p); break;
|
||||
case "knowledgedirectory": o.KnowledgeDirectory = Str(p); break;
|
||||
case "reportsdirectory": o.ReportsDirectory = Str(p); break;
|
||||
case "paperstartingbalance": o.PaperStartingBalance = Num(p); break;
|
||||
case "paperslippagepips": o.PaperSlippagePips = Num(p); break;
|
||||
case "allowdemoauto":
|
||||
warnings.Add("la chiave 'run.allowDemoAuto' appartiene a una versione precedente ed è stata ignorata: in Demo il bot opera da solo.");
|
||||
break;
|
||||
default: warnings.Add($"chiave sconosciuta 'run.{p.Name}'"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadRisk(BotConfig config, JsonElement e, List<string> warnings)
|
||||
private static void ReadUi(BotConfig config, JsonElement e, List<string> warnings)
|
||||
{
|
||||
Core.Risk.RiskLimits r = config.Risk;
|
||||
foreach (JsonProperty p in Properties(e, "risk", warnings))
|
||||
UiOptions o = config.Ui;
|
||||
foreach (JsonProperty p in Properties(e, "ui", warnings))
|
||||
{
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
case "stakepct": r.StakePct = Num(p); break;
|
||||
case "stakeamount": r.StakeAmount = 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 "maxopenpairs": r.MaxOpenPairs = Int(p); break;
|
||||
case "maxtradesperday": r.MaxTradesPerDay = 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 "maxrelativespread": r.MaxRelativeSpread = Num(p); break;
|
||||
case "minordernotional": r.MinOrderNotional = Num(p); break;
|
||||
case "maxordernotional": r.MaxOrderNotional = Num(p); break;
|
||||
case "maxmarginratio": r.MaxMarginRatio = Num(p); break;
|
||||
case "maxhedgeratio": r.MaxHedgeRatio = Num(p); break;
|
||||
default: warnings.Add($"chiave sconosciuta 'risk.{p.Name}'"); break;
|
||||
case "timezone": o.TimeZone = Str(p); break;
|
||||
default: warnings.Add($"chiave sconosciuta 'ui.{p.Name}'"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,154 +200,39 @@ public static class ConfigLoader
|
||||
case "console": o.Console = Bool(p); break;
|
||||
case "directory": o.Directory = Str(p); break;
|
||||
case "file": o.File = Str(p); break;
|
||||
case "tradejournal": o.TradeJournal = Str(p); break;
|
||||
case "decisionlog": o.DecisionLog = Str(p); break;
|
||||
case "executionlog": o.ExecutionLog = Str(p); break;
|
||||
case "maxfilesizemb": o.MaxFileSizeMb = Int(p); break;
|
||||
case "maxfiles": o.MaxFiles = Int(p); break;
|
||||
case "logmarketdata": o.LogMarketData = Bool(p); break;
|
||||
case "statuslines": o.StatusLines = Int(p); break;
|
||||
case "bufferedlines": o.BufferedLines = Int(p); break;
|
||||
case "tradejournal" or "decisionlog" or "executionlog" or "logmarketdata":
|
||||
warnings.Add($"la chiave 'logging.{p.Name}' appartiene a una versione precedente ed è stata ignorata: il ledger dei basket sta in data/ledger.");
|
||||
break;
|
||||
default: warnings.Add($"chiave sconosciuta 'logging.{p.Name}'"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadStorage(BotConfig config, JsonElement e, List<string> warnings)
|
||||
{
|
||||
StorageOptions o = config.Storage;
|
||||
foreach (JsonProperty p in Properties(e, "storage", warnings))
|
||||
{
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
case "enabled": o.Enabled = Bool(p); break;
|
||||
case "databasepath": o.DatabasePath = Str(p); break;
|
||||
default: warnings.Add($"chiave sconosciuta 'storage.{p.Name}'"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadMl(BotConfig config, JsonElement e, List<string> warnings)
|
||||
{
|
||||
MlOptions o = config.Ml;
|
||||
foreach (JsonProperty p in Properties(e, "ml", warnings))
|
||||
{
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
case "enabled": o.Enabled = Bool(p); break;
|
||||
case "minprobability": o.MinProbability = Num(p); break;
|
||||
case "sizebyprobability": o.SizeByProbability = Bool(p); break;
|
||||
case "minsizefraction": o.MinSizeFraction = Num(p); break;
|
||||
case "driftcheckbars": o.DriftCheckBars = Int(p); break;
|
||||
case "driftpsialert": o.DriftPsiAlert = Num(p); break;
|
||||
case "driftalertstosuspend": o.DriftAlertsToSuspend = Int(p); break;
|
||||
default: warnings.Add($"chiave sconosciuta 'ml.{p.Name}'"); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadPairs(BotConfig config, JsonElement e, List<string> warnings)
|
||||
{
|
||||
if (e.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException("'pairs' deve essere un array.");
|
||||
}
|
||||
|
||||
config.Pairs.Clear();
|
||||
foreach (JsonElement item in e.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("ignoro una voce non-oggetto in 'pairs'");
|
||||
continue;
|
||||
}
|
||||
|
||||
PairConfig pair = new();
|
||||
foreach (JsonProperty p in item.EnumerateObject())
|
||||
{
|
||||
if (p.Name.StartsWith('_'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
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":
|
||||
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)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (JsonProperty kv in value.EnumerateObject())
|
||||
{
|
||||
if (kv.Name.StartsWith('_'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
pair.Parameters[kv.Name] = kv.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => kv.Value.GetDouble(),
|
||||
JsonValueKind.True => 1,
|
||||
JsonValueKind.False => 0,
|
||||
JsonValueKind.String when double.TryParse(
|
||||
kv.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture,
|
||||
out double parsed) => parsed,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyEnvironment(BotConfig config)
|
||||
{
|
||||
bool fromEnvironment = false;
|
||||
|
||||
// Binance's own conventional variable names, so existing tooling keeps working.
|
||||
string? key = Environment.GetEnvironmentVariable("BINANCE_API_KEY");
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
// eToro keys for automation (a VPS service): never written anywhere by the app.
|
||||
string? apiKey = Environment.GetEnvironmentVariable("ETORO_API_KEY");
|
||||
string? userKey = Environment.GetEnvironmentVariable("ETORO_USER_KEY");
|
||||
if (!string.IsNullOrWhiteSpace(apiKey) && !string.IsNullOrWhiteSpace(userKey))
|
||||
{
|
||||
config.Binance.ApiKey = key.Trim();
|
||||
fromEnvironment = true;
|
||||
config.Etoro.ApiKey = apiKey.Trim();
|
||||
config.Etoro.UserKey = userKey.Trim();
|
||||
}
|
||||
|
||||
string? secret = Environment.GetEnvironmentVariable("BINANCE_API_SECRET");
|
||||
if (!string.IsNullOrWhiteSpace(secret))
|
||||
string? etoroEnvironment = Environment.GetEnvironmentVariable("ETORO_ENVIRONMENT");
|
||||
if (!string.IsNullOrWhiteSpace(etoroEnvironment))
|
||||
{
|
||||
config.Binance.ApiSecret = secret.Trim();
|
||||
fromEnvironment = true;
|
||||
config.Etoro.Environment = etoroEnvironment.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
bool haveBoth =
|
||||
!string.IsNullOrWhiteSpace(config.Binance.ApiKey) &&
|
||||
!string.IsNullOrWhiteSpace(config.Binance.ApiSecret);
|
||||
|
||||
config.CredentialOrigin = haveBoth
|
||||
? fromEnvironment ? CredentialSource.Environment : CredentialSource.ConfigFile
|
||||
: CredentialSource.None;
|
||||
|
||||
if (TryEnvBool("ENCELADO_TESTNET", out bool testnet))
|
||||
string? executionMode = Environment.GetEnvironmentVariable("ENCELADO_EXECUTION_MODE");
|
||||
if (!string.IsNullOrWhiteSpace(executionMode))
|
||||
{
|
||||
config.Binance.Testnet = testnet;
|
||||
}
|
||||
|
||||
if (TryEnvBool("ENCELADO_DRY_RUN", out bool dryRun))
|
||||
{
|
||||
config.Engine.DryRun = dryRun;
|
||||
config.Run.ExecutionMode = ExecutionModeExtensions.TryParse(executionMode, out ExecutionMode m) ? m.ToString() : executionMode.Trim();
|
||||
}
|
||||
|
||||
string? level = Environment.GetEnvironmentVariable("ENCELADO_LOG_LEVEL");
|
||||
@@ -359,20 +240,12 @@ public static class ConfigLoader
|
||||
{
|
||||
config.Logging.Level = level.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryEnvBool(string name, out bool value)
|
||||
{
|
||||
string? raw = Environment.GetEnvironmentVariable(name);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
string? zone = Environment.GetEnvironmentVariable("ENCELADO_TIME_ZONE");
|
||||
if (!string.IsNullOrWhiteSpace(zone))
|
||||
{
|
||||
value = false;
|
||||
return false;
|
||||
config.Ui.TimeZone = zone.Trim();
|
||||
}
|
||||
|
||||
raw = raw.Trim();
|
||||
value = raw is "1" or "true" or "True" or "TRUE" or "yes" or "YES" or "on";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IEnumerable<JsonProperty> Properties(JsonElement e, string section, List<string> warnings)
|
||||
@@ -418,7 +291,7 @@ public static class ConfigLoader
|
||||
JsonValueKind.True => true,
|
||||
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}' deve essere un booleano."),
|
||||
JsonValueKind.String => (p.Value.GetString() ?? string.Empty).Trim().ToLowerInvariant() is "true" or "1" or "yes" or "sì" or "si" or "on",
|
||||
_ => throw new InvalidOperationException($"'{p.Name}' deve essere vero o falso."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
using Encelado.Binance;
|
||||
using Encelado.Binance.Rest;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <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
|
||||
{
|
||||
CredentialSource.ConfigFile => $"file di configurazione ({MaskedKey})",
|
||||
CredentialSource.Environment => $"variabili d'ambiente ({MaskedKey})",
|
||||
CredentialSource.SavedStore => $"chiavi salvate ({MaskedKey})",
|
||||
CredentialSource.Interactive => $"inserite a mano ({MaskedKey})",
|
||||
_ => "nessuna credenziale",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves in order of explicitness: environment variables (automation), then keys
|
||||
/// saved by the user, then the config file. Mutates <paramref name="config"/> with
|
||||
/// whatever it settles on.
|
||||
/// </summary>
|
||||
public static CredentialLookup Resolve(BotConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
if (config.CredentialOrigin == CredentialSource.Environment)
|
||||
{
|
||||
return new CredentialLookup(true, CredentialSource.Environment,
|
||||
CredentialStore.Mask(config.Binance.ApiKey));
|
||||
}
|
||||
|
||||
if (CredentialStore.Load(config.Binance.Testnet) is { } saved)
|
||||
{
|
||||
config.Binance.ApiKey = saved.ApiKey;
|
||||
config.Binance.ApiSecret = saved.ApiSecret;
|
||||
config.CredentialOrigin = CredentialSource.SavedStore;
|
||||
return new CredentialLookup(true, CredentialSource.SavedStore, CredentialStore.Mask(saved.ApiKey));
|
||||
}
|
||||
|
||||
if (config.CredentialOrigin == CredentialSource.ConfigFile)
|
||||
{
|
||||
return new CredentialLookup(true, CredentialSource.ConfigFile,
|
||||
CredentialStore.Mask(config.Binance.ApiKey));
|
||||
}
|
||||
|
||||
return new CredentialLookup(false, CredentialSource.None, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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, FuturesAccount? Account)> VerifyAsync(
|
||||
string apiKey,
|
||||
string apiSecret,
|
||||
bool testnet,
|
||||
CancellationToken ct)
|
||||
{
|
||||
BinanceOptions probe = new()
|
||||
{
|
||||
ApiKey = apiKey,
|
||||
ApiSecret = apiSecret,
|
||||
Testnet = testnet,
|
||||
HttpTimeout = TimeSpan.FromSeconds(20),
|
||||
MaxRetries = 1,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
probe.Validate();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return (false, ex.Message, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using BinanceFuturesClient client = new(probe);
|
||||
await client.WarmupAsync(ct).ConfigureAwait(false);
|
||||
|
||||
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 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 apiKey, string apiSecret, bool save)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
config.Binance.ApiKey = apiKey;
|
||||
config.Binance.ApiSecret = apiSecret;
|
||||
config.CredentialOrigin = CredentialSource.Interactive;
|
||||
|
||||
if (save)
|
||||
{
|
||||
CredentialStore.Save(config.Binance.Testnet, apiKey, apiSecret);
|
||||
config.CredentialOrigin = CredentialSource.SavedStore;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>An API key pair as it was saved.</summary>
|
||||
public readonly record struct StoredCredentials(string ApiKey, string ApiSecret);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// matters for a bot that has to restart unattended. On other platforms DPAPI does not
|
||||
/// exist, so the file is written as plain JSON with owner-only permissions and
|
||||
/// <see cref="IsEncrypted"/> reports <see langword="false"/> so callers can warn.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CredentialStore
|
||||
{
|
||||
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>
|
||||
public static bool IsEncrypted => OperatingSystem.IsWindows();
|
||||
|
||||
/// <summary>
|
||||
/// Where the store lives. <c>ENCELADO_HOME</c> overrides it, which keeps portable
|
||||
/// installs and containers self-contained — and lets the tests run without ever
|
||||
/// touching the real user profile. Read on every access so it stays overridable.
|
||||
/// </summary>
|
||||
public static string DirectoryPath =>
|
||||
Environment.GetEnvironmentVariable("ENCELADO_HOME") is { Length: > 0 } custom
|
||||
? custom
|
||||
: Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Encelado");
|
||||
|
||||
public static string FilePath => Path.Combine(DirectoryPath, "credentials.dat");
|
||||
|
||||
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 testnet)
|
||||
{
|
||||
Dictionary<string, StoredCredentials> all = LoadAll();
|
||||
return all.TryGetValue(testnet ? TestnetKey : LiveKey, out StoredCredentials found) ? found : null;
|
||||
}
|
||||
|
||||
public static void Save(bool testnet, string apiKey, string apiSecret)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(apiKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(apiSecret);
|
||||
|
||||
Dictionary<string, StoredCredentials> all = LoadAll();
|
||||
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 testnet)
|
||||
{
|
||||
Dictionary<string, StoredCredentials> all = LoadAll();
|
||||
if (!all.Remove(testnet ? TestnetKey : LiveKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (all.Count == 0)
|
||||
{
|
||||
Delete();
|
||||
}
|
||||
else
|
||||
{
|
||||
Write(all);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ClearAll()
|
||||
{
|
||||
if (!Exists)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips control characters, byte-order marks and stray spacing from a pasted
|
||||
/// credential. Keys copied out of a browser routinely carry a zero-width space or a
|
||||
/// BOM, which would surface much later as an opaque "invalid char encoding" failure
|
||||
/// deep inside the HTTP stack.
|
||||
/// </summary>
|
||||
public static string? Clean(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<char> buffer = raw.Length <= 256 ? stackalloc char[raw.Length] : new char[raw.Length];
|
||||
int length = 0;
|
||||
|
||||
foreach (char c in raw)
|
||||
{
|
||||
if (!char.IsControl(c) && c != '\uFEFF' && c != '\u200B' && c != '\u00A0')
|
||||
{
|
||||
buffer[length++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
string cleaned = new string(buffer[..length]).Trim();
|
||||
return cleaned.Length == 0 ? null : cleaned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 "(vuota)";
|
||||
}
|
||||
|
||||
if (value.Length <= 6)
|
||||
{
|
||||
return new string('*', value.Length);
|
||||
}
|
||||
|
||||
return value[..6] + new string('*', Math.Min(12, value.Length - 6));
|
||||
}
|
||||
|
||||
private static Dictionary<string, StoredCredentials> LoadAll()
|
||||
{
|
||||
Dictionary<string, StoredCredentials> result = new(StringComparer.OrdinalIgnoreCase);
|
||||
if (!File.Exists(FilePath))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
byte[] raw;
|
||||
try
|
||||
{
|
||||
raw = File.ReadAllBytes(FilePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
byte[] plaintext;
|
||||
try
|
||||
{
|
||||
plaintext = Unprotect(raw);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
// Written by a different Windows user, or the file is corrupt. Treat it as
|
||||
// absent so the caller falls back to prompting instead of crashing.
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(plaintext);
|
||||
foreach (JsonProperty entry in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
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(apiKey) && !string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
result[entry.Name] = new StoredCredentials(apiKey, secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Write(Dictionary<string, StoredCredentials> all)
|
||||
{
|
||||
ArrayBufferWriter<byte> buffer = new(256);
|
||||
using (Utf8JsonWriter w = new(buffer))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
foreach ((string environment, StoredCredentials credentials) in all)
|
||||
{
|
||||
w.WriteStartObject(environment);
|
||||
w.WriteString("apiKey", credentials.ApiKey);
|
||||
w.WriteString("apiSecret", credentials.ApiSecret);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
||||
|
||||
byte[] payload = Protect(buffer.WrittenSpan);
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(FilePath, payload);
|
||||
RestrictPermissions(FilePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(payload);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Delete()
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(FilePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Nothing more we can do; the caller reports the path.
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Protect(ReadOnlySpan<byte> plaintext)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return ProtectedData.Protect(plaintext.ToArray(), optionalEntropy: null, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
|
||||
return plaintext.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] Unprotect(byte[] stored)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return ProtectedData.Unprotect(stored, optionalEntropy: null, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/// <summary>Owner-only access. On Windows DPAPI already scopes the data to the user.</summary>
|
||||
private static void RestrictPermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
|
||||
{
|
||||
// Best effort: the caller already warns that the file is not encrypted here.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Bot.Configuration;
|
||||
|
||||
/// <summary>The two long-lived keys eToro issues, for one environment.</summary>
|
||||
public sealed record EtoroKeys(string ApiKey, string UserKey, DateTime SavedUtc)
|
||||
{
|
||||
public bool IsComplete => ApiKey.Length > 0 && UserKey.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the eToro keys outside the repository, per user and per environment (demo
|
||||
/// and real are different keys), in <c>%LOCALAPPDATA%\Encelado\etoro.dat</c>.
|
||||
/// <para>
|
||||
/// On Windows the file is encrypted with DPAPI bound to the current user account, so it
|
||||
/// needs no passphrase and survives an unattended restart; elsewhere it is plain JSON
|
||||
/// with owner-only permissions and <see cref="IsEncrypted"/> says so.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class EtoroKeyStore
|
||||
{
|
||||
public static bool IsEncrypted => OperatingSystem.IsWindows();
|
||||
|
||||
/// <summary>
|
||||
/// Where the store lives. <c>ENCELADO_HOME</c> overrides it, which keeps portable
|
||||
/// installs self-contained and lets the tests run without touching the real profile.
|
||||
/// </summary>
|
||||
public static string DirectoryPath =>
|
||||
Environment.GetEnvironmentVariable("ENCELADO_HOME") is { Length: > 0 } custom
|
||||
? custom
|
||||
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Encelado");
|
||||
|
||||
public static string FilePath => Path.Combine(DirectoryPath, "etoro.dat");
|
||||
|
||||
public static bool Exists => File.Exists(FilePath);
|
||||
|
||||
public static EtoroKeys? Load(bool demo)
|
||||
{
|
||||
Dictionary<string, EtoroKeys> all = LoadAll();
|
||||
return all.TryGetValue(Key(demo), out EtoroKeys? found) ? found : null;
|
||||
}
|
||||
|
||||
public static void Save(bool demo, EtoroKeys keys)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
Dictionary<string, EtoroKeys> all = LoadAll();
|
||||
all[Key(demo)] = keys;
|
||||
Write(all);
|
||||
}
|
||||
|
||||
public static bool Clear(bool demo)
|
||||
{
|
||||
Dictionary<string, EtoroKeys> all = LoadAll();
|
||||
if (!all.Remove(Key(demo)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (all.Count == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(FilePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The caller reports the path; nothing more to do.
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write(all);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips control characters, byte-order marks and stray spacing from a pasted key.
|
||||
/// Keys copied out of a browser routinely carry a zero-width space, which would
|
||||
/// surface much later as an opaque 401 deep inside the stack.
|
||||
/// </summary>
|
||||
public static string? Clean(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<char> buffer = raw.Length <= 256 ? stackalloc char[raw.Length] : new char[raw.Length];
|
||||
int length = 0;
|
||||
|
||||
foreach (char c in raw)
|
||||
{
|
||||
if (!char.IsControl(c) && c != '' && c != '' && c != ' ')
|
||||
{
|
||||
buffer[length++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
string cleaned = new string(buffer[..length]).Trim();
|
||||
return cleaned.Length == 0 ? null : cleaned;
|
||||
}
|
||||
|
||||
/// <summary>Masks a secret for display: the first six characters, then stars. Never the whole key.</summary>
|
||||
public static string Mask(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "(vuota)";
|
||||
}
|
||||
|
||||
if (value.Length <= 6)
|
||||
{
|
||||
return new string('*', value.Length);
|
||||
}
|
||||
|
||||
return value[..6] + new string('*', Math.Min(12, value.Length - 6));
|
||||
}
|
||||
|
||||
/// <summary>Installs keys into the configuration: from the environment first, then from the store.</summary>
|
||||
public static bool Resolve(BotConfig config, out string origin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
if (config.Etoro.HasKeys)
|
||||
{
|
||||
origin = $"variabili d'ambiente ({Mask(config.Etoro.ApiKey)})";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Load(config.Etoro.IsDemo) is { IsComplete: true } saved)
|
||||
{
|
||||
config.Etoro.ApiKey = saved.ApiKey;
|
||||
config.Etoro.UserKey = saved.UserKey;
|
||||
origin = $"chiavi salvate ({Mask(saved.ApiKey)}, {saved.SavedUtc:yyyy-MM-dd})";
|
||||
return true;
|
||||
}
|
||||
|
||||
origin = "nessuna chiave eToro";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Key(bool demo) => demo ? "demo" : "real";
|
||||
|
||||
private static Dictionary<string, EtoroKeys> LoadAll()
|
||||
{
|
||||
Dictionary<string, EtoroKeys> result = new(StringComparer.OrdinalIgnoreCase);
|
||||
if (!File.Exists(FilePath))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
byte[] raw;
|
||||
try
|
||||
{
|
||||
raw = File.ReadAllBytes(FilePath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
byte[] plaintext;
|
||||
try
|
||||
{
|
||||
plaintext = OperatingSystem.IsWindows()
|
||||
? ProtectedData.Unprotect(raw, optionalEntropy: null, DataProtectionScope.CurrentUser)
|
||||
: raw;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
// Written by a different Windows user, or corrupt: treated as absent so the
|
||||
// caller prompts instead of crashing.
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(plaintext);
|
||||
foreach (JsonProperty entry in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
JsonElement v = entry.Value;
|
||||
string api = Get(v, "apiKey");
|
||||
string user = Get(v, "userKey");
|
||||
DateTime saved = DateTime.TryParse(Get(v, "savedUtc"), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t) ? t : DateTime.MinValue;
|
||||
if (api.Length > 0 && user.Length > 0)
|
||||
{
|
||||
result[entry.Name] = new EtoroKeys(api, user, saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
static string Get(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out JsonElement p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? string.Empty : string.Empty;
|
||||
}
|
||||
|
||||
private static void Write(Dictionary<string, EtoroKeys> all)
|
||||
{
|
||||
ArrayBufferWriter<byte> buffer = new(512);
|
||||
using (Utf8JsonWriter w = new(buffer))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
foreach ((string environment, EtoroKeys k) in all)
|
||||
{
|
||||
w.WriteStartObject(environment);
|
||||
w.WriteString("apiKey", k.ApiKey);
|
||||
w.WriteString("userKey", k.UserKey);
|
||||
w.WriteString("savedUtc", k.SavedUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
|
||||
byte[] payload = OperatingSystem.IsWindows()
|
||||
? ProtectedData.Protect(buffer.WrittenSpan.ToArray(), optionalEntropy: null, DataProtectionScope.CurrentUser)
|
||||
: buffer.WrittenSpan.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(FilePath, payload);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(FilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
|
||||
{
|
||||
// Best effort; the login window already warns that the file is not encrypted here.
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Journal;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
|
||||
namespace Encelado.Bot.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Structured, machine-readable record of everything the engine decided and why.
|
||||
/// <para>
|
||||
/// Two CSV files, joined on <c>decisionId</c>:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <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 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 exchange's answer for each leg.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Every table the bot writes uses <c>;</c> as its separator and carries a
|
||||
/// <c>motivazione</c> column that says in plain language why the row exists. The
|
||||
/// separator is what makes the file open correctly in an Italian-locale spreadsheet
|
||||
/// without an import wizard; the column is what makes the row readable without the
|
||||
/// code that wrote it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two things learned from a real run are enforced here. A file whose first line is not
|
||||
/// this header is moved aside rather than appended to — the previous run had left an
|
||||
/// executions file with the old column layout, and a decisions file with no header at
|
||||
/// all. And the decision id is seeded from the clock, because a counter that restarts
|
||||
/// at one on every launch produced two unrelated rows with <c>decisionId = 1</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AnalyticsLog : IDisposable
|
||||
{
|
||||
public const char Separator = ';';
|
||||
|
||||
public 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;" +
|
||||
"bidA;askA;spreadPctA;bidB;askB;spreadPctB;quoteAgeSec;" +
|
||||
"fundingA;fundingB;equity;halted;probPredetta;motivazione";
|
||||
|
||||
public const string ExecutionHeader =
|
||||
"timestampUtc;decisionId;pair;phase;approved;reason;" +
|
||||
"sideA;notionalA;quantityA;priceA;sideB;notionalB;quantityB;priceB;" +
|
||||
"netFundingRate;equity;availableBalance;grossExposure;openPairs;" +
|
||||
"orderIdA;orderIdB;error;latencyMs;motivazione";
|
||||
|
||||
private readonly StreamWriter? _decisions;
|
||||
private readonly StreamWriter? _executions;
|
||||
private readonly IJournalSink? _sink;
|
||||
private readonly Lock _gate = new();
|
||||
private readonly StringBuilder _row = new(768);
|
||||
|
||||
private long _nextId;
|
||||
private DateTime _lastFlush = DateTime.UtcNow;
|
||||
|
||||
public AnalyticsLog(LoggingOptions options, IJournalSink? sink = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_sink = sink;
|
||||
|
||||
_decisions = CsvTable.Open(options.ResolvePath(options.DecisionLog), DecisionHeader);
|
||||
_executions = CsvTable.Open(options.ResolvePath(options.ExecutionLog), ExecutionHeader);
|
||||
|
||||
// Milliseconds since the epoch, times a thousand: unique across restarts, and
|
||||
// still monotonic within one run for anything under a thousand decisions a
|
||||
// millisecond.
|
||||
_nextId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000;
|
||||
}
|
||||
|
||||
public bool IsEnabled => _decisions is not null || _executions is not null;
|
||||
|
||||
/// <summary>Allocates the id that ties a decision row to its execution rows.</summary>
|
||||
public long NextDecisionId() => Interlocked.Increment(ref _nextId);
|
||||
|
||||
/// <summary>Records one aligned-bar evaluation. Called on the market-data thread.</summary>
|
||||
public void Decision(
|
||||
long decisionId,
|
||||
string pair,
|
||||
in Bar barA,
|
||||
in Bar barB,
|
||||
LegSnapshot legA,
|
||||
LegSnapshot legB,
|
||||
StatArbStrategy strategy,
|
||||
in PairPositionView position,
|
||||
in PairSignal signal,
|
||||
string explanation,
|
||||
double quoteAgeSeconds,
|
||||
double equity,
|
||||
bool halted,
|
||||
double metaProbability = double.NaN)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(strategy);
|
||||
|
||||
PairCalibration c = strategy.Calibration;
|
||||
string motivazione = signal.Reason.Length > 0 ? signal.Reason : explanation;
|
||||
|
||||
_sink?.Decision(new DecisionRow(
|
||||
barA.TimeUtc, decisionId, pair, legA.Symbol, legB.Symbol, strategy.IsReady,
|
||||
barA.Close, barB.Close, strategy.Spread, strategy.ZScore, c.Beta, c.Alpha, c.PValue,
|
||||
c.AdfStatistic, c.HalfLifeBars, c.IsCointegrated, position.QuantityA, position.QuantityB,
|
||||
position.EntryZScore, position.BarsHeld, signal.Kind.ToString(), legA.SpreadPct,
|
||||
legB.SpreadPct, legA.FundingRate, legB.FundingRate, equity, halted, metaProbability,
|
||||
motivazione));
|
||||
|
||||
if (_decisions is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_row.Clear();
|
||||
|
||||
Add(barA.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
Add(decisionId);
|
||||
Add(pair);
|
||||
Add(legA.Symbol);
|
||||
Add(legB.Symbol);
|
||||
Add(strategy.IsReady ? 1 : 0);
|
||||
|
||||
Add(barA.Close);
|
||||
Add(barB.Close);
|
||||
Add(barA.Volume);
|
||||
Add(barB.Volume);
|
||||
Add(barA.Delta);
|
||||
Add(barB.Delta);
|
||||
|
||||
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(position.QuantityA);
|
||||
Add(position.QuantityB);
|
||||
Add(position.EntryZScore);
|
||||
Add(position.BarsHeld);
|
||||
Add(signal.Kind.ToString());
|
||||
|
||||
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(halted ? 1 : 0);
|
||||
|
||||
// The meta-model's probability that this entry pays off; empty when there is
|
||||
// no model or the row is not an entry.
|
||||
Add(double.IsFinite(metaProbability)
|
||||
? metaProbability.ToString("F4", CultureInfo.InvariantCulture)
|
||||
: string.Empty);
|
||||
|
||||
// The signal's own reason when there is one; otherwise the strategy's
|
||||
// explanation of why it is waiting, so a row of "None" still says something.
|
||||
Add(motivazione, last: true);
|
||||
|
||||
_decisions.WriteLine(_row.ToString());
|
||||
MaybeFlush();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records what the order path did with a signal.</summary>
|
||||
public void Execution(in ExecutionRecord record)
|
||||
{
|
||||
_sink?.Execution(new ExecutionRow(
|
||||
DateTime.UtcNow, record.DecisionId, record.Pair, record.Phase, record.Approved, record.Reason,
|
||||
Describe(record.SideA), record.NotionalA, record.QuantityA, record.PriceA,
|
||||
Describe(record.SideB), record.NotionalB, record.QuantityB, record.PriceB,
|
||||
record.NetFundingRate, record.Equity, record.AvailableBalance, record.GrossExposure,
|
||||
record.OpenPairs, record.OrderIdA, record.OrderIdB, record.Error, record.LatencyMs,
|
||||
record.Detail));
|
||||
|
||||
if (_executions is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_row.Clear();
|
||||
|
||||
Add(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
|
||||
Add(record.DecisionId);
|
||||
Add(record.Pair);
|
||||
Add(record.Phase);
|
||||
Add(record.Approved ? 1 : 0);
|
||||
Add(record.Reason);
|
||||
|
||||
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);
|
||||
Add(record.Detail, last: true);
|
||||
|
||||
_executions.WriteLine(_row.ToString());
|
||||
MaybeFlush();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(Side side) => side switch
|
||||
{
|
||||
Side.Buy => "BUY",
|
||||
Side.Sell => "SELL",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private void Add(double value, bool last = false)
|
||||
{
|
||||
if (double.IsFinite(value))
|
||||
{
|
||||
_row.Append(value.ToString("G10", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!last)
|
||||
{
|
||||
_row.Append(Separator);
|
||||
}
|
||||
}
|
||||
|
||||
private void Add(long value, bool last = false)
|
||||
{
|
||||
_row.Append(value.ToString(CultureInfo.InvariantCulture));
|
||||
if (!last)
|
||||
{
|
||||
_row.Append(Separator);
|
||||
}
|
||||
}
|
||||
|
||||
private void Add(string? value, bool last = false)
|
||||
{
|
||||
CsvTable.AppendField(_row, value);
|
||||
|
||||
if (!last)
|
||||
{
|
||||
_row.Append(Separator);
|
||||
}
|
||||
}
|
||||
|
||||
private void MaybeFlush()
|
||||
{
|
||||
if (DateTime.UtcNow - _lastFlush < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastFlush = DateTime.UtcNow;
|
||||
Flush();
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
_decisions?.Flush();
|
||||
_executions?.Flush();
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Log.Warn($"scrittura analytics fallita: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
_decisions?.Flush();
|
||||
_executions?.Flush();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best effort on shutdown.
|
||||
}
|
||||
|
||||
_decisions?.Dispose();
|
||||
_executions?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one way every <c>;</c>-separated table in the bot is opened and written.
|
||||
/// </summary>
|
||||
public static class CsvTable
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens a table for appending, writing the header when the file is new and moving
|
||||
/// the file aside when its first line is some <i>other</i> header.
|
||||
/// <para>
|
||||
/// Appending rows in one layout under a header in another is worse than losing the
|
||||
/// old rows: the file still opens, every column is silently one place off, and the
|
||||
/// analysis built on it is wrong without a single error. The old file is kept under
|
||||
/// <c>.old</c> so nothing is actually lost.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static StreamWriter? Open(string? path, string header)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string full = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
|
||||
if (File.Exists(full) && new FileInfo(full).Length > 0 && !HasHeader(full, header))
|
||||
{
|
||||
string aside = Path.Combine(
|
||||
Path.GetDirectoryName(full)!,
|
||||
Path.GetFileNameWithoutExtension(full) + ".old" + Path.GetExtension(full));
|
||||
|
||||
File.Move(full, aside, overwrite: true);
|
||||
Log.Warn($"{Path.GetFileName(full)} aveva colonne di una versione precedente: spostato in {Path.GetFileName(aside)}");
|
||||
}
|
||||
|
||||
FileStream stream = new(full, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 16384);
|
||||
StreamWriter writer = new(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
|
||||
{
|
||||
AutoFlush = false,
|
||||
};
|
||||
|
||||
if (stream.Length == 0)
|
||||
{
|
||||
writer.WriteLine(header);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
return writer;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"impossibile aprire la tabella {path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasHeader(string path, string header)
|
||||
{
|
||||
try
|
||||
{
|
||||
using StreamReader reader = new(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return string.Equals(reader.ReadLine(), header, StringComparison.Ordinal);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends one text field, quoted only when it contains the separator, a quote or a
|
||||
/// line break. Line breaks become spaces: a row is one line, always.
|
||||
/// </summary>
|
||||
public static void AppendField(StringBuilder row, string? value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.AsSpan().IndexOfAny(";\"\r\n") < 0)
|
||||
{
|
||||
row.Append(value);
|
||||
return;
|
||||
}
|
||||
|
||||
row.Append('"');
|
||||
foreach (char c in value)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
row.Append('"');
|
||||
}
|
||||
|
||||
row.Append(c is '\r' or '\n' ? ' ' : c);
|
||||
}
|
||||
|
||||
row.Append('"');
|
||||
}
|
||||
}
|
||||
|
||||
/// <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: 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; }
|
||||
|
||||
/// <summary>The plain-language why. Becomes the <c>motivazione</c> column.</summary>
|
||||
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; }
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Encelado.Bot.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-bucket latency histogram. Recording is a single interlocked increment, so it
|
||||
/// can sit directly on the decision path without perturbing what it measures.
|
||||
/// </summary>
|
||||
public sealed class LatencyHistogram(string name)
|
||||
{
|
||||
private static readonly long[] BoundsMicros =
|
||||
[50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 1_000_000, long.MaxValue];
|
||||
|
||||
private readonly long[] _buckets = new long[BoundsMicros.Length];
|
||||
private long _count;
|
||||
private long _sumMicros;
|
||||
private long _maxMicros;
|
||||
|
||||
public string Name { get; } = name;
|
||||
|
||||
public long Count => Interlocked.Read(ref _count);
|
||||
|
||||
public void Record(long micros)
|
||||
{
|
||||
if (micros < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
while (index < BoundsMicros.Length - 1 && micros > BoundsMicros[index])
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _buckets[index]);
|
||||
Interlocked.Increment(ref _count);
|
||||
Interlocked.Add(ref _sumMicros, micros);
|
||||
|
||||
long observedMax = Interlocked.Read(ref _maxMicros);
|
||||
while (micros > observedMax)
|
||||
{
|
||||
long previous = Interlocked.CompareExchange(ref _maxMicros, micros, observedMax);
|
||||
if (previous == observedMax)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
observedMax = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records the elapsed time since a <see cref="Stopwatch.GetTimestamp"/> reading.</summary>
|
||||
public void RecordSince(long startTimestamp) =>
|
||||
Record((long)Stopwatch.GetElapsedTime(startTimestamp).TotalMicroseconds);
|
||||
|
||||
public string Summary()
|
||||
{
|
||||
long total = Interlocked.Read(ref _count);
|
||||
if (total == 0)
|
||||
{
|
||||
return $"{Name}: no samples";
|
||||
}
|
||||
|
||||
double mean = Interlocked.Read(ref _sumMicros) / (double)total;
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Name}: n={total} avg={Format(mean)} p50={Format(Percentile(0.50, total))} " +
|
||||
$"p95={Format(Percentile(0.95, total))} p99={Format(Percentile(0.99, total))} " +
|
||||
$"max={Format(Interlocked.Read(ref _maxMicros))}");
|
||||
}
|
||||
|
||||
/// <summary>Upper bound of the bucket containing the requested percentile.</summary>
|
||||
private double Percentile(double percentile, long total)
|
||||
{
|
||||
long target = (long)Math.Ceiling(percentile * total);
|
||||
long running = 0;
|
||||
for (int i = 0; i < _buckets.Length; i++)
|
||||
{
|
||||
running += Interlocked.Read(ref _buckets[i]);
|
||||
if (running >= target)
|
||||
{
|
||||
return BoundsMicros[i] == long.MaxValue ? BoundsMicros[^2] : BoundsMicros[i];
|
||||
}
|
||||
}
|
||||
|
||||
return BoundsMicros[^2];
|
||||
}
|
||||
|
||||
private static string Format(double micros) =>
|
||||
micros >= 1000
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{micros / 1000:F1}ms")
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{micros:F0}us");
|
||||
}
|
||||
|
||||
/// <summary>Process-wide counters and latency traces for the trading loop.</summary>
|
||||
public sealed class Metrics
|
||||
{
|
||||
private long _trades;
|
||||
private long _quotes;
|
||||
private long _bars;
|
||||
private long _signals;
|
||||
private long _ordersSubmitted;
|
||||
private long _ordersFilled;
|
||||
private long _orderErrors;
|
||||
private long _riskRejects;
|
||||
private long _exits;
|
||||
|
||||
public LatencyHistogram BarToSignal { get; } = new("bar->signal");
|
||||
|
||||
public LatencyHistogram SignalToOrder { get; } = new("signal->order");
|
||||
|
||||
public long Trades => Interlocked.Read(ref _trades);
|
||||
|
||||
public long Quotes => Interlocked.Read(ref _quotes);
|
||||
|
||||
public long Bars => Interlocked.Read(ref _bars);
|
||||
|
||||
public long Signals => Interlocked.Read(ref _signals);
|
||||
|
||||
public long OrdersSubmitted => Interlocked.Read(ref _ordersSubmitted);
|
||||
|
||||
public long OrdersFilled => Interlocked.Read(ref _ordersFilled);
|
||||
|
||||
public long OrderErrors => Interlocked.Read(ref _orderErrors);
|
||||
|
||||
public long RiskRejects => Interlocked.Read(ref _riskRejects);
|
||||
|
||||
public long Exits => Interlocked.Read(ref _exits);
|
||||
|
||||
public void CountTrade() => Interlocked.Increment(ref _trades);
|
||||
|
||||
public void CountQuote() => Interlocked.Increment(ref _quotes);
|
||||
|
||||
public void CountBar() => Interlocked.Increment(ref _bars);
|
||||
|
||||
public void CountSignal() => Interlocked.Increment(ref _signals);
|
||||
|
||||
public void CountOrderSubmitted() => Interlocked.Increment(ref _ordersSubmitted);
|
||||
|
||||
public void CountOrderFilled() => Interlocked.Increment(ref _ordersFilled);
|
||||
|
||||
public void CountOrderError() => Interlocked.Increment(ref _orderErrors);
|
||||
|
||||
public void CountRiskReject() => Interlocked.Increment(ref _riskRejects);
|
||||
|
||||
public void CountExit() => Interlocked.Increment(ref _exits);
|
||||
|
||||
public string Summary()
|
||||
{
|
||||
StringBuilder sb = new(256);
|
||||
sb.Append(CultureInfo.InvariantCulture, $"ticks={Trades} quotes={Quotes} bars={Bars} ")
|
||||
.Append(CultureInfo.InvariantCulture, $"signals={Signals} orders={OrdersSubmitted} fills={OrdersFilled} ")
|
||||
.Append(CultureInfo.InvariantCulture, $"exits={Exits} riskRejects={RiskRejects} errors={OrderErrors}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
|
||||
<ProjectReference Include="..\Encelado.Binance\Encelado.Binance.csproj" />
|
||||
<ProjectReference Include="..\Encelado.Storage\Encelado.Storage.csproj" />
|
||||
<ProjectReference Include="..\Encelado.Etoro\Encelado.Etoro.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- DPAPI (System.Security.Cryptography.ProtectedData) ships inside the Windows
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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 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 _walletBalance;
|
||||
private double _availableBalance;
|
||||
private double _unrealizedPnl;
|
||||
private double _marginRatio;
|
||||
private bool _canTrade;
|
||||
|
||||
// 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 WalletBalance => Volatile.Read(ref _walletBalance);
|
||||
|
||||
/// <summary>Free margin, i.e. what a new position can actually be opened against.</summary>
|
||||
public double AvailableBalance => Volatile.Read(ref _availableBalance);
|
||||
|
||||
public double UnrealizedPnl => Volatile.Read(ref _unrealizedPnl);
|
||||
|
||||
/// <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 DateTime LastUpdateUtc { get; private set; }
|
||||
|
||||
public bool HasData => Equity > 0 || WalletBalance > 0;
|
||||
|
||||
/// <summary>The last full account payload, or null before the first reconcile.</summary>
|
||||
public FuturesAccount? Raw => Volatile.Read(ref _raw);
|
||||
|
||||
public void Update(FuturesAccount account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
|
||||
Volatile.Write(ref _raw, account);
|
||||
Volatile.Write(ref _equity, (double)account.Equity);
|
||||
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);
|
||||
LastUpdateUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 void UpdateWalletBalance(double walletBalance)
|
||||
{
|
||||
if (walletBalance <= 0 || !double.IsFinite(walletBalance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _walletBalance, walletBalance);
|
||||
LastUpdateUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Bot.Ui;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
@@ -11,172 +12,120 @@ public enum BotState
|
||||
Faulted,
|
||||
}
|
||||
|
||||
/// <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(
|
||||
/// <summary>One basket as the dashboard shows it: the row of the Titany screenshot, plus the numbers behind it.</summary>
|
||||
public sealed record BasketRow(
|
||||
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 PairA,
|
||||
string PairB,
|
||||
string Cross,
|
||||
string State,
|
||||
int OpenLegs,
|
||||
double PnlUsd,
|
||||
double PnlPct,
|
||||
double Pips,
|
||||
double TpPips,
|
||||
double Rho,
|
||||
double RhoShort,
|
||||
double Z,
|
||||
double CostPips,
|
||||
double PMl,
|
||||
bool MlActive,
|
||||
string NextEvent,
|
||||
bool Enabled,
|
||||
string DisabledReason,
|
||||
string Intent,
|
||||
string? LastRefusal)
|
||||
double EntryZ,
|
||||
int BarsHeld,
|
||||
int Adds,
|
||||
bool IsOpen,
|
||||
double HalfLife)
|
||||
{
|
||||
public bool IsOpen => Math.Abs(QuantityA) > 1e-12 || Math.Abs(QuantityB) > 1e-12;
|
||||
public string PnlDisplay => PnlUsd.ToString("+#,##0.00;-#,##0.00;0.00", CultureInfo.CurrentCulture);
|
||||
|
||||
public string Direction => !IsOpen
|
||||
? "—"
|
||||
: QuantityA > 0 ? "long spread" : "short spread";
|
||||
public string PnlPctDisplay => PnlPct.ToString("+0.00%;-0.00%;0.00%", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <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
|
||||
public string PipsDisplay => IsOpen ? Pips.ToString("+0.0;-0.0;0.0", CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
public string TpDisplay => TpPips.ToString("0", CultureInfo.CurrentCulture);
|
||||
|
||||
public string RhoDisplay => double.IsFinite(Rho) ? Rho.ToString("0.00", CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
public string ZDisplay => double.IsFinite(Z) ? Z.ToString("+0.00;-0.00;0.00", CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
public string CostDisplay => double.IsFinite(CostPips) ? CostPips.ToString("0.0", CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
public string PMlDisplay => double.IsFinite(PMl) ? PMl.ToString("0.00", CultureInfo.CurrentCulture) + (MlActive ? string.Empty : " (ombra)") : "—";
|
||||
|
||||
public string HalfLifeDisplay => double.IsFinite(HalfLife) ? HalfLife.ToString("0", CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
/// <summary>The state as a short Italian word for the chip.</summary>
|
||||
public string StateLabel => !Enabled ? "off" : State switch
|
||||
{
|
||||
"Idle" => "in attesa",
|
||||
"Entering" => "apertura…",
|
||||
"Open" => "aperto",
|
||||
"Adding" => "aggiunta…",
|
||||
"Exiting" => "chiusura…",
|
||||
"Error" => "errore",
|
||||
"fermo" => "fermo",
|
||||
_ => State.ToLowerInvariant(),
|
||||
};
|
||||
|
||||
public string Tooltip => Enabled ? Intent : DisabledReason;
|
||||
}
|
||||
|
||||
/// <summary>Top of book of one instrument.</summary>
|
||||
public sealed record QuoteRow(string Symbol, double Bid, double Ask, double SpreadPips, DateTime TimeUtc, double AgeSeconds)
|
||||
{
|
||||
public string BidDisplay => Bid > 0 ? Bid.ToString("0.00000", CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
public string AskDisplay => Ask > 0 ? Ask.ToString("0.00000", CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
public string SpreadDisplay => Bid > 0 ? SpreadPips.ToString("0.0", CultureInfo.CurrentCulture) : "—";
|
||||
|
||||
public string AgeDisplay => AgeSeconds < 0 ? "—" : AgeSeconds.ToString("0", CultureInfo.CurrentCulture) + " s";
|
||||
}
|
||||
|
||||
public sealed record SentimentRow(string Currency, double Net1h, double Net4h, double Net24h, double Hawkish, double RiskOff, int Count24h)
|
||||
{
|
||||
public string Net1hDisplay => Net1h.ToString("+0.00;-0.00;0.00", CultureInfo.CurrentCulture);
|
||||
|
||||
public string Net4hDisplay => Net4h.ToString("+0.00;-0.00;0.00", CultureInfo.CurrentCulture);
|
||||
|
||||
public string Net24hDisplay => Net24h.ToString("+0.00;-0.00;0.00", CultureInfo.CurrentCulture);
|
||||
|
||||
public string HawkishDisplay => Hawkish.ToString("+0.00;-0.00;0.00", CultureInfo.CurrentCulture);
|
||||
}
|
||||
|
||||
public sealed record CalendarRow(DateTime TimeUtc, string Currency, string Title, string Impact, string Forecast, string Previous)
|
||||
{
|
||||
/// <summary>The event time in the window's time zone (see <see cref="UiClock"/>).</summary>
|
||||
public string TimeLocal => UiClock.Format(TimeUtc, "ddd dd/MM HH:mm");
|
||||
|
||||
public string InMinutes
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!double.IsFinite(ZScore) || StopZ <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.Clamp(Math.Abs(ZScore) / StopZ, 0, 1);
|
||||
double m = (TimeUtc - DateTime.UtcNow).TotalMinutes;
|
||||
return m < 0 ? "passato" : m < 90 ? $"fra {m:0} min" : $"fra {m / 60:0.0} h";
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
double LastPrice,
|
||||
double MarketValue,
|
||||
double UnrealizedPnl,
|
||||
double UnrealizedPnlPct,
|
||||
DateTime OpenedAtUtc);
|
||||
|
||||
public sealed record MetricRow(string Name, double Value, string Format)
|
||||
{
|
||||
public string Display => Format switch
|
||||
{
|
||||
"P1" => (Value * 100).ToString("F1", CultureInfo.CurrentCulture) + "%",
|
||||
"F0" => Value.ToString("F0", CultureInfo.CurrentCulture),
|
||||
"F4" => Value.ToString("F4", CultureInfo.CurrentCulture),
|
||||
_ => Value.ToString("F2", CultureInfo.CurrentCulture),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A point on the session equity curve.</summary>
|
||||
public sealed record EquityPoint(DateTime TimeUtc, double Equity);
|
||||
/// <summary>The context strip: sentiment, events, volatility, learning.</summary>
|
||||
public sealed record ContextRow(
|
||||
IReadOnlyList<SentimentRow> Sentiment,
|
||||
IReadOnlyList<CalendarRow> NextEvents,
|
||||
string VolForecast,
|
||||
string MlState,
|
||||
string BanditProposal,
|
||||
string CalendarState,
|
||||
string NewsState);
|
||||
|
||||
/// <summary>One line of the activity feed. <paramref name="Time"/> is already in the window's time zone.</summary>
|
||||
public sealed record EventRow(string Time, string Level, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// 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(
|
||||
double WalletBalance,
|
||||
double MarginBalance,
|
||||
double AvailableBalance,
|
||||
double UnrealizedPnl,
|
||||
double MaintenanceMargin,
|
||||
double MarginRatio,
|
||||
int FeeTier,
|
||||
bool CanTrade,
|
||||
bool MultiAssetsMargin,
|
||||
DateTime UpdatedUtc)
|
||||
{
|
||||
/// <summary>Everything that would make the exchange refuse an order, in one line.</summary>
|
||||
public string Restrictions
|
||||
{
|
||||
get
|
||||
{
|
||||
List<string> issues = [];
|
||||
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 list.</summary>
|
||||
public sealed record OrderRow(
|
||||
string OrderId,
|
||||
string Symbol,
|
||||
string Side,
|
||||
string Type,
|
||||
string Status,
|
||||
double Quantity,
|
||||
double FilledQuantity,
|
||||
double FilledAveragePrice,
|
||||
double LimitPrice,
|
||||
bool ReduceOnly,
|
||||
DateTime SubmittedUtc)
|
||||
{
|
||||
public bool IsWorking { get; init; }
|
||||
|
||||
public double Notional => FilledQuantity > 0 && FilledAveragePrice > 0
|
||||
? FilledQuantity * FilledAveragePrice
|
||||
: Quantity * (double.IsFinite(LimitPrice) && LimitPrice > 0 ? LimitPrice : 0);
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
/// <summary>Everything the window renders, produced under one lock and consumed on the UI thread.</summary>
|
||||
public sealed record BotSnapshot
|
||||
{
|
||||
public required BotState State { get; init; }
|
||||
@@ -187,98 +136,74 @@ public sealed record BotSnapshot
|
||||
|
||||
public TimeSpan Uptime { get; init; }
|
||||
|
||||
/// <summary>PAPER, DEMO, LIVE — the badge.</summary>
|
||||
public required string Mode { get; init; }
|
||||
|
||||
public bool Testnet { get; init; }
|
||||
/// <summary><c>paper</c>, <c>demo</c> or <c>live</c>, for the badge colour.</summary>
|
||||
public string EnvironmentKind { get; init; } = "demo";
|
||||
|
||||
public bool DryRun { get; init; }
|
||||
public string ExecutionMode { get; init; } = string.Empty;
|
||||
|
||||
public required string TimeFrame { get; init; }
|
||||
public string Endpoint { get; init; } = string.Empty;
|
||||
|
||||
public required string Endpoint { get; init; }
|
||||
public string Preset { get; init; } = "—";
|
||||
|
||||
public int Leverage { get; init; }
|
||||
public string StrategyVersion { get; init; } = string.Empty;
|
||||
|
||||
public string ApiState { get; init; } = "fermo";
|
||||
|
||||
public double ApiLatencyMs { get; init; } = double.NaN;
|
||||
|
||||
public double ClockSkewSeconds { get; init; }
|
||||
|
||||
// ---- money -----------------------------------------------------------
|
||||
public double Equity { get; init; }
|
||||
|
||||
public double WalletBalance { get; init; }
|
||||
public double Balance { get; init; }
|
||||
|
||||
public double AvailableBalance { get; init; }
|
||||
|
||||
public double UnrealizedPnl { get; init; }
|
||||
public double PeakEquity { get; init; }
|
||||
|
||||
public double RealizedToday { get; init; }
|
||||
public double DrawdownPct { get; init; }
|
||||
|
||||
public double PnlSession { get; init; }
|
||||
/// <summary>The equity stop threshold, as a fraction, so the drawdown tile can show how far it is.</summary>
|
||||
public double EquityStopPct { get; init; }
|
||||
|
||||
public double PnlSessionPct { get; init; }
|
||||
public double DailyLossPct { get; init; }
|
||||
|
||||
public double MarginRatio { get; init; }
|
||||
public double TodayPnl { get; init; }
|
||||
|
||||
public double GrossExposure { get; init; }
|
||||
public double TodayPnlPct { get; init; }
|
||||
|
||||
public double ExposurePct { get; init; }
|
||||
public double OpenPnl { get; init; }
|
||||
|
||||
public double OpenPnlPct { get; init; }
|
||||
|
||||
public int OpenBaskets { get; init; }
|
||||
|
||||
public int MaxBaskets { get; init; }
|
||||
|
||||
// ---- risk -------------------------------------------------------------
|
||||
public bool Halted { get; init; }
|
||||
|
||||
public string? HaltReason { get; init; }
|
||||
|
||||
public int TradesToday { get; init; }
|
||||
public bool EquityStopped { get; init; }
|
||||
|
||||
public int MaxTradesPerDay { get; init; }
|
||||
public bool KillSwitched { get; init; }
|
||||
|
||||
public int OpenPairs { get; init; }
|
||||
/// <summary>Why new entries are blocked while exits still run (clock skew, API errors, data quality), or null.</summary>
|
||||
public string? EntriesBlockedReason { 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 OrderStreamState { get; init; }
|
||||
|
||||
public string? StreamRejection { get; init; }
|
||||
|
||||
public int Reconnects { get; init; }
|
||||
|
||||
public long Quotes { get; init; }
|
||||
|
||||
public long Bars { get; init; }
|
||||
|
||||
public long Signals { get; init; }
|
||||
|
||||
public long Orders { get; init; }
|
||||
|
||||
public long Fills { get; init; }
|
||||
|
||||
public long Exits { get; init; }
|
||||
|
||||
public long RiskRejects { get; init; }
|
||||
|
||||
public long Errors { get; init; }
|
||||
|
||||
public required string BarToSignal { get; init; }
|
||||
|
||||
public required string SignalToOrder { get; init; }
|
||||
|
||||
public IReadOnlyList<PairRow> Pairs { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<PositionRow> Positions { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<EquityPoint> EquityCurve { get; init; } = [];
|
||||
/// <summary>API quota use and the age of the last quote, one line.</summary>
|
||||
public string Counters { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<EventRow> Events { get; init; } = [];
|
||||
|
||||
/// <summary>Null until the first successful reconcile against the exchange.</summary>
|
||||
public AccountRow? Account { get; init; }
|
||||
public IReadOnlyList<BasketRow> Baskets { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<OrderRow> OrderHistory { get; init; } = [];
|
||||
public IReadOnlyList<QuoteRow> Quotes { get; init; } = [];
|
||||
|
||||
public ContextRow? Context { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Result of a start/stop/close request from the UI.</summary>
|
||||
public sealed record CommandResult(bool Ok, string Message);
|
||||
public readonly record struct CommandResult(bool Ok, string Message);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Binance.Rest;
|
||||
using Encelado.Bot.Baskets;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Portfolio;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
using Encelado.Bot.Ui;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
@@ -11,31 +9,30 @@ 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, 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.
|
||||
/// Each start creates a <b>fresh</b> engine. Reusing one would mean resurrecting a
|
||||
/// connection, models 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
public sealed class BotSupervisor(BotConfig config, Func<BotConfig, bool, IEngine>? factory = null) : IAsyncDisposable
|
||||
{
|
||||
private const int EquityCurveCapacity = 1440;
|
||||
|
||||
private readonly int _eventCapacity = Math.Max(20, config.Logging.StatusLines);
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Queue<EquityPoint> _equityCurve = new(EquityCurveCapacity);
|
||||
private readonly Queue<EventRow> _events = new();
|
||||
private readonly Func<BotConfig, bool, IEngine> _factory = factory ?? (static (c, confirmed) => new BasketEngine(c, confirmed));
|
||||
|
||||
private TradingEngine? _engine;
|
||||
private IEngine? _engine;
|
||||
private CancellationTokenSource? _engineCts;
|
||||
private Task? _engineTask;
|
||||
private BotState _state = BotState.Stopped;
|
||||
private string? _error;
|
||||
private DateTime _lastEquitySample;
|
||||
private DateTime? _startedUtc;
|
||||
|
||||
public BotConfig Config => config;
|
||||
|
||||
/// <summary>Set by the shell once the operator has confirmed the live mode at start.</summary>
|
||||
public bool StartConfirmed { get; set; }
|
||||
|
||||
public BotState State
|
||||
{
|
||||
get { lock (_gate) { return _state; } }
|
||||
@@ -70,10 +67,10 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
|
||||
Log.Info("── avvio richiesto ──");
|
||||
|
||||
TradingEngine engine;
|
||||
IEngine engine;
|
||||
try
|
||||
{
|
||||
engine = new TradingEngine(config);
|
||||
engine = _factory(config, StartConfirmed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -88,12 +85,11 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
|
||||
CancellationTokenSource cts = new();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_engine = engine;
|
||||
_engineCts = cts;
|
||||
_equityCurve.Clear();
|
||||
_startedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// RunAsync blocks for the whole session, so it owns a background task and the
|
||||
@@ -124,39 +120,26 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
_error = ex.Message;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await engine.ShutdownAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"arresto: {ex.Message}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_engineTask = task;
|
||||
if (_state == BotState.Starting)
|
||||
{
|
||||
_state = BotState.Running;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// A failure in the first seconds (bad keys, unknown instrument) surfaces here
|
||||
// instead of leaving the window showing "running" on an engine that is gone.
|
||||
await Task.WhenAny(task, Task.Delay(1500)).ConfigureAwait(false);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == BotState.Faulted)
|
||||
{
|
||||
return new CommandResult(false, _error ?? "il motore non è riuscito a partire");
|
||||
}
|
||||
|
||||
if (_state == BotState.Starting)
|
||||
{
|
||||
_state = BotState.Running;
|
||||
return new CommandResult(false, _error ?? "avvio fallito");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +165,25 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
|
||||
Log.Info("── arresto richiesto ──");
|
||||
|
||||
IEngine? engine;
|
||||
lock (_gate)
|
||||
{
|
||||
engine = _engine;
|
||||
}
|
||||
|
||||
if (engine is not null && config.Run.CloseOnShutdown)
|
||||
{
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource closing = new(TimeSpan.FromSeconds(30));
|
||||
await engine.CloseAllAsync("arresto del motore", closing.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Log.Error("chiusura dei basket all'arresto non riuscita", ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (cts is not null)
|
||||
{
|
||||
await cts.CancelAsync().ConfigureAwait(false);
|
||||
@@ -195,14 +197,12 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Log.Warn("il motore non si è fermato entro 45s");
|
||||
Log.Warn("il motore non si è fermato entro 45 s");
|
||||
}
|
||||
}
|
||||
|
||||
TradingEngine? engine;
|
||||
lock (_gate)
|
||||
{
|
||||
engine = _engine;
|
||||
_engine = null;
|
||||
_engineTask = null;
|
||||
_engineCts = null;
|
||||
@@ -219,13 +219,13 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
return new CommandResult(true, "bot fermo");
|
||||
}
|
||||
|
||||
/// <summary>Closes one pair on demand from the pairs list.</summary>
|
||||
public async Task<CommandResult> ClosePairAsync(string pairName, CancellationToken ct)
|
||||
/// <summary>Forwards a command to the running engine.</summary>
|
||||
public async Task<CommandResult> ExecuteAsync(EngineCommand command, CancellationToken ct)
|
||||
{
|
||||
TradingEngine? engine;
|
||||
IEngine? engine;
|
||||
lock (_gate)
|
||||
{
|
||||
engine = _engine;
|
||||
engine = _state == BotState.Running ? _engine : null;
|
||||
}
|
||||
|
||||
if (engine is null)
|
||||
@@ -233,359 +233,38 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
return new CommandResult(false, "il bot non è in esecuzione");
|
||||
}
|
||||
|
||||
if (config.Engine.DryRun)
|
||||
{
|
||||
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.CloseAsync(pair, "chiusa a mano dall'applicazione", ct).ConfigureAwait(false);
|
||||
return new CommandResult(true, $"{pairName}: chiusura richiesta");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"chiusura manuale di {pairName} fallita", ex);
|
||||
return new CommandResult(false, ex.Message);
|
||||
}
|
||||
return await engine.ExecuteAsync(command, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Closes one basket on demand from the window or the console.</summary>
|
||||
public Task<CommandResult> CloseAsync(string basket, CancellationToken ct) =>
|
||||
ExecuteAsync(new EngineCommand(EngineCommandKind.Close, basket, "chiusura manuale"), ct);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Snapshot
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public BotSnapshot Snapshot()
|
||||
{
|
||||
TradingEngine? engine;
|
||||
IEngine? engine;
|
||||
BotState state;
|
||||
string? error;
|
||||
|
||||
DateTime? started;
|
||||
lock (_gate)
|
||||
{
|
||||
engine = _engine;
|
||||
state = _state;
|
||||
error = _error;
|
||||
started = _startedUtc;
|
||||
}
|
||||
|
||||
return engine is null || state is BotState.Stopped or BotState.Faulted
|
||||
? IdleSnapshot(state, error)
|
||||
: LiveSnapshot(engine, state, error);
|
||||
}
|
||||
|
||||
private BotSnapshot IdleSnapshot(BotState state, string? error) => new()
|
||||
{
|
||||
State = state,
|
||||
Error = error,
|
||||
Mode = DescribeMode(),
|
||||
Testnet = config.Binance.Testnet,
|
||||
DryRun = config.Engine.DryRun,
|
||||
TimeFrame = config.Engine.TimeFrame,
|
||||
Endpoint = config.Binance.RestBaseUrl,
|
||||
Leverage = config.Binance.Leverage,
|
||||
MarketDataState = "disconnesso",
|
||||
OrderStreamState = "disconnesso",
|
||||
BarToSignal = "—",
|
||||
SignalToOrder = "—",
|
||||
MaxOpenPairs = config.Risk.MaxOpenPairs,
|
||||
MaxTradesPerDay = config.Risk.MaxTradesPerDay,
|
||||
MaxDailyLossPct = config.Risk.MaxDailyLossPct,
|
||||
Sizing = config.Risk.DescribeSizing(),
|
||||
Pairs = IdlePairs(),
|
||||
EquityCurve = SnapshotEquityCurve(),
|
||||
Events = SnapshotEvents(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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<PairRow> IdlePairs()
|
||||
{
|
||||
List<PairRow> rows = [];
|
||||
|
||||
foreach (PairConfig pc in config.EnabledPairs)
|
||||
EventRow[] events = SnapshotEvents();
|
||||
if (engine is null || state is BotState.Stopped or BotState.Faulted)
|
||||
{
|
||||
StatArbStrategy? strategy = null;
|
||||
try
|
||||
{
|
||||
strategy = new StatArbStrategy(pc.ToStrategyParameters());
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// A misconfigured pair is reported by validation, not here.
|
||||
}
|
||||
|
||||
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 BasketEngine.IdleSnapshot(config, state, error, events);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private BotSnapshot LiveSnapshot(TradingEngine engine, BotState state, string? error)
|
||||
{
|
||||
AccountState account = engine.Account;
|
||||
double equity = account.Equity;
|
||||
|
||||
SampleEquity(equity);
|
||||
|
||||
double sessionBase = engine.StartEquity;
|
||||
|
||||
List<PairRow> pairs = [];
|
||||
List<PositionRow> positions = [];
|
||||
|
||||
IReadOnlyDictionary<string, string> refusals = engine.Router.LastRefusal;
|
||||
|
||||
foreach (PairPipeline pipe in engine.Pairs)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (refusals.TryGetValue(pipe.Name, out string? found))
|
||||
{
|
||||
refusal = found;
|
||||
}
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
double exposure = engine.Book.GrossExposure;
|
||||
int openPairs = pairs.Count(static p => p.IsOpen);
|
||||
|
||||
return new BotSnapshot
|
||||
{
|
||||
State = state,
|
||||
Error = error,
|
||||
StartedAtUtc = engine.StartedAtUtc,
|
||||
Uptime = engine.StartedAtUtc == default
|
||||
? TimeSpan.Zero
|
||||
: DateTime.UtcNow - engine.StartedAtUtc,
|
||||
|
||||
Mode = DescribeMode(),
|
||||
Testnet = config.Binance.Testnet,
|
||||
DryRun = config.Engine.DryRun,
|
||||
TimeFrame = config.Engine.TimeFrame,
|
||||
Endpoint = config.Binance.RestBaseUrl,
|
||||
Leverage = config.Binance.Leverage,
|
||||
|
||||
Equity = equity,
|
||||
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,
|
||||
MarginRatio = account.MarginRatio,
|
||||
GrossExposure = exposure,
|
||||
ExposurePct = equity > 0 ? exposure / equity : 0,
|
||||
|
||||
Halted = engine.Risk.IsHalted,
|
||||
HaltReason = engine.Risk.IsHalted ? engine.Risk.HaltReason : null,
|
||||
TradesToday = engine.Risk.TradesToday,
|
||||
MaxTradesPerDay = config.Risk.MaxTradesPerDay,
|
||||
OpenPairs = openPairs,
|
||||
MaxOpenPairs = config.Risk.MaxOpenPairs,
|
||||
MaxDailyLossPct = config.Risk.MaxDailyLossPct,
|
||||
Sizing = config.Risk.DescribeSizing(),
|
||||
|
||||
MarketDataState = engine.MarketData.State.ToString().ToLowerInvariant(),
|
||||
OrderStreamState = engine.UserData.State.ToString().ToLowerInvariant(),
|
||||
StreamRejection = engine.MarketData.RejectionReason ?? engine.UserData.RejectionReason,
|
||||
Reconnects = Math.Max(0, engine.MarketData.ConnectCount - 1),
|
||||
Quotes = engine.Metrics.Quotes,
|
||||
Bars = engine.Metrics.Bars,
|
||||
Signals = engine.Metrics.Signals,
|
||||
Orders = engine.Metrics.OrdersSubmitted,
|
||||
Fills = engine.Metrics.OrdersFilled,
|
||||
Exits = engine.Metrics.Exits,
|
||||
RiskRejects = engine.Metrics.RiskRejects,
|
||||
Errors = engine.Metrics.OrderErrors,
|
||||
BarToSignal = engine.Metrics.BarToSignal.Summary(),
|
||||
SignalToOrder = engine.Metrics.SignalToOrder.Summary(),
|
||||
|
||||
Pairs = pairs,
|
||||
Positions = positions,
|
||||
EquityCurve = SnapshotEquityCurve(),
|
||||
Events = SnapshotEvents(),
|
||||
Account = BuildAccount(account),
|
||||
OrderHistory = BuildOrders(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)
|
||||
{
|
||||
if (state.Raw is not { } a)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AccountRow(
|
||||
(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<BinanceOrder> source = engine.RecentOrders;
|
||||
List<OrderRow> rows = new(source.Count);
|
||||
|
||||
foreach (BinanceOrder o in source)
|
||||
{
|
||||
rows.Add(new OrderRow(
|
||||
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.AverageFillPrice,
|
||||
o.LimitPrice,
|
||||
o.ReduceOnly,
|
||||
o.SubmittedUtc)
|
||||
{
|
||||
IsWorking = o.IsWorking,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string DescribeStatus(OrderStatus status) => status switch
|
||||
{
|
||||
OrderStatus.Filled => "eseguito",
|
||||
OrderStatus.PartiallyFilled => "parziale",
|
||||
OrderStatus.Canceled => "annullato",
|
||||
OrderStatus.Expired => "scaduto",
|
||||
OrderStatus.Rejected => "rifiutato",
|
||||
OrderStatus.New => "in attesa",
|
||||
_ => status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
private string DescribeMode() =>
|
||||
config.Engine.DryRun ? "DRY-RUN" : config.Binance.Testnet ? "TESTNET" : "REALE";
|
||||
|
||||
private void SampleEquity(double equity)
|
||||
{
|
||||
if (equity <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
if (now - _lastEquitySample < TimeSpan.FromSeconds(10))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastEquitySample = now;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_equityCurve.Count >= EquityCurveCapacity)
|
||||
{
|
||||
_equityCurve.Dequeue();
|
||||
}
|
||||
|
||||
_equityCurve.Enqueue(new EquityPoint(now, equity));
|
||||
}
|
||||
}
|
||||
|
||||
private EquityPoint[] SnapshotEquityCurve()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return [.. _equityCurve];
|
||||
}
|
||||
return engine.Snapshot(state, error, started, events);
|
||||
}
|
||||
|
||||
private EventRow[] SnapshotEvents()
|
||||
@@ -599,7 +278,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
private void RecordEvent(Logging.LogLevel level, DateTime timestamp, string message)
|
||||
{
|
||||
EventRow view = new(
|
||||
timestamp.ToString("HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
UiClock.Format(timestamp.ToUniversalTime(), "HH:mm:ss"),
|
||||
level.ToString().ToLowerInvariant(),
|
||||
message);
|
||||
|
||||
@@ -613,9 +292,8 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
_events.Enqueue(view);
|
||||
}
|
||||
|
||||
// Pushed rather than polled. The log page keeps thousands of lines, and copying
|
||||
// that array into a snapshot once a second would cost more than everything else
|
||||
// the UI does put together.
|
||||
// Pushed rather than polled: the log page keeps thousands of lines, and copying
|
||||
// that array into a snapshot every refresh would cost more than the rest of the UI.
|
||||
try
|
||||
{
|
||||
EventLogged?.Invoke(view);
|
||||
@@ -626,10 +304,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised for every log line, on the thread that logged it. Subscribers that touch
|
||||
/// the UI must marshal to the dispatcher themselves.
|
||||
/// </summary>
|
||||
/// <summary>Raised for every log line, on the thread that logged it.</summary>
|
||||
public event Action<EventRow>? EventLogged;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>What the window and the headless runner can ask a running engine to do.</summary>
|
||||
public enum EngineCommandKind
|
||||
{
|
||||
/// <summary>Close one basket at market. <c>Argument</c> = basket name.</summary>
|
||||
Close = 0,
|
||||
|
||||
/// <summary>Close everything now, block new entries until reset.</summary>
|
||||
KillSwitch,
|
||||
|
||||
/// <summary>Change the style preset at runtime. <c>Argument</c> = Conservative | Moderate | Aggressive.</summary>
|
||||
SetPreset,
|
||||
|
||||
/// <summary>Lift the equity stop or the kill-switch. <c>Reason</c> is written to the ledger and must not be empty.</summary>
|
||||
ResetEquityStop,
|
||||
}
|
||||
|
||||
public sealed record EngineCommand(EngineCommandKind Kind, string Argument = "", string Reason = "");
|
||||
|
||||
/// <summary>
|
||||
/// The seam between the supervisor and the engine. The engine runs for the whole session
|
||||
/// inside <see cref="RunAsync"/>, answers commands on demand and hands the window an
|
||||
/// immutable snapshot on request. An interface rather than the class so the tests can
|
||||
/// drive the supervisor with a fake.
|
||||
/// </summary>
|
||||
public interface IEngine : IAsyncDisposable
|
||||
{
|
||||
Task RunAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Closes every open basket, for the shutdown path.</summary>
|
||||
Task CloseAllAsync(string reason, CancellationToken ct);
|
||||
|
||||
Task<CommandResult> ExecuteAsync(EngineCommand command, CancellationToken ct);
|
||||
|
||||
/// <summary>The picture the window renders. <paramref name="events"/> is the activity feed the supervisor keeps.</summary>
|
||||
BotSnapshot Snapshot(BotState state, string? error, DateTime? startedUtc, EventRow[] events);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the last bar of a downloaded history as already seen, without counting it
|
||||
/// as a live bar. Without this the REST backstop would "recover" the final bars of
|
||||
/// the calibration window on every start, because it compares against a last-closed
|
||||
/// time that was never set.
|
||||
/// </summary>
|
||||
public void SeedHistory(in Bar bar)
|
||||
{
|
||||
if (bar.TimeUtc > LastClosedBar.TimeUtc)
|
||||
{
|
||||
LastClosedBar = bar;
|
||||
}
|
||||
|
||||
if (bar.Close > 0 && LastPrice <= 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;
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Ml;
|
||||
using Encelado.Core.Statistics;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
using Encelado.Storage;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>What the meta-model said about one proposed entry.</summary>
|
||||
public readonly record struct MetaVerdict(bool HasModel, double Probability, bool Allowed, double SizeFraction, string Note)
|
||||
{
|
||||
public static readonly MetaVerdict NoModel = new(false, double.NaN, true, 1.0, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The meta-model's seat at the decision table.
|
||||
/// <para>
|
||||
/// For every pair with a promoted champion in the database this keeps the rolling
|
||||
/// history the features need, scores each entry the statistical rule proposes, refuses
|
||||
/// the ones the model rates below the configured probability, sizes the rest by its
|
||||
/// confidence, and watches the live feature distribution for drift against the
|
||||
/// training set. It can only say no or "smaller": the rule still decides what and
|
||||
/// which way, which is what keeps the model from inventing trades nobody validated.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A pair without a champion passes straight through — the bot then trades exactly as
|
||||
/// it did before the model existed. The champion is re-read at every recalibration, so
|
||||
/// a model promoted by the research tool takes effect without a restart, and a model
|
||||
/// suspended for drift gets another chance when the next one is loaded.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MetaGate
|
||||
{
|
||||
private readonly MlOptions _options;
|
||||
private readonly StorageDb? _storage;
|
||||
private readonly FeatureRegistry _registry = FeatureRegistry.Default();
|
||||
private readonly Dictionary<string, Slot> _slots = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private sealed class Slot(PairFeatureBuffer buffer)
|
||||
{
|
||||
public PairFeatureBuffer Buffer { get; } = buffer;
|
||||
|
||||
public GbdtModel? Model { get; set; }
|
||||
|
||||
public long ModelId { get; set; }
|
||||
|
||||
public DriftMonitor? Drift { get; set; }
|
||||
|
||||
public bool Suspended { get; set; }
|
||||
|
||||
public int Scored { get; set; }
|
||||
}
|
||||
|
||||
public MetaGate(MlOptions options, StorageDb? storage, IReadOnlyList<PairPipeline> pairs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(pairs);
|
||||
|
||||
_options = options;
|
||||
_storage = storage;
|
||||
|
||||
foreach (PairPipeline pair in pairs)
|
||||
{
|
||||
_slots[pair.Name] = new Slot(new PairFeatureBuffer(pair.Strategy.Window));
|
||||
}
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
Log.Info("meta-modello disattivato dalla configurazione: opera la sola regola statistica");
|
||||
}
|
||||
else if (storage is null)
|
||||
{
|
||||
Log.Info("meta-modello non disponibile: senza database locale non c'è un campione da leggere");
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsActive => _options.Enabled && _storage is not null;
|
||||
|
||||
/// <summary>Whether a usable champion is loaded for the pair.</summary>
|
||||
public bool HasModel(string pair) =>
|
||||
_slots.TryGetValue(pair, out Slot? slot) && slot.Model is not null && !slot.Suspended;
|
||||
|
||||
/// <summary>(Re)loads every pair's champion from the database.</summary>
|
||||
public void ReloadChampions()
|
||||
{
|
||||
if (!IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((string pair, Slot slot) in _slots)
|
||||
{
|
||||
try
|
||||
{
|
||||
StorageDb.ModelRecord? champion = _storage!.LoadChampion(pair);
|
||||
if (champion is null)
|
||||
{
|
||||
if (slot.Model is not null)
|
||||
{
|
||||
Log.Warn($"[{pair}] il campione è stato rimosso dal database: torno alla sola regola statistica");
|
||||
}
|
||||
|
||||
slot.Model = null;
|
||||
slot.ModelId = 0;
|
||||
slot.Drift = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (champion.Id == slot.ModelId && slot.Model is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
GbdtModel model = GbdtModel.FromBytes(champion.Blob);
|
||||
if (!SameFeatures(model.FeatureNames))
|
||||
{
|
||||
Log.Error($"[{pair}] il campione #{champion.Id} usa feature diverse da quelle del bot: lo ignoro", null);
|
||||
continue;
|
||||
}
|
||||
|
||||
slot.Model = model;
|
||||
slot.ModelId = champion.Id;
|
||||
slot.Suspended = false;
|
||||
slot.Scored = 0;
|
||||
slot.Drift = BuildDrift(champion);
|
||||
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"[{pair}] campione #{champion.Id} caricato ({model.TreeCount} alberi, " +
|
||||
$"{model.FeatureNames.Length} feature, del {champion.CreatedUtc:yyyy-MM-dd}); " +
|
||||
$"soglia {_options.MinProbability:F2}"));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Log.Error($"[{pair}] impossibile caricare il campione: opero senza", ex);
|
||||
slot.Model = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a pair's feature history with a warm-up download and the strategy's
|
||||
/// z-score after each of its bars.
|
||||
/// </summary>
|
||||
public void Seed(
|
||||
string pair, IReadOnlyList<Bar> barsA, IReadOnlyList<Bar> barsB, in PairCalibration calibration,
|
||||
IReadOnlyList<double> zScores)
|
||||
{
|
||||
if (_slots.TryGetValue(pair, out Slot? slot))
|
||||
{
|
||||
slot.Buffer.Seed(barsA, barsB, calibration, zScores);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends the bar just closed with the z-score the strategy computed on it. Call
|
||||
/// once per aligned bar, before <see cref="Judge"/>.
|
||||
/// </summary>
|
||||
public void Observe(string pair, in Bar barA, in Bar barB, in PairCalibration calibration, double zScore)
|
||||
{
|
||||
if (_slots.TryGetValue(pair, out Slot? slot))
|
||||
{
|
||||
slot.Buffer.Append(barA, barB, calibration, zScore);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scores a proposed entry. Returns <see cref="MetaVerdict.NoModel"/> when there is
|
||||
/// nothing to say, so the caller's path is the same with and without a model.
|
||||
/// </summary>
|
||||
public MetaVerdict Judge(string pair, double latencyMs)
|
||||
{
|
||||
if (!IsActive || !_slots.TryGetValue(pair, out Slot? slot) || slot.Model is null || slot.Suspended)
|
||||
{
|
||||
return MetaVerdict.NoModel;
|
||||
}
|
||||
|
||||
double[]? row = slot.Buffer.Latest(_registry, latencyMs);
|
||||
if (row is null)
|
||||
{
|
||||
return new MetaVerdict(true, double.NaN, true, 1.0,
|
||||
$"storia insufficiente per le feature ({slot.Buffer.Count}/{slot.Buffer.Capacity} barre): passo senza giudizio");
|
||||
}
|
||||
|
||||
double p;
|
||||
try
|
||||
{
|
||||
p = slot.Model.Predict(row);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Log.Error($"[{pair}] il meta-modello ha fallito la predizione: passo senza giudizio", ex);
|
||||
return MetaVerdict.NoModel;
|
||||
}
|
||||
|
||||
slot.Scored++;
|
||||
CheckDrift(pair, slot, row);
|
||||
|
||||
if (p < _options.MinProbability)
|
||||
{
|
||||
return new MetaVerdict(true, p, false, 0, string.Create(CultureInfo.InvariantCulture,
|
||||
$"il meta-modello dà {p:P1} di probabilità che l'ingresso ripaghi i costi, " +
|
||||
$"sotto la soglia {_options.MinProbability:P0}"));
|
||||
}
|
||||
|
||||
double fraction = 1.0;
|
||||
if (_options.SizeByProbability)
|
||||
{
|
||||
fraction = Math.Clamp(Performance.BetSizeFromProbability(p), _options.MinSizeFraction, 1.0);
|
||||
}
|
||||
|
||||
string note = fraction < 1
|
||||
? string.Create(CultureInfo.InvariantCulture,
|
||||
$"il meta-modello dà {p:P1}: ingresso ammesso con size al {fraction:P0}")
|
||||
: string.Create(CultureInfo.InvariantCulture, $"il meta-modello dà {p:P1}: ingresso ammesso");
|
||||
|
||||
return new MetaVerdict(true, p, true, fraction, note);
|
||||
}
|
||||
|
||||
private void CheckDrift(string pair, Slot slot, double[] row)
|
||||
{
|
||||
if (slot.Drift is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
slot.Drift.Observe(row);
|
||||
if (!slot.Drift.Due(_options.DriftCheckBars))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<DriftReport> reports = slot.Drift.Check();
|
||||
int alerts = 0;
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
foreach (DriftReport r in reports)
|
||||
{
|
||||
if (r.Alert)
|
||||
{
|
||||
alerts++;
|
||||
Log.Warn($"[{pair}] DERIVA {r.Motivazione}");
|
||||
}
|
||||
|
||||
_storage?.InsertDrift(now, pair, r.Feature, r.Psi, r.Ks, r.Alert, r.Motivazione);
|
||||
}
|
||||
|
||||
Log.Info(string.Create(CultureInfo.InvariantCulture,
|
||||
$"[{pair}] controllo deriva su {slot.Drift.RecentRows} segnali: {alerts} feature su {reports.Count} " +
|
||||
$"oltre PSI {_options.DriftPsiAlert:F2}"));
|
||||
|
||||
if (_options.DriftAlertsToSuspend > 0 && alerts >= _options.DriftAlertsToSuspend)
|
||||
{
|
||||
slot.Suspended = true;
|
||||
Log.Warn($"[{pair}] meta-modello SOSPESO: {alerts} feature in deriva. Fino alla prossima " +
|
||||
"ricalibrazione opera la sola regola statistica, come prima del modello.");
|
||||
}
|
||||
}
|
||||
|
||||
private DriftMonitor? BuildDrift(StorageDb.ModelRecord champion)
|
||||
{
|
||||
if (champion.DatasetId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dataset = _storage!.LoadDataset(champion.DatasetId);
|
||||
if (dataset is null || dataset.Value.Rows.Count < 50)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double[][] rows = [.. dataset.Value.Rows.Select(static r => r.Features)];
|
||||
return new DriftMonitor(champion.FeatureNames, rows, psiAlert: _options.DriftPsiAlert);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Log.Warn($"dataset #{champion.DatasetId} non leggibile: nessun controllo di deriva ({ex.Message})");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool SameFeatures(IReadOnlyList<string> names)
|
||||
{
|
||||
IReadOnlyList<string> mine = _registry.Names;
|
||||
if (names.Count != mine.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < names.Count; i++)
|
||||
{
|
||||
if (names[i] != mine[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
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,
|
||||
double SizeFraction = 1.0);
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// The meta-model's bet size scales the risk engine's notional, never the
|
||||
// other way round: the risk engine sets the ceiling, the model may only
|
||||
// come in under it.
|
||||
if (intent.SizeFraction is > 0 and < 1)
|
||||
{
|
||||
verdict = verdict with
|
||||
{
|
||||
NotionalA = verdict.NotionalA * intent.SizeFraction,
|
||||
NotionalB = verdict.NotionalB * intent.SizeFraction,
|
||||
};
|
||||
}
|
||||
|
||||
// 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,120 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Encelado.Bot.Diagnostics;
|
||||
using Encelado.Core.Journal;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Bot.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only record of every order event: entries, fills, exits, rejections and the
|
||||
/// occasional unwind. Separate from the log on purpose — this is meant to be opened in
|
||||
/// a spreadsheet or joined against the decision log, not read line by line.
|
||||
/// <para>
|
||||
/// A <c>;</c>-separated table with a <c>motivazione</c> column, like every other table
|
||||
/// the bot writes. It used to be JSON lines, which no spreadsheet opens and which put
|
||||
/// the one column a human reads last, inside quotes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TradeJournal : IDisposable
|
||||
{
|
||||
public const string Header =
|
||||
"timestampUtc;event;symbol;side;quantity;price;orderId;stop;target;equity;realizedPnl;motivazione";
|
||||
|
||||
private readonly StreamWriter? _writer;
|
||||
private readonly IJournalSink? _sink;
|
||||
private readonly Lock _gate = new();
|
||||
private readonly StringBuilder _row = new(256);
|
||||
|
||||
public TradeJournal(string? path, IJournalSink? sink = null)
|
||||
{
|
||||
_writer = CsvTable.Open(path, Header);
|
||||
_sink = sink;
|
||||
}
|
||||
|
||||
public bool IsEnabled => _writer is not null;
|
||||
|
||||
public void Record(
|
||||
string @event,
|
||||
string symbol,
|
||||
Side side,
|
||||
double quantity,
|
||||
double price,
|
||||
string reason,
|
||||
string? orderId = null,
|
||||
double? stopPrice = null,
|
||||
double? targetPrice = null,
|
||||
double? equity = null,
|
||||
double? realizedPnl = null)
|
||||
{
|
||||
string sideText = side switch { Side.Buy => "BUY", Side.Sell => "SELL", _ => string.Empty };
|
||||
|
||||
_sink?.Trade(new TradeRow(
|
||||
DateTime.UtcNow, @event, symbol, sideText, quantity, price, orderId,
|
||||
stopPrice, targetPrice, equity, realizedPnl, reason));
|
||||
|
||||
if (_writer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_row.Clear();
|
||||
|
||||
Add(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
|
||||
Add(@event);
|
||||
Add(symbol);
|
||||
Add(sideText);
|
||||
Add(quantity);
|
||||
Add(price);
|
||||
Add(orderId);
|
||||
Add(stopPrice);
|
||||
Add(targetPrice);
|
||||
Add(equity);
|
||||
Add(realizedPnl);
|
||||
CsvTable.AppendField(_row, reason);
|
||||
|
||||
_writer.WriteLine(_row.ToString());
|
||||
|
||||
// Every row is an order event, and there are few of them: flushing each one
|
||||
// costs nothing and means a crash never loses the record of a fill.
|
||||
_writer.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
private void Add(string? value)
|
||||
{
|
||||
CsvTable.AppendField(_row, value);
|
||||
_row.Append(AnalyticsLog.Separator);
|
||||
}
|
||||
|
||||
private void Add(double value)
|
||||
{
|
||||
if (double.IsFinite(value))
|
||||
{
|
||||
_row.Append(Math.Round(value, 8).ToString("G10", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
_row.Append(AnalyticsLog.Separator);
|
||||
}
|
||||
|
||||
private void Add(double? value)
|
||||
{
|
||||
if (value is { } v && double.IsFinite(v))
|
||||
{
|
||||
_row.Append(Math.Round(v, 8).ToString("G10", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
_row.Append(AnalyticsLog.Separator);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_writer?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +1,78 @@
|
||||
<Window x:Class="Encelado.Bot.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Encelado" Height="920" Width="1520"
|
||||
MinHeight="640" MinWidth="1120"
|
||||
Title="Encelado" Height="860" Width="1320"
|
||||
MinHeight="620" MinWidth="980"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource Bg}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextRenderingMode="ClearType">
|
||||
|
||||
<!--
|
||||
Nessuna personalizzazione della cornice: niente WindowStyle="None", niente
|
||||
WindowChrome, niente AllowsTransparency. Barra del titolo, bordi, angoli
|
||||
arrotondati, snap, Aero Shake e i pulsanti riduci/ingrandisci/chiudi sono quelli
|
||||
di Windows. L'unica cosa che il codice tocca è l'attributo DWM che dice al gestore
|
||||
finestre di disegnare *la sua* barra in scuro invece che in chiaro — vedi
|
||||
ApplyNativeDarkTitleBar in MainWindow.xaml.cs.
|
||||
Nessuna personalizzazione della cornice: barra del titolo, bordi e pulsanti sono
|
||||
quelli di Windows. L'unica cosa che il codice tocca è l'attributo DWM che chiede
|
||||
la barra del titolo scura — vedi ApplyNativeDarkTitleBar in MainWindow.xaml.cs.
|
||||
|
||||
La struttura è una barra in alto e una pagina sotto. Nella barra: il marchio, le tre
|
||||
schede, lo stato del motore, l'ambiente, l'ora nel fuso scelto e il pulsante di
|
||||
avvio. Tutto il resto vive nelle pagine.
|
||||
-->
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<DockPanel>
|
||||
|
||||
<!-- ==================== side navigation ==================== -->
|
||||
<Border Grid.Column="0" Width="236" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Line}" BorderThickness="0,0,1,0">
|
||||
<DockPanel Margin="14,16,14,14">
|
||||
<!-- ==================== barra superiore ==================== -->
|
||||
<Border DockPanel.Dock="Top" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Line}" BorderThickness="0,0,0,1" Padding="18,9">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- brand -->
|
||||
<StackPanel DockPanel.Dock="Top" Margin="4,0,0,18">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Image x:Name="LogoImage" Width="26" Height="26" Margin="0,0,10,0"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
<TextBlock Text="Encelado" FontSize="18" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="VersionText" Style="{StaticResource Label}" Margin="0,7,0,0"/>
|
||||
<!-- marchio -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,22,0">
|
||||
<Image x:Name="LogoImage" Width="24" Height="24" Margin="0,0,9,0"
|
||||
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||
<TextBlock Text="Encelado" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="VersionText" Style="{StaticResource Sub}" Margin="8,2,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== bottom block ==================== -->
|
||||
<!--
|
||||
Declared before the nav list so DockPanel gives it its height first: the list
|
||||
then takes what is left and scrolls, instead of pushing the power button off
|
||||
the bottom of a short window.
|
||||
-->
|
||||
<StackPanel DockPanel.Dock="Bottom">
|
||||
<!-- schede -->
|
||||
<ListBox x:Name="Nav" Grid.Column="1" Style="{StaticResource TabList}"
|
||||
SelectionChanged="OnNavigated" VerticalAlignment="Center"/>
|
||||
|
||||
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="12,10" Margin="0,14,0,10">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Style="{StaticResource Dot}" Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StateText}" Foreground="{StaticResource Dim}" FontSize="12.5"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,9,0,0">
|
||||
<Border Style="{StaticResource ModeBadge}" Margin="0,0,5,0" Padding="6,2">
|
||||
<TextBlock Text="{Binding Mode}" FontFamily="{StaticResource Mono}"
|
||||
FontSize="10" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Chip}" Padding="6,2">
|
||||
<TextBlock x:Name="FrameChip" FontFamily="{StaticResource Mono}"
|
||||
FontSize="10" Foreground="{StaticResource Dim}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Button x:Name="PowerBtn" Style="{StaticResource PowerButton}"
|
||||
Content="{Binding PowerText}" IsEnabled="{Binding CanToggle}"
|
||||
Click="OnTogglePower" HorizontalAlignment="Stretch"/>
|
||||
<!-- stato del motore -->
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,16,0">
|
||||
<Ellipse Style="{StaticResource Dot}" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StateText}" Foreground="{StaticResource Dim}" FontSize="12.5" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<ListBox x:Name="Nav" Style="{StaticResource NavList}"
|
||||
SelectionChanged="OnNavigated"/>
|
||||
<!-- ambiente -->
|
||||
<Border Grid.Column="4" Style="{StaticResource ModeBadge}" Margin="0,0,16,0"
|
||||
ToolTip="{Binding ExecutionMode, StringFormat='Modalità di esecuzione: {0}. Paper = simulatore; Demo = conto demo eToro; Live = conto reale.'}">
|
||||
<TextBlock Text="{Binding Mode}" FontFamily="{StaticResource Mono}" FontSize="11" FontWeight="Bold"/>
|
||||
</Border>
|
||||
|
||||
</DockPanel>
|
||||
<!-- ora nel fuso scelto -->
|
||||
<StackPanel Grid.Column="5" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,18,0"
|
||||
ToolTip="{Binding ClockSkew, StringFormat='Ora nel fuso impostato. Scarto orologio locale − server eToro: {0:+0.0;-0.0} s (oltre 5 s le nuove entrate vengono bloccate).'}">
|
||||
<TextBlock Text="{Binding Clock}" FontFamily="{StaticResource Mono}" FontSize="15" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding ClockZone}" Style="{StaticResource Sub}" Margin="7,0,0,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="PowerBtn" Grid.Column="6" Style="{StaticResource PowerButton}"
|
||||
Content="{Binding PowerText}" IsEnabled="{Binding CanToggle}"
|
||||
Click="OnTogglePower" MinWidth="110"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== page ==================== -->
|
||||
<ContentControl x:Name="PageHost" Grid.Column="1" Margin="18,16,14,16"/>
|
||||
<!-- ==================== pagina ==================== -->
|
||||
<ContentControl x:Name="PageHost" Margin="20,16,16,16"/>
|
||||
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
|
||||
@@ -12,24 +12,16 @@ using Encelado.Bot.Engine;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Bot.Ui;
|
||||
using Encelado.Bot.Ui.Pages;
|
||||
using Encelado.Core.Baskets;
|
||||
|
||||
namespace Encelado.Bot;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// touched from exactly one place.
|
||||
/// </para>
|
||||
/// The shell: a top bar with the three tabs and the start/stop button, one page below.
|
||||
/// 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 key store, the file system and the engine are touched from
|
||||
/// exactly one place.
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window, IUiActions
|
||||
{
|
||||
@@ -38,10 +30,10 @@ public partial class MainWindow : Window, IUiActions
|
||||
private readonly BotSupervisor _supervisor;
|
||||
private readonly DispatcherTimer _timer;
|
||||
|
||||
private readonly StatusPage _status = new();
|
||||
private readonly PositionsPage _positions = new();
|
||||
private readonly DashboardPage _dashboard = new();
|
||||
private readonly LogPage _log = new();
|
||||
private readonly SettingsPage _settings = new();
|
||||
private readonly UserControl[] _pages;
|
||||
|
||||
private bool _busy;
|
||||
private bool _closing;
|
||||
@@ -62,20 +54,19 @@ public partial class MainWindow : Window, IUiActions
|
||||
_supervisor.EventLogged += _vm.Log.Enqueue;
|
||||
|
||||
DataContext = _vm;
|
||||
|
||||
foreach (UserControl page in new UserControl[] { _status, _positions, _log, _settings })
|
||||
_pages = [_dashboard, _log, _settings];
|
||||
foreach (UserControl page in _pages)
|
||||
{
|
||||
page.DataContext = _vm;
|
||||
}
|
||||
|
||||
_status.Actions = this;
|
||||
_positions.Actions = this;
|
||||
_dashboard.Actions = this;
|
||||
_log.Actions = this;
|
||||
_settings.Actions = this;
|
||||
|
||||
BuildNavigation();
|
||||
Nav.ItemsSource = new[] { "Dashboard", "Log", "Impostazioni" };
|
||||
Nav.SelectedIndex = 0;
|
||||
|
||||
FrameChip.Text = _config.Engine.TimeFrame;
|
||||
VersionText.Text = $"v{Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}";
|
||||
LoadLogo();
|
||||
RefreshSettings();
|
||||
@@ -94,30 +85,11 @@ public partial class MainWindow : Window, IUiActions
|
||||
Closing += OnClosing;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void BuildNavigation()
|
||||
{
|
||||
// Glyphs are Segoe MDL2 Assets code points, which ships with Windows.
|
||||
NavItem[] items =
|
||||
[
|
||||
new("Stato", "", () => _status),
|
||||
new("Operatività", "", () => _positions),
|
||||
new("Log", "", () => _log),
|
||||
new("Impostazioni", "", () => _settings),
|
||||
];
|
||||
|
||||
Nav.ItemsSource = items;
|
||||
Nav.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private void OnNavigated(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (Nav.SelectedItem is NavItem item)
|
||||
if (Nav.SelectedIndex >= 0 && Nav.SelectedIndex < _pages.Length)
|
||||
{
|
||||
PageHost.Content = item.Page;
|
||||
PageHost.Content = _pages[Nav.SelectedIndex];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,22 +108,21 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
|
||||
Log.Info($"Encelado avviato — configurazione {App.ConfigPath}");
|
||||
Log.Info($"log in {_config.Logging.ResolveDirectory()}");
|
||||
Log.Info($"log in {_config.Logging.ResolveDirectory()}; orari mostrati nel fuso {UiClock.ZoneName}");
|
||||
|
||||
if (App.SeedNote is { } seeded)
|
||||
{
|
||||
Log.Warn(seeded);
|
||||
}
|
||||
|
||||
CredentialLookup lookup = CredentialResolver.Resolve(_config);
|
||||
if (!lookup.Found)
|
||||
if (EtoroKeyStore.Resolve(_config, out string origin))
|
||||
{
|
||||
Log.Info("nessuna credenziale trovata: apro la finestra di login");
|
||||
PromptForCredentials();
|
||||
Log.Info($"chiavi eToro: {origin}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Info($"credenziali: {lookup.Describe()}");
|
||||
Log.Info("nessuna chiave eToro trovata: apro la finestra di accesso (le quotazioni richiedono le chiavi anche in Paper)");
|
||||
PromptForCredentials();
|
||||
}
|
||||
|
||||
RefreshSettings();
|
||||
@@ -159,48 +130,29 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says so, now, if the configuration on disk is the one from the Alpaca version.
|
||||
/// <para>
|
||||
/// An installation keeps the user's <c>encelado.json</c> across an update — which is
|
||||
/// Says so, now, if the configuration on disk comes from a previous version. 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 WarnIfConfigurationIsStale()
|
||||
{
|
||||
bool stale = App.ConfigWarnings.Any(static w => w.Contains("versione Alpaca", StringComparison.Ordinal));
|
||||
|
||||
if (!stale && _config.EnabledPairs.Any())
|
||||
bool stale = App.ConfigWarnings.Any(static w => w.Contains("versione precedente", StringComparison.Ordinal));
|
||||
if (!stale)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Warn("la configurazione non contiene nessuna coppia utilizzabile");
|
||||
Log.Warn("la configurazione contiene chiavi di una versione precedente");
|
||||
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"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.",
|
||||
"Il file di configurazione proviene da una versione precedente e contiene sezioni o chiavi che questa " +
|
||||
"versione ignora.\n\nVai in Impostazioni e premi «Ripristina i valori predefiniti»: il file attuale " +
|
||||
"viene salvato con la data accanto all'originale, quindi non perdi niente. Le chiavi eToro non vengono toccate.",
|
||||
"Configurazione da aggiornare",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
|
||||
foreach (NavItem item in Nav.Items.OfType<NavItem>())
|
||||
{
|
||||
if (item.Title == "Impostazioni")
|
||||
{
|
||||
Nav.SelectedItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadLogo()
|
||||
@@ -216,15 +168,7 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <summary>Asks the desktop window manager to draw <b>its own</b> title bar dark.</summary>
|
||||
private void ApplyNativeDarkTitleBar()
|
||||
{
|
||||
const int DwmwaUseImmersiveDarkMode = 20;
|
||||
@@ -252,19 +196,8 @@ public partial class MainWindow : Window, IUiActions
|
||||
{
|
||||
_vm.Apply(_supervisor.Snapshot());
|
||||
_vm.Log.Flush();
|
||||
|
||||
if (Nav.ItemsSource is IEnumerable<NavItem> items)
|
||||
{
|
||||
foreach (NavItem item in items)
|
||||
{
|
||||
item.Badge = item.Title switch
|
||||
{
|
||||
"Operatività" when _vm.OpenPairs > 0 =>
|
||||
_vm.OpenPairs.ToString(System.Globalization.CultureInfo.CurrentCulture),
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
_vm.Clock = UiClock.Format(DateTime.UtcNow, "HH:mm:ss");
|
||||
_vm.ClockZone = UiClock.Label;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -283,15 +216,14 @@ public partial class MainWindow : Window, IUiActions
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_vm.IsRunning && !_config.Binance.Testnet && !_config.Engine.DryRun && !ConfirmLiveTrading())
|
||||
if (!_vm.IsRunning && !ConfirmStart())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Never touch PowerBtn.IsEnabled here. It is bound to CanToggle, and assigning a
|
||||
// dependency property imperatively replaces the binding with a local value — the
|
||||
// button then stays disabled for ever and the bot cannot be stopped from the
|
||||
// window. The view model owns the whole thing.
|
||||
// button then stays disabled for ever. The view model owns the whole thing.
|
||||
_busy = true;
|
||||
_vm.IsBusy = true;
|
||||
|
||||
@@ -315,22 +247,41 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
}
|
||||
|
||||
private bool ConfirmLiveTrading() =>
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"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,
|
||||
MessageBoxResult.No) == MessageBoxResult.Yes;
|
||||
/// <summary>The only confirmation left: the live mode wants the typed phrase. Paper and Demo start as they are.</summary>
|
||||
private bool ConfirmStart()
|
||||
{
|
||||
_supervisor.StartConfirmed = false;
|
||||
|
||||
private bool EnsureCredentials() =>
|
||||
CredentialResolver.Resolve(_config).Found || PromptForCredentials();
|
||||
ExecutionMode mode = _config.Run.Mode;
|
||||
if (!mode.IsLive())
|
||||
{
|
||||
_supervisor.StartConfirmed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
PromptWindow prompt = new(
|
||||
"Conto REALE",
|
||||
"La modalità Live manda ordini al conto reale di eToro con denaro vero, senza chiedere conferma per i singoli ordini. Per continuare scrivi la frase esatta.",
|
||||
$"SCRIVI «{PromptWindow.LivePhrase}»",
|
||||
v => v == PromptWindow.LivePhrase ? null : $"La frase deve essere esattamente «{PromptWindow.LivePhrase}».",
|
||||
"Avvia sul reale")
|
||||
{ Owner = this };
|
||||
|
||||
bool ok = prompt.ShowDialog() == true;
|
||||
_supervisor.StartConfirmed = ok;
|
||||
if (ok)
|
||||
{
|
||||
Log.Warn($"avvio in Live confermato dall'operatore con la frase {PromptWindow.LivePhrase}");
|
||||
}
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
private bool EnsureCredentials() => EtoroKeyStore.Resolve(_config, out _) || PromptForCredentials();
|
||||
|
||||
private bool PromptForCredentials()
|
||||
{
|
||||
LoginWindow dialog = new(_config) { Owner = this };
|
||||
EtoroLoginWindow dialog = new(_config) { Owner = this };
|
||||
bool ok = dialog.ShowDialog() == true;
|
||||
RefreshSettings();
|
||||
return ok;
|
||||
@@ -340,64 +291,97 @@ public partial class MainWindow : Window, IUiActions
|
||||
// IUiActions — everything the pages can ask the shell to do
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public async Task ClosePairAsync(string pairName)
|
||||
public async Task CloseBasketAsync(string basket)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pairName))
|
||||
if (string.IsNullOrWhiteSpace(basket))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (MessageBox.Show(
|
||||
this,
|
||||
$"Chiudere entrambe le gambe di {pairName} al prezzo di mercato?",
|
||||
"Chiusura coppia",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
if (MessageBox.Show(this, $"Chiudere entrambe le gambe di {basket} al prezzo di mercato?", "Chiusura basket",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CommandResult result = await _supervisor
|
||||
.ClosePairAsync(pairName, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
Report(await _supervisor.CloseAsync(basket, CancellationToken.None).ConfigureAwait(true));
|
||||
}
|
||||
|
||||
public async Task KillSwitchAsync()
|
||||
{
|
||||
if (MessageBox.Show(this,
|
||||
"KILL-SWITCH: chiude tutte le gambe di tutti i basket a mercato e blocca le nuove entrate fino a un reset.\n\nContinuare?",
|
||||
"Kill-switch", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Warn("KILL-SWITCH richiesto dalla finestra");
|
||||
Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, string.Empty, "kill-switch dalla finestra"), CancellationToken.None).ConfigureAwait(true));
|
||||
}
|
||||
|
||||
public async Task SetPresetAsync(string preset)
|
||||
{
|
||||
CommandResult result = await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.SetPreset, preset, "cambio preset dalla finestra"), CancellationToken.None).ConfigureAwait(true);
|
||||
if (!result.Ok)
|
||||
{
|
||||
Log.Warn($"preset non cambiato: {result.Message}");
|
||||
}
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public async Task ResetEquityStopAsync()
|
||||
{
|
||||
PromptWindow prompt = new(
|
||||
"Reset del blocco",
|
||||
"Il bot ha chiuso tutto e si è bloccato (equity stop o kill-switch). Prima di ripartire scrivi perché ritieni di poterlo fare: la motivazione finisce nel ledger.",
|
||||
"MOTIVAZIONE",
|
||||
v => v.Length >= 10 ? null : "Scrivi almeno dieci caratteri.",
|
||||
"Sblocca")
|
||||
{ Owner = this };
|
||||
|
||||
if (prompt.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.ResetEquityStop, string.Empty, prompt.Value), CancellationToken.None).ConfigureAwait(true));
|
||||
}
|
||||
|
||||
private void Report(CommandResult result)
|
||||
{
|
||||
if (!result.Ok)
|
||||
{
|
||||
MessageBox.Show(this, result.Message, "Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Info(result.Message);
|
||||
}
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public void ShowLogin() => PromptForCredentials();
|
||||
|
||||
public void ForgetCredentials()
|
||||
{
|
||||
string environment = _config.Binance.Testnet ? "TESTNET" : "REALE";
|
||||
|
||||
if (MessageBox.Show(
|
||||
this,
|
||||
$"Rimuovere le chiavi salvate per l'ambiente {environment}?",
|
||||
"Rimozione credenziali",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question,
|
||||
MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
string environment = _config.Etoro.IsDemo ? "DEMO" : "REALE";
|
||||
if (MessageBox.Show(this, $"Rimuovere le chiavi eToro salvate per l'ambiente {environment}?", "Rimozione chiavi",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool removed = CredentialStore.Clear(_config.Binance.Testnet);
|
||||
Log.Info(removed ? "credenziali salvate rimosse" : "non c'erano credenziali salvate da rimuovere");
|
||||
bool removed = EtoroKeyStore.Clear(_config.Etoro.IsDemo);
|
||||
_config.Etoro.ApiKey = string.Empty;
|
||||
_config.Etoro.UserKey = string.Empty;
|
||||
Log.Info(removed ? "chiavi eToro salvate rimosse" : "non c'erano chiavi eToro 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>
|
||||
/// <summary>Rewrites the configuration with the factory values, never while the engine runs.</summary>
|
||||
public void RestoreDefaults()
|
||||
{
|
||||
if (_vm.IsRunning)
|
||||
@@ -413,10 +397,9 @@ public partial class MainWindow : Window, IUiActions
|
||||
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" +
|
||||
"Vengono persi: i valori che hai cambiato 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.",
|
||||
"recuperabile. Le chiavi eToro e strategy.json non vengono toccati.",
|
||||
"Ripristino dei valori predefiniti",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning,
|
||||
@@ -451,6 +434,33 @@ public partial class MainWindow : Window, IUiActions
|
||||
|
||||
public void OpenConfigFile() => OpenInShell(App.ConfigPath);
|
||||
|
||||
public void OpenStrategyFile()
|
||||
{
|
||||
string path = _config.Run.StrategyPath;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
App.SeedStrategyFile(path);
|
||||
}
|
||||
|
||||
OpenInShell(path);
|
||||
}
|
||||
|
||||
public void OpenDataFolder()
|
||||
{
|
||||
string directory = _config.Run.DataPath;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"impossibile creare {directory}: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
OpenInShell(directory);
|
||||
}
|
||||
|
||||
public void OpenLogFolder()
|
||||
{
|
||||
string directory = _config.Logging.ResolveDirectory();
|
||||
@@ -583,24 +593,21 @@ 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 {environment}."
|
||||
: "Nessuna credenziale configurata. Il bot non può partire finché non ne inserisci una coppia.";
|
||||
|
||||
string store = CredentialStore.Exists
|
||||
? $"Archivio: {CredentialStore.FilePath}" +
|
||||
(CredentialStore.IsEncrypted ? " (cifrato con DPAPI)" : " (in chiaro, permessi ristretti)")
|
||||
: $"Nessun archivio salvato. Verrebbe creato in {CredentialStore.FilePath}.";
|
||||
|
||||
string environment = _config.Etoro.IsDemo ? "DEMO" : "REALE";
|
||||
bool found = EtoroKeyStore.Resolve(_config, out string origin);
|
||||
string status = found
|
||||
? $"Origine: {origin} — ambiente eToro {environment}, modalità {_config.Run.Mode}."
|
||||
: $"Nessuna chiave eToro per l'ambiente {environment}. Il bot non può leggere le quotazioni finché non ne inserisci una coppia.";
|
||||
string store = EtoroKeyStore.Exists
|
||||
? $"Archivio: {EtoroKeyStore.FilePath}" + (EtoroKeyStore.IsEncrypted ? " (cifrato con DPAPI)" : " (in chiaro, permessi ristretti)")
|
||||
: $"Nessun archivio salvato. Verrebbe creato in {EtoroKeyStore.FilePath}.";
|
||||
string about =
|
||||
"Encelado — arbitraggio statistico su coppie cointegrate, Binance Futures USDⓈ-M.\n" +
|
||||
"Encelado — Correlation Baskets su eToro (CFD forex).\n" +
|
||||
$"Versione {Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}\n" +
|
||||
$"Configurazione: {App.ConfigPath}\n" +
|
||||
$"Endpoint: {_config.Binance.RestBaseUrl} leva: {_config.Binance.Leverage}x " +
|
||||
$"margine: {_config.Binance.MarginType}";
|
||||
$"Strategia: {_config.Run.StrategyPath}\n" +
|
||||
$"Dati: {_config.Run.DataPath}\n" +
|
||||
$"Endpoint: {_config.Etoro.BaseUrl} ambiente: {environment} modalità: {_config.Run.Mode} fuso: {UiClock.ZoneName}";
|
||||
|
||||
_settings.Refresh(_config, status, store, about);
|
||||
}
|
||||
@@ -612,23 +619,13 @@ public partial class MainWindow : Window, IUiActions
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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
|
||||
/// Window»</c>. Ogni tentativo successivo va annullato: il primo possiede lo
|
||||
/// spegnimento e lo porterà a termine.
|
||||
/// </para>
|
||||
/// Ogni tentativo successivo va annullato: il primo possiede lo spegnimento e lo
|
||||
/// porterà a termine.
|
||||
/// </summary>
|
||||
private async void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
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ù.
|
||||
if (!_closed)
|
||||
{
|
||||
e.Cancel = true;
|
||||
@@ -641,10 +638,9 @@ public partial class MainWindow : Window, IUiActions
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Il bot è in esecuzione. Chiudere l'applicazione lo ferma.\n\n" +
|
||||
(_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.") +
|
||||
(_config.Run.CloseOnShutdown
|
||||
? "I basket aperti verranno chiusi."
|
||||
: "I basket aperti RESTANO aperti sul conto, senza nessuno che applichi lo stop di basket o il take-profit. Gli stop nativi sul server restano attivi.") +
|
||||
"\n\nContinuare?",
|
||||
"Chiusura",
|
||||
MessageBoxButton.YesNo,
|
||||
@@ -666,12 +662,10 @@ public partial class MainWindow : Window, IUiActions
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Uno spegnimento andato storto non deve lasciare la finestra inchiodata: si
|
||||
// registra e si chiude comunque.
|
||||
Log.Error("errore durante lo spegnimento", ex);
|
||||
}
|
||||
|
||||
// Su un frame nuovo del dispatcher, non nella continuazione di OnClosing. Se il
|
||||
// Su un frame nuovo del dispatcher, non nella continuazione di OnClosing: se il
|
||||
// Task si completa in modo sincrono la ripresa avviene ancora dentro il callback
|
||||
// di chiusura, ed è lì che Close() solleva l'eccezione.
|
||||
_ = Dispatcher.BeginInvoke(DispatcherPriority.Normal, ChiudiDavvero);
|
||||
|
||||
@@ -5,18 +5,16 @@ using System.Windows.Media;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>Shared brushes, resolved once so converters do not allocate per binding tick.</summary>
|
||||
/// <summary>Shared brushes, resolved once so converters do not allocate per binding tick. Aligned with Theme.xaml.</summary>
|
||||
internal static class Palette
|
||||
{
|
||||
public static readonly SolidColorBrush Up = Freeze(Color.FromRgb(0x2E, 0xE6, 0xA8));
|
||||
public static readonly SolidColorBrush Down = Freeze(Color.FromRgb(0xFF, 0x5A, 0x7A));
|
||||
// Devono restare allineati a Theme.xaml: qui vestono gli assi dei grafici, che sono
|
||||
// disegnati a mano e non passano dal dizionario risorse.
|
||||
public static readonly SolidColorBrush Dim = Freeze(Color.FromRgb(0xA3, 0xAF, 0xC6));
|
||||
public static readonly SolidColorBrush Faint = Freeze(Color.FromRgb(0x7E, 0x8A, 0xA4));
|
||||
public static readonly SolidColorBrush Warn = Freeze(Color.FromRgb(0xFF, 0xB3, 0x47));
|
||||
public static readonly SolidColorBrush Accent = Freeze(Color.FromRgb(0x5B, 0x8C, 0xFF));
|
||||
public static readonly SolidColorBrush Text = Freeze(Color.FromRgb(0xE6, 0xEB, 0xF5));
|
||||
public static readonly SolidColorBrush Up = Freeze(Color.FromRgb(0x34, 0xD3, 0x99));
|
||||
public static readonly SolidColorBrush Down = Freeze(Color.FromRgb(0xF8, 0x71, 0x71));
|
||||
public static readonly SolidColorBrush Dim = Freeze(Color.FromRgb(0xA8, 0xB3, 0xC7));
|
||||
public static readonly SolidColorBrush Faint = Freeze(Color.FromRgb(0x7B, 0x87, 0x9E));
|
||||
public static readonly SolidColorBrush Warn = Freeze(Color.FromRgb(0xF5, 0xB7, 0x4F));
|
||||
public static readonly SolidColorBrush Accent = Freeze(Color.FromRgb(0x6C, 0x9C, 0xFF));
|
||||
public static readonly SolidColorBrush Text = Freeze(Color.FromRgb(0xEC, 0xEF, 0xF6));
|
||||
|
||||
private static SolidColorBrush Freeze(Color c)
|
||||
{
|
||||
@@ -89,57 +87,11 @@ public sealed class LevelBrushConverter : IValueConverter
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a conviction score in [-1, 1] to the left edge of the meter fill, as a
|
||||
/// fraction of a 200-pixel track centred on zero.
|
||||
/// </summary>
|
||||
public sealed class ScoreOffsetConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
double score = Math.Clamp(PnlBrushConverter.ToDouble(value), -1, 1);
|
||||
const double track = 200;
|
||||
double half = Math.Abs(score) * (track / 2);
|
||||
return new Thickness(score >= 0 ? track / 2 : (track / 2) - half, 0, 0, 0);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class ScoreWidthConverter : IValueConverter
|
||||
/// <summary>UTC timestamps rendered in the window's time zone (see <see cref="UiClock"/>).</summary>
|
||||
public sealed class LocalTimeConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
Math.Clamp(Math.Abs(PnlBrushConverter.ToDouble(value)), 0, 1) * 100;
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class ScoreBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
PnlBrushConverter.ToDouble(value) >= 0 ? Palette.Up : Palette.Down;
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Prices need more decimals the smaller they get; a dash when there is none.</summary>
|
||||
public sealed class PriceConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
double v = PnlBrushConverter.ToDouble(value);
|
||||
if (!double.IsFinite(v) || v == 0)
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
return v >= 1000 ? v.ToString("N2", culture)
|
||||
: v >= 1 ? v.ToString("N4", culture)
|
||||
: v.ToString("N6", culture);
|
||||
}
|
||||
value is DateTime utc ? UiClock.Smart(utc) : "—";
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
@@ -154,90 +106,3 @@ public sealed class YesNoConverter : IValueConverter
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamps rendered in the operator's own time zone. Everything inside the
|
||||
/// engine is UTC on purpose; the only place that should differ is the screen.
|
||||
/// </summary>
|
||||
public sealed class LocalTimeConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not DateTime utc || utc == default)
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
DateTime local = (utc.Kind == DateTimeKind.Utc ? utc : DateTime.SpecifyKind(utc, DateTimeKind.Utc))
|
||||
.ToLocalTime();
|
||||
|
||||
return local.Date == DateTime.Now.Date
|
||||
? local.ToString("HH:mm:ss", culture)
|
||||
: local.ToString("dd/MM HH:mm", culture);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Buy green, sell red — the same convention as everywhere else in the window.</summary>
|
||||
public sealed class SideBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
(value as string) switch
|
||||
{
|
||||
"acquisto" => Palette.Up,
|
||||
"vendita" => Palette.Down,
|
||||
_ => Palette.Dim,
|
||||
};
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Order status: filled reads as done, rejected as a problem, the rest as pending.</summary>
|
||||
public sealed class OrderStatusBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
(value as string) switch
|
||||
{
|
||||
"eseguito" => Palette.Up,
|
||||
"parziale" => Palette.Warn,
|
||||
"rifiutato" => Palette.Down,
|
||||
"annullato" or "scaduto" => Palette.Faint,
|
||||
_ => Palette.Accent,
|
||||
};
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Green only when the account has nothing standing in its way.</summary>
|
||||
public sealed class RestrictionBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value as string == "nessuna" ? Palette.Up : Palette.Warn;
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Fractional crypto sizes need many decimals; whole shares need none.</summary>
|
||||
public sealed class QuantityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
double v = PnlBrushConverter.ToDouble(value);
|
||||
if (!double.IsFinite(v))
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
return Math.Abs(v - Math.Truncate(v)) < 1e-9
|
||||
? v.ToString("N0", culture)
|
||||
: v.ToString("0.########", culture);
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
+14
-17
@@ -1,8 +1,8 @@
|
||||
<Window x:Class="Encelado.Bot.Ui.LoginWindow"
|
||||
<Window x:Class="Encelado.Bot.Ui.EtoroLoginWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Credenziali Binance"
|
||||
Width="560" SizeToContent="Height"
|
||||
Title="Chiavi eToro"
|
||||
Width="600" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ResizeMode="NoResize"
|
||||
Background="{StaticResource Bg}"
|
||||
@@ -11,33 +11,30 @@
|
||||
<Border Padding="24">
|
||||
<StackPanel>
|
||||
|
||||
<TextBlock Text="Credenziali Binance Futures" FontSize="19" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Accesso a eToro Public API" 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="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"/>
|
||||
Text="1. Su api-portal.etoro.com crea (o apri) la tua applicazione e genera la chiave dell'applicazione (x-api-key) e la chiave utente (x-user-key) per l'ambiente indicato sopra. Demo e reale hanno chiavi diverse."/>
|
||||
<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."/>
|
||||
Text="2. Incollale qui. Prima di salvare, il bot le verifica leggendo il profilo e il portafoglio: nessun ordine viene inviato."/>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0,8,0,0" TextWrapping="Wrap"
|
||||
Text="3. Le chiavi non finiscono mai nel file di configurazione, nel log o nel repository."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="API KEY" Style="{StaticResource Label}" Margin="0,18,0,6"/>
|
||||
<TextBox x:Name="KeyBox"/>
|
||||
<TextBlock Text="CHIAVE DELL'APPLICAZIONE (x-api-key)" Style="{StaticResource Label}" Margin="0,18,0,6"/>
|
||||
<PasswordBox x:Name="ApiKeyBox"/>
|
||||
|
||||
<TextBlock Text="API SECRET" Style="{StaticResource Label}" Margin="0,14,0,6"/>
|
||||
<PasswordBox x:Name="SecretBox"/>
|
||||
<TextBlock Text="CHIAVE UTENTE (x-user-key)" Style="{StaticResource Label}" Margin="0,14,0,6"/>
|
||||
<PasswordBox x:Name="UserKeyBox"/>
|
||||
|
||||
<CheckBox x:Name="SaveBox" Content="Ricorda queste chiavi su questo computer"
|
||||
IsChecked="True" Margin="0,16,0,0"/>
|
||||
<CheckBox x:Name="SaveBox" Content="Ricorda su questo computer" IsChecked="True" Margin="0,16,0,0"/>
|
||||
<TextBlock x:Name="StorageNote" Style="{StaticResource Sub}" Margin="24,4,0,0" TextWrapping="Wrap"/>
|
||||
|
||||
<Border x:Name="StatusBox" Style="{StaticResource Card}" Margin="0,16,0,0"
|
||||
Padding="11,9" Visibility="Collapsed">
|
||||
<Border x:Name="StatusBox" Style="{StaticResource Card}" Margin="0,16,0,0" Padding="11,9" Visibility="Collapsed">
|
||||
<TextBlock x:Name="StatusText" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
</Border>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Core.Broker;
|
||||
using Encelado.Etoro;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Asks for the two eToro keys, verifies them with two read-only calls (profile and
|
||||
/// portfolio) and saves them encrypted. The first time the operator runs the bot this
|
||||
/// is the only input it needs; afterwards it starts unattended.
|
||||
/// </summary>
|
||||
public partial class EtoroLoginWindow : Window
|
||||
{
|
||||
private readonly BotConfig _config;
|
||||
|
||||
public EtoroLoginWindow(BotConfig config)
|
||||
{
|
||||
InitializeComponent();
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_config = config;
|
||||
|
||||
bool demo = config.Etoro.IsDemo;
|
||||
EnvLine.Text = demo
|
||||
? "Ambiente DEMO — conto virtuale, denaro finto. Gli ordini partono davvero sul server demo."
|
||||
: "Ambiente REALE — gli ordini impegnano denaro vero. Servono le chiavi del conto reale.";
|
||||
StorageNote.Text = EtoroKeyStore.IsEncrypted
|
||||
? $"Salvate cifrate con DPAPI in {EtoroKeyStore.FilePath}: leggibili solo dal tuo account Windows."
|
||||
: "Su questo sistema DPAPI non è disponibile: il file sarà in chiaro, con permessi di solo proprietario.";
|
||||
|
||||
if (EtoroKeyStore.Load(demo) is { } existing)
|
||||
{
|
||||
ApiKeyBox.Password = existing.ApiKey;
|
||||
UserKeyBox.Password = existing.UserKey;
|
||||
ShowStatus($"Sono già salvate delle chiavi ({EtoroKeyStore.Mask(existing.ApiKey)}, del {existing.SavedUtc:yyyy-MM-dd}). Verifica di nuovo per sostituirle.", warning: false);
|
||||
}
|
||||
else if (config.Etoro.HasKeys)
|
||||
{
|
||||
ApiKeyBox.Password = config.Etoro.ApiKey;
|
||||
UserKeyBox.Password = config.Etoro.UserKey;
|
||||
}
|
||||
|
||||
Loaded += (_, _) => ApiKeyBox.Focus();
|
||||
}
|
||||
|
||||
private async void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string? api = EtoroKeyStore.Clean(ApiKeyBox.Password);
|
||||
string? user = EtoroKeyStore.Clean(UserKeyBox.Password);
|
||||
if (api is null || user is null)
|
||||
{
|
||||
ShowStatus("Inserisci sia la chiave dell'applicazione sia la chiave utente.", warning: true);
|
||||
return;
|
||||
}
|
||||
|
||||
EtoroOptions options = new()
|
||||
{
|
||||
Environment = _config.Etoro.Environment,
|
||||
BaseUrl = _config.Etoro.BaseUrl,
|
||||
RequestTimeoutSeconds = _config.Etoro.RequestTimeoutSeconds,
|
||||
FillTimeoutSeconds = _config.Etoro.FillTimeoutSeconds,
|
||||
ApiKey = api,
|
||||
UserKey = user,
|
||||
};
|
||||
|
||||
SetBusy(true, "Verifica delle chiavi in corso (profilo e portafoglio, sola lettura)…");
|
||||
try
|
||||
{
|
||||
(bool ok, string message) = await VerifyAsync(options).ConfigureAwait(true);
|
||||
if (!ok)
|
||||
{
|
||||
ShowStatus(message, warning: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_config.Etoro.ApiKey = api;
|
||||
_config.Etoro.UserKey = user;
|
||||
if (SaveBox.IsChecked == true)
|
||||
{
|
||||
EtoroKeyStore.Save(_config.Etoro.IsDemo, new EtoroKeys(api, user, DateTime.UtcNow));
|
||||
ShowStatus($"{message} Chiavi salvate in {EtoroKeyStore.FilePath}.", warning: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowStatus($"{message} Non salvate: valgono solo per questa sessione.", warning: false);
|
||||
}
|
||||
|
||||
DialogResult = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetBusy(false, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The whole read path an order would take, minus the order.</summary>
|
||||
public static async Task<(bool Ok, string Message)> VerifyAsync(EtoroOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
try
|
||||
{
|
||||
await using EtoroBroker broker = new(options);
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
|
||||
string user = await broker.VerifyAsync(cts.Token).ConfigureAwait(false);
|
||||
AccountSnapshot account = await broker.GetAccountAsync(cts.Token).ConfigureAwait(false);
|
||||
return (true, string.Create(CultureInfo.InvariantCulture,
|
||||
$"Conto {user} ({(options.IsDemo ? "DEMO" : "REALE")}) verificato — equity {account.Equity:N2} {account.Currency}, disponibile {account.Available:N2}."));
|
||||
}
|
||||
catch (BrokerException ex)
|
||||
{
|
||||
return (false, ex.StatusCode is 401 or 403
|
||||
? $"eToro ha rifiutato le chiavi ({ex.StatusCode}): controlla di aver copiato entrambe per l'ambiente giusto. {ex.Message}"
|
||||
: $"Verifica fallita: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return (false, $"Verifica fallita: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||
|
||||
private void SetBusy(bool busy, string? status)
|
||||
{
|
||||
Busy.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||
OkButton.IsEnabled = !busy;
|
||||
CancelButton.IsEnabled = !busy;
|
||||
ApiKeyBox.IsEnabled = !busy;
|
||||
UserKeyBox.IsEnabled = !busy;
|
||||
if (status is not null)
|
||||
{
|
||||
ShowStatus(status, warning: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowStatus(string message, bool warning)
|
||||
{
|
||||
StatusBox.Visibility = Visibility.Visible;
|
||||
StatusText.Text = message;
|
||||
StatusText.Foreground = warning ? Palette.Down : Palette.Up;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// What a page can ask the shell to do. Pages are given this rather than a reference to
|
||||
/// the window so they stay unaware of how the shell is put together — and so the only
|
||||
/// place that touches the supervisor, the key store or the file system stays the window
|
||||
/// itself. No decision is taken in the UI: it reads state and sends commands.
|
||||
/// </summary>
|
||||
public interface IUiActions
|
||||
{
|
||||
/// <summary>Closes both legs of one basket at market, after confirmation.</summary>
|
||||
Task CloseBasketAsync(string basket);
|
||||
|
||||
/// <summary>Closes everything and blocks new entries, after confirmation.</summary>
|
||||
Task KillSwitchAsync();
|
||||
|
||||
/// <summary>Switches the style preset at runtime; open baskets are not touched.</summary>
|
||||
Task SetPresetAsync(string preset);
|
||||
|
||||
/// <summary>Lifts the equity stop or the kill-switch; asks for the written reason that goes in the ledger.</summary>
|
||||
Task ResetEquityStopAsync();
|
||||
|
||||
void ShowLogin();
|
||||
|
||||
void ForgetCredentials();
|
||||
|
||||
void OpenConfigFile();
|
||||
|
||||
/// <summary>Opens <c>strategy.json</c> in the shell's default editor.</summary>
|
||||
void OpenStrategyFile();
|
||||
|
||||
/// <summary>Opens the data folder (ledger, market, models) in Explorer.</summary>
|
||||
void OpenDataFolder();
|
||||
|
||||
void OpenLogFolder();
|
||||
|
||||
void OpenLogFile();
|
||||
|
||||
/// <summary>Asks for a new log directory and persists it to the configuration file.</summary>
|
||||
void ChangeLogDirectory();
|
||||
|
||||
/// <summary>Rewrites the configuration with the factory values, after taking a backup.</summary>
|
||||
void RestoreDefaults();
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using System.Windows;
|
||||
using Encelado.Binance.Rest;
|
||||
using Encelado.Bot.Configuration;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
private readonly BotConfig _config;
|
||||
|
||||
public LoginWindow(BotConfig config)
|
||||
{
|
||||
InitializeComponent();
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_config = config;
|
||||
|
||||
bool testnet = config.Binance.Testnet;
|
||||
|
||||
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(testnet) is { } existing)
|
||||
{
|
||||
KeyBox.Text = existing.ApiKey;
|
||||
ShowStatus($"Sono già salvate delle chiavi ({CredentialStore.Mask(existing.ApiKey)}). " +
|
||||
"Inseriscine di nuove per sostituirle.", warning: false);
|
||||
}
|
||||
|
||||
Loaded += (_, _) => KeyBox.Focus();
|
||||
}
|
||||
|
||||
private async void OnConfirm(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string apiKey = CredentialStore.Clean(KeyBox.Text) ?? string.Empty;
|
||||
string apiSecret = CredentialStore.Clean(SecretBox.Password) ?? string.Empty;
|
||||
|
||||
if (apiKey.Length == 0 || apiSecret.Length == 0)
|
||||
{
|
||||
ShowStatus("Inserisci sia la API key sia il secret.", warning: true);
|
||||
return;
|
||||
}
|
||||
|
||||
SetBusy(true);
|
||||
try
|
||||
{
|
||||
(bool ok, string message, FuturesAccount? account) = await CredentialResolver
|
||||
.VerifyAsync(apiKey, apiSecret, _config.Binance.Testnet, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
ShowStatus(message, warning: true);
|
||||
return;
|
||||
}
|
||||
|
||||
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} — margine disponibile {account!.AvailableBalance:N2} USDT.{where}",
|
||||
warning: false);
|
||||
|
||||
DialogResult = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||
|
||||
private void SetBusy(bool busy)
|
||||
{
|
||||
Busy.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||
OkButton.IsEnabled = !busy;
|
||||
CancelButton.IsEnabled = !busy;
|
||||
KeyBox.IsEnabled = !busy;
|
||||
SecretBox.IsEnabled = !busy;
|
||||
OkButton.Content = busy ? "Verifica in corso…" : "Verifica e salva";
|
||||
}
|
||||
|
||||
private void ShowStatus(string message, bool warning)
|
||||
{
|
||||
StatusBox.Visibility = Visibility.Visible;
|
||||
StatusText.Text = message;
|
||||
StatusText.Foreground = warning ? Palette.Down : Palette.Up;
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,15 @@ using Encelado.Bot.Engine;
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// The window's state, fed from a <see cref="BotSnapshot"/> on a timer. Nothing here
|
||||
/// is updated per tick: the engine works at its own pace and the view catches up every
|
||||
/// second, which is all a person can read anyway.
|
||||
/// </summary>
|
||||
public sealed class MainViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private string _mode = "TESTNET";
|
||||
private string _modeKind = "paper";
|
||||
private string _mode = "DEMO";
|
||||
private string _modeKind = "demo";
|
||||
private string _executionMode = string.Empty;
|
||||
private string _stateText = "fermo";
|
||||
private string _stateKind = "stopped";
|
||||
private bool _isRunning;
|
||||
@@ -23,59 +24,60 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
private string _banner = string.Empty;
|
||||
private bool _hasBanner;
|
||||
private bool _bannerIsWarning;
|
||||
|
||||
private double _equity;
|
||||
private double _walletBalance;
|
||||
private double _balance;
|
||||
private double _availableBalance;
|
||||
private double _unrealized;
|
||||
private double _realizedToday;
|
||||
private double _pnlSession;
|
||||
private double _pnlSessionPct;
|
||||
private double _marginRatio;
|
||||
private double _exposurePct;
|
||||
private int _openPairs;
|
||||
private int _maxOpenPairs;
|
||||
private int _tradesToday;
|
||||
private int _maxTradesPerDay;
|
||||
private double _maxDailyLossPct;
|
||||
private string _sizing = "—";
|
||||
private int _leverage = 2;
|
||||
|
||||
private string _marketData = "—";
|
||||
private string _orderStream = "—";
|
||||
private double _peakEquity;
|
||||
private double _drawdownPct;
|
||||
private double _equityStopPct;
|
||||
private double _todayPnl;
|
||||
private double _todayPnlPct;
|
||||
private double _openPnl;
|
||||
private double _openPnlPct;
|
||||
private int _openBaskets;
|
||||
private int _maxBaskets;
|
||||
private bool _equityStopped;
|
||||
private bool _killSwitched;
|
||||
private string _preset = "—";
|
||||
private string _strategyVersion = string.Empty;
|
||||
private string _apiState = "fermo";
|
||||
private string _apiLatency = "—";
|
||||
private string _clock = "--:--:--";
|
||||
private string _clockZone = UiClock.Label;
|
||||
private double _clockSkew;
|
||||
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<OrderRow> Orders { get; } = [];
|
||||
private string _nextEvent = "—";
|
||||
private string _volForecast = "—";
|
||||
private string _mlState = "—";
|
||||
private string _banditProposal = "—";
|
||||
private string _calendarState = "—";
|
||||
private string _newsState = "—";
|
||||
|
||||
public ObservableCollection<EventRow> Events { get; } = [];
|
||||
|
||||
/// <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 ObservableCollection<BasketRow> Baskets { get; } = [];
|
||||
|
||||
public bool HasAccount => _account is not null;
|
||||
public ObservableCollection<QuoteRow> Quotes { get; } = [];
|
||||
|
||||
/// <summary>How many lines the status strip keeps. Set once from the configuration.</summary>
|
||||
public ObservableCollection<SentimentRow> Sentiment { get; } = [];
|
||||
|
||||
public ObservableCollection<CalendarRow> NextEvents { get; } = [];
|
||||
|
||||
public IReadOnlyList<string> Presets { get; } = ["CONSERVATIVE", "MODERATE", "AGGRESSIVE"];
|
||||
|
||||
/// <summary>How many activity lines the dashboard keeps.</summary>
|
||||
public int StatusLines { get; init; } = 200;
|
||||
|
||||
/// <summary>Backs the log page. Fed by push from the supervisor, not by the snapshot.</summary>
|
||||
public required LogViewModel Log { get; init; }
|
||||
|
||||
public string Mode { get => _mode; private set => Set(ref _mode, value); }
|
||||
|
||||
/// <summary>"paper", "live" or "dry" — drives the badge colour.</summary>
|
||||
/// <summary><c>paper</c>, <c>demo</c> or <c>live</c>, for the badge colour.</summary>
|
||||
public string ModeKind { get => _modeKind; private set => Set(ref _modeKind, value); }
|
||||
|
||||
public string ExecutionMode { get => _executionMode; private set => Set(ref _executionMode, value); }
|
||||
|
||||
public string StateText { get => _stateText; private set => Set(ref _stateText, value); }
|
||||
|
||||
public string StateKind { get => _stateKind; private set => Set(ref _stateKind, value); }
|
||||
@@ -83,15 +85,8 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
public bool IsRunning { get => _isRunning; private set => Set(ref _isRunning, value); }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the start/stop button accepts a click.
|
||||
/// <para>
|
||||
/// Owned entirely by the view model, and deliberately so. The window used to disable
|
||||
/// the button by assigning <c>PowerBtn.IsEnabled = false</c> while a start or stop
|
||||
/// was in flight — which in WPF replaces the binding with a local value and
|
||||
/// <i>permanently detaches it</i>. The button went dead on the first click and the
|
||||
/// bot could no longer be stopped from the window at all. Nothing outside this class
|
||||
/// touches that property now.
|
||||
/// </para>
|
||||
/// Whether the power button accepts a click. False while a start or stop is in
|
||||
/// flight, so a double click cannot queue a second command behind the first.
|
||||
/// </summary>
|
||||
public bool CanToggle => !_isBusy && _stateKind is not ("starting" or "stopping");
|
||||
|
||||
@@ -121,76 +116,78 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
public double Equity { get => _equity; private set => Set(ref _equity, value); }
|
||||
|
||||
public double WalletBalance { get => _walletBalance; private set => Set(ref _walletBalance, value); }
|
||||
public double Balance { get => _balance; private set => Set(ref _balance, value); }
|
||||
|
||||
public double AvailableBalance
|
||||
{
|
||||
get => _availableBalance;
|
||||
private set => Set(ref _availableBalance, value);
|
||||
}
|
||||
public double AvailableBalance { get => _availableBalance; private set => Set(ref _availableBalance, value); }
|
||||
|
||||
public double Unrealized { get => _unrealized; private set => Set(ref _unrealized, value); }
|
||||
public double PeakEquity { get => _peakEquity; private set => Set(ref _peakEquity, value); }
|
||||
|
||||
public double RealizedToday { get => _realizedToday; private set => Set(ref _realizedToday, value); }
|
||||
public double DrawdownPct { get => _drawdownPct; private set => Set(ref _drawdownPct, value); }
|
||||
|
||||
public double PnlSession { get => _pnlSession; private set => Set(ref _pnlSession, value); }
|
||||
public double EquityStopPct { get => _equityStopPct; private set => Set(ref _equityStopPct, value); }
|
||||
|
||||
public double PnlSessionPct { get => _pnlSessionPct; private set => Set(ref _pnlSessionPct, value); }
|
||||
public string DrawdownSub => _equityStopPct > 0
|
||||
? string.Create(CultureInfo.CurrentCulture, $"picco {_peakEquity:N2} · stop a {_equityStopPct:P0}")
|
||||
: string.Create(CultureInfo.CurrentCulture, $"picco {_peakEquity:N2}");
|
||||
|
||||
/// <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 TodayPnl { get => _todayPnl; private set => Set(ref _todayPnl, value); }
|
||||
|
||||
public double ExposurePct { get => _exposurePct; private set => Set(ref _exposurePct, value); }
|
||||
public double TodayPnlPct { get => _todayPnlPct; private set => Set(ref _todayPnlPct, value); }
|
||||
|
||||
public int OpenPairs { get => _openPairs; private set => Set(ref _openPairs, value); }
|
||||
public double OpenPnl { get => _openPnl; private set => Set(ref _openPnl, value); }
|
||||
|
||||
public int MaxOpenPairs { get => _maxOpenPairs; private set => Set(ref _maxOpenPairs, value); }
|
||||
public double OpenPnlPct { get => _openPnlPct; private set => Set(ref _openPnlPct, value); }
|
||||
|
||||
public int TradesToday { get => _tradesToday; private set => Set(ref _tradesToday, value); }
|
||||
public int OpenBaskets { get => _openBaskets; private set => Set(ref _openBaskets, value); }
|
||||
|
||||
public int MaxTradesPerDay { get => _maxTradesPerDay; private set => Set(ref _maxTradesPerDay, value); }
|
||||
public int MaxBaskets { get => _maxBaskets; private set => Set(ref _maxBaskets, value); }
|
||||
|
||||
/// <summary>
|
||||
/// 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 PairsDisplay => Counter(_openPairs, _maxOpenPairs);
|
||||
public string BasketsDisplay => _maxBaskets > 0 ? $"{_openBaskets} / {_maxBaskets}" : _openBaskets.ToString(CultureInfo.CurrentCulture);
|
||||
|
||||
public string TradesDisplay => Counter(_tradesToday, _maxTradesPerDay);
|
||||
public bool EquityStopped { get => _equityStopped; private set => Set(ref _equityStopped, value); }
|
||||
|
||||
private static string Counter(int value, int limit) =>
|
||||
limit > 0
|
||||
? string.Create(CultureInfo.CurrentCulture, $"{value} / {limit}")
|
||||
: value.ToString(CultureInfo.CurrentCulture);
|
||||
public bool KillSwitched { get => _killSwitched; private set => Set(ref _killSwitched, value); }
|
||||
|
||||
public double MaxDailyLossPct { get => _maxDailyLossPct; private set => Set(ref _maxDailyLossPct, value); }
|
||||
/// <summary>The active preset label. Set by the engine; the page changes it through a command, never directly.</summary>
|
||||
public string Preset { get => _preset; private set => Set(ref _preset, value); }
|
||||
|
||||
public string Sizing { get => _sizing; private set => Set(ref _sizing, value); }
|
||||
public string StrategyVersion { get => _strategyVersion; private set => Set(ref _strategyVersion, value); }
|
||||
|
||||
public int Leverage { get => _leverage; private set => Set(ref _leverage, value); }
|
||||
public string ApiState { get => _apiState; private set => Set(ref _apiState, value); }
|
||||
|
||||
public string MarketDataState { get => _marketData; private set => Set(ref _marketData, value); }
|
||||
public string ApiLatency { get => _apiLatency; private set => Set(ref _apiLatency, value); }
|
||||
|
||||
public string OrderStreamState { get => _orderStream; private set => Set(ref _orderStream, value); }
|
||||
/// <summary>Wall clock in the window's time zone, refreshed by the window's timer.</summary>
|
||||
public string Clock { get => _clock; set => Set(ref _clock, value); }
|
||||
|
||||
public string ClockZone { get => _clockZone; set => Set(ref _clockZone, value); }
|
||||
|
||||
public double ClockSkew { get => _clockSkew; private set => Set(ref _clockSkew, 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); }
|
||||
|
||||
public IReadOnlyList<double> EquityCurve { get => _equityCurve; private set => Set(ref _equityCurve, value); }
|
||||
/// <summary>The next high-impact event, one line, for the context strip.</summary>
|
||||
public string NextEvent { get => _nextEvent; private set => Set(ref _nextEvent, value); }
|
||||
|
||||
public string VolForecast { get => _volForecast; private set => Set(ref _volForecast, value); }
|
||||
|
||||
public string MlState { get => _mlState; private set => Set(ref _mlState, value); }
|
||||
|
||||
public string BanditProposal { get => _banditProposal; private set => Set(ref _banditProposal, value); }
|
||||
|
||||
public string CalendarState { get => _calendarState; private set => Set(ref _calendarState, value); }
|
||||
|
||||
public string NewsState { get => _newsState; private set => Set(ref _newsState, value); }
|
||||
|
||||
public void Apply(BotSnapshot s)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(s);
|
||||
|
||||
Mode = s.Mode;
|
||||
ModeKind = s.DryRun ? "dry" : s.Testnet ? "paper" : "live";
|
||||
ModeKind = s.EnvironmentKind;
|
||||
ExecutionMode = s.ExecutionMode;
|
||||
|
||||
IsRunning = s.State is BotState.Running or BotState.Starting;
|
||||
PowerText = IsRunning ? "FERMA" : "AVVIA";
|
||||
@@ -211,70 +208,64 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
ApplyBanner(s);
|
||||
|
||||
Equity = s.Equity;
|
||||
WalletBalance = s.WalletBalance;
|
||||
Balance = s.Balance;
|
||||
AvailableBalance = s.AvailableBalance;
|
||||
Unrealized = s.UnrealizedPnl;
|
||||
RealizedToday = s.RealizedToday;
|
||||
PnlSession = s.PnlSession;
|
||||
PnlSessionPct = s.PnlSessionPct;
|
||||
MarginRatio = s.MarginRatio;
|
||||
ExposurePct = s.ExposurePct;
|
||||
OpenPairs = s.OpenPairs;
|
||||
MaxOpenPairs = s.MaxOpenPairs;
|
||||
TradesToday = s.TradesToday;
|
||||
MaxTradesPerDay = s.MaxTradesPerDay;
|
||||
MaxDailyLossPct = s.MaxDailyLossPct;
|
||||
Sizing = s.Sizing;
|
||||
Leverage = s.Leverage;
|
||||
Raise(nameof(PairsDisplay));
|
||||
Raise(nameof(TradesDisplay));
|
||||
PeakEquity = s.PeakEquity;
|
||||
DrawdownPct = s.DrawdownPct;
|
||||
EquityStopPct = s.EquityStopPct;
|
||||
Raise(nameof(DrawdownSub));
|
||||
TodayPnl = s.TodayPnl;
|
||||
TodayPnlPct = s.TodayPnlPct;
|
||||
OpenPnl = s.OpenPnl;
|
||||
OpenPnlPct = s.OpenPnlPct;
|
||||
OpenBaskets = s.OpenBaskets;
|
||||
MaxBaskets = s.MaxBaskets;
|
||||
Raise(nameof(BasketsDisplay));
|
||||
EquityStopped = s.EquityStopped;
|
||||
KillSwitched = s.KillSwitched;
|
||||
|
||||
MarketDataState = s.MarketDataState;
|
||||
OrderStreamState = s.OrderStreamState;
|
||||
TimeFrame = s.TimeFrame;
|
||||
Endpoint = s.Endpoint;
|
||||
Preset = s.Preset;
|
||||
StrategyVersion = s.StrategyVersion;
|
||||
ApiState = s.ApiState;
|
||||
ApiLatency = double.IsFinite(s.ApiLatencyMs) ? s.ApiLatencyMs.ToString("0", CultureInfo.CurrentCulture) + " ms" : "—";
|
||||
ClockSkew = s.ClockSkewSeconds;
|
||||
Uptime = s.Uptime > TimeSpan.Zero ? FormatUptime(s.Uptime) : "—";
|
||||
Latency = $"{s.BarToSignal}\n{s.SignalToOrder}";
|
||||
Counters = $"barre {s.Bars} segnali {s.Signals} ordini {s.Orders} eseguiti {s.Fills} " +
|
||||
$"uscite {s.Exits} blocchi risk {s.RiskRejects} errori {s.Errors}";
|
||||
Counters = s.Counters;
|
||||
|
||||
double[] curve = new double[s.EquityCurve.Count];
|
||||
for (int i = 0; i < curve.Length; i++)
|
||||
if (s.Context is { } c)
|
||||
{
|
||||
curve[i] = s.EquityCurve[i].Equity;
|
||||
VolForecast = c.VolForecast;
|
||||
MlState = c.MlState;
|
||||
BanditProposal = c.BanditProposal;
|
||||
CalendarState = c.CalendarState;
|
||||
NewsState = c.NewsState;
|
||||
CalendarRow? next = c.NextEvents.FirstOrDefault(static e => e.TimeUtc >= DateTime.UtcNow);
|
||||
NextEvent = next is null ? "nessun evento ad alto impatto in vista" : $"{next.Currency} {next.Title} · {next.TimeLocal} ({next.InMinutes})";
|
||||
Sync(Sentiment, c.Sentiment, static (a, b) => a.Currency == b.Currency);
|
||||
Sync(NextEvents, c.NextEvents, static (a, b) => a.TimeUtc == b.TimeUtc && a.Title == b.Title);
|
||||
}
|
||||
|
||||
EquityCurve = curve;
|
||||
|
||||
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(Orders, s.OrderHistory, static (a, b) => a.OrderId == b.OrderId);
|
||||
Sync(Baskets, s.Baskets, static (a, b) => a.Name == b.Name);
|
||||
Sync(Quotes, s.Quotes, static (a, b) => a.Symbol == b.Symbol);
|
||||
SyncEvents(s.Events);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <summary>Picks the one thing most worth saying at the top of the window.</summary>
|
||||
private void ApplyBanner(BotSnapshot s)
|
||||
{
|
||||
if (s.Halted)
|
||||
if (s.EquityStopped)
|
||||
{
|
||||
Banner = $"OPERATIVITÀ SOSPESA — {s.HaltReason}";
|
||||
Banner = $"EQUITY STOP — {s.HaltReason}. Serve un reset manuale con motivazione.";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(s.StreamRejection))
|
||||
if (s.Halted)
|
||||
{
|
||||
Banner = $"BINANCE RIFIUTA LA CONNESSIONE — {s.StreamRejection}";
|
||||
Banner = $"OPERATIVITÀ SOSPESA — {s.HaltReason}";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
BannerIsWarning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,15 +277,23 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.DryRun && IsRunning)
|
||||
if (IsRunning && s.EntriesBlockedReason is { Length: > 0 } blocked)
|
||||
{
|
||||
Banner = "DRY-RUN attivo: le decisioni vengono calcolate e mostrate, ma nessun ordine viene inviato.";
|
||||
Banner = $"Nuove entrate bloccate: {blocked}. Le uscite restano attive.";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!s.Testnet && IsRunning)
|
||||
if (IsRunning && s.ApiState is "caduta" or "disconnesso")
|
||||
{
|
||||
Banner = "Collegamento a eToro caduto: il motore prova a riconnettersi da solo.";
|
||||
HasBanner = true;
|
||||
BannerIsWarning = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.EnvironmentKind == "live" && IsRunning)
|
||||
{
|
||||
Banner = "Conto REALE: gli ordini impegnano denaro vero.";
|
||||
HasBanner = true;
|
||||
@@ -307,7 +306,7 @@ public sealed class MainViewModel : INotifyPropertyChanged
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the contents only where they actually differ. Clearing and refilling
|
||||
/// would drop the user's selection and scroll position every second.
|
||||
/// would drop the user's selection and scroll position on every refresh.
|
||||
/// </summary>
|
||||
private static void Sync<T>(ObservableCollection<T> target, IReadOnlyList<T> source, Func<T, T, bool> sameKey)
|
||||
{
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
private UserControl? _page;
|
||||
private string _badge = string.Empty;
|
||||
|
||||
public string Title { get; } = title;
|
||||
|
||||
/// <summary>A Segoe MDL2 Assets code point.</summary>
|
||||
public string Glyph { get; } = glyph;
|
||||
|
||||
/// <summary>Small count shown on the right of the entry, e.g. the number of open positions.</summary>
|
||||
public string Badge
|
||||
{
|
||||
get => _badge;
|
||||
set
|
||||
{
|
||||
if (_badge == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_badge = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Badge)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasBadge)));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasBadge => _badge.Length > 0;
|
||||
|
||||
public UserControl Page => _page ??= factory();
|
||||
|
||||
/// <summary>The fallback any accessibility client falls back to. Never the type name.</summary>
|
||||
public override string ToString() => Title;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a page can ask the shell to do. Pages are given this rather than a reference to
|
||||
/// the window so they stay unaware of how the shell is put together — and so the only
|
||||
/// place that touches the supervisor, the credential store or the file system stays the
|
||||
/// window itself.
|
||||
/// </summary>
|
||||
public interface IUiActions
|
||||
{
|
||||
/// <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();
|
||||
|
||||
void ForgetCredentials();
|
||||
|
||||
void OpenConfigFile();
|
||||
|
||||
void OpenLogFolder();
|
||||
|
||||
void OpenLogFile();
|
||||
|
||||
/// <summary>Asks for a new log directory and persists it to the configuration file.</summary>
|
||||
void ChangeLogDirectory();
|
||||
|
||||
/// <summary>Rewrites the configuration with the factory values, after taking a backup.</summary>
|
||||
void RestoreDefaults();
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.DashboardPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
La dashboard: solo quello che serve a capire in tre secondi come sta andando.
|
||||
Cinque numeri, la tabella dei basket, una striscia di contesto e le ultime righe
|
||||
del log. Nessuna decisione avviene qui: la pagina legge lo snapshot del motore e
|
||||
gli manda comandi (chiudi, kill-switch, preset, reset). I dettagli stanno nei
|
||||
tooltip e nella pagina Log.
|
||||
-->
|
||||
|
||||
<UserControl.Resources>
|
||||
<Style x:Key="Cell" TargetType="TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
|
||||
</Style>
|
||||
<Style x:Key="CellDim" TargetType="TextBlock" BasedOn="{StaticResource Cell}">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
</Style>
|
||||
<Style x:Key="Small" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel MaxWidth="1480" HorizontalAlignment="Stretch">
|
||||
|
||||
<!-- ==================== intestazione ==================== -->
|
||||
<Grid Margin="0,0,0,14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Correlation Baskets" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Cinque basket di due coppie forex correlate: ingresso quando il cross sintetico diverge (z-score oltre la soglia del preset), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale. Il bot apre e chiude da solo."/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding StrategyVersion}" Style="{StaticResource Sub}"
|
||||
ToolTip="Versione del codice, hash di strategy.json e id della sessione, scritti in ogni riga del ledger."/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,12,0">
|
||||
<TextBlock Text="Preset" Style="{StaticResource Label}" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<ComboBox x:Name="PresetBox" Width="160" ItemsSource="{Binding Presets}" SelectedItem="{Binding Preset, Mode=OneWay}"
|
||||
SelectionChanged="OnPresetChanged" IsEnabled="{Binding IsRunning}"
|
||||
ToolTip="Conservative: z 2,5, rischio 0,25 %, 2 basket, TP 8 pip. Moderate: z 2,0, 0,5 %, 3 basket, TP 10. Aggressive: z 1,5, 1 %, 5 basket, TP 12. Il cambio a caldo non tocca i basket aperti."/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2" Content="KILL-SWITCH" Click="OnKillSwitch" Style="{StaticResource Danger}" MinWidth="120"
|
||||
FontWeight="SemiBold" IsEnabled="{Binding IsRunning}"
|
||||
ToolTip="Chiude tutte le gambe di tutti i basket a mercato e blocca le nuove entrate. Chiede conferma. Lo stesso effetto si ottiene creando un file STOP nella cartella di lavoro."/>
|
||||
</Grid>
|
||||
|
||||
<!-- ==================== avviso ==================== -->
|
||||
<Border Margin="0,0,0,14" CornerRadius="10" Padding="14,10"
|
||||
Visibility="{Binding HasBanner, Converter={StaticResource BoolVis}}">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="#1AF87171"/>
|
||||
<Setter Property="BorderBrush" Value="#66F87171"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BannerIsWarning}" Value="True">
|
||||
<Setter Property="Background" Value="#1AF5B74F"/>
|
||||
<Setter Property="BorderBrush" Value="#66F5B74F"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding Banner}" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Down}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BannerIsWarning}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<Button Grid.Column="1" Content="Sblocca…" Click="OnResetEquityStop" Margin="12,0,0,0"
|
||||
Visibility="{Binding EquityStopped, Converter={StaticResource BoolVis}}"
|
||||
ToolTip="Sblocca il bot dopo un equity stop o un kill-switch. La motivazione scritta finisce nel ledger."/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== i cinque numeri ==================== -->
|
||||
<UniformGrid Rows="1" Columns="5" Margin="0,0,-12,14">
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="Equity = saldo + P&L non realizzato. È il numero su cui si calcolano rischio per basket, equity stop e perdita giornaliera.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Equity" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Equity, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding Balance, StringFormat='saldo {0:N2}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="P&L chiuso di oggi (giornata UTC), dal ledger dei basket, e in percentuale dell'equity di inizio giornata. Al 3 % di perdita il bot non apre più fino a domani.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L oggi" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding TodayPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding TodayPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding TodayPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="P&L aperto complessivo dei basket, netto dei costi già maturati, e in percentuale dell'equity.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L aperto" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding OpenPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding OpenPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding OpenPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="Distanza dell'equity dal suo massimo storico. All'equity stop il bot chiude tutto e si blocca finché non lo sblocchi con una motivazione.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Drawdown" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding DrawdownPct, StringFormat='{}{0:0.00%}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding DrawdownSub}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="Basket aperti sul massimo consentito dal preset in vigore.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Basket aperti" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding BasketsDisplay}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding Preset, StringFormat='preset {0}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
|
||||
<!-- ==================== basket ==================== -->
|
||||
<Border Style="{StaticResource Card}" Padding="0" Margin="0,0,0,14">
|
||||
<DataGrid ItemsSource="{Binding Baskets}" MinHeight="120" ColumnHeaderHeight="36" HorizontalScrollBarVisibility="Disabled">
|
||||
<DataGrid.RowStyle>
|
||||
<Style TargetType="DataGridRow" BasedOn="{StaticResource {x:Type DataGridRow}}">
|
||||
<Setter Property="ToolTip" Value="{Binding Tooltip}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Enabled}" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</DataGrid.RowStyle>
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Header="Basket" Width="190">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Cross, StringFormat='cross {0}'}" FontSize="10.5" Foreground="{StaticResource Faint}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTemplateColumn Header="Stato" Width="110">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource Chip}" HorizontalAlignment="Left" Padding="9,2">
|
||||
<TextBlock Text="{Binding StateLabel}" FontSize="11">
|
||||
<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}"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding State}" Value="Error">
|
||||
<Setter Property="Foreground" Value="{StaticResource Down}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="z" Binding="{Binding ZDisplay}" Width="72" ElementStyle="{StaticResource Cell}"/>
|
||||
<DataGridTextColumn Header="ρ" Binding="{Binding RhoDisplay}" Width="66" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="HL" Binding="{Binding HalfLifeDisplay}" Width="54" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="Pips" Binding="{Binding PipsDisplay}" Width="70" ElementStyle="{StaticResource Cell}"/>
|
||||
<DataGridTextColumn Header="TP" Binding="{Binding TpDisplay}" Width="50" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="P&L $" Binding="{Binding PnlDisplay}" Width="104">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Cell}">
|
||||
<Setter Property="Foreground" Value="{Binding PnlUsd, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="Costo" Binding="{Binding CostDisplay}" Width="66" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="p ML" Binding="{Binding PMlDisplay}" Width="112" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="Prossimo evento" Binding="{Binding NextEvent}" Width="230" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTemplateColumn Header="" Width="90">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Button Content="CHIUDI" Click="OnCloseBasket" Tag="{Binding Name}" Style="{StaticResource Danger}"
|
||||
Padding="9,3" FontSize="11" FontWeight="SemiBold"
|
||||
Visibility="{Binding IsOpen, Converter={StaticResource BoolVis}}"
|
||||
ToolTip="Chiude entrambe le gambe a mercato, dopo conferma."/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== contesto ==================== -->
|
||||
<Grid Margin="0,0,0,14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Style="{StaticResource Card}" Margin="0,0,12,0" Padding="14,12"
|
||||
ToolTip="Dal calendario settimanale FairEconomy. Blackout: nessuna entrata nei 45 minuti prima e nei 30 dopo un evento ad alto impatto sulle valute del basket.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Prossimo evento" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding NextEvent}" Style="{StaticResource Small}" Margin="0,6,0,0" Foreground="{StaticResource Txt}"/>
|
||||
<TextBlock Text="{Binding CalendarState}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Style="{StaticResource Card}" Margin="0,0,12,0" Padding="14,12"
|
||||
ToolTip="Stato del collegamento a eToro Public API, latenza dell'ultima richiesta e uso delle quote (120 richieste al minuto per le quotazioni, 20 per gli ordini).">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Collegamento eToro" Style="{StaticResource Label}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<TextBlock Text="{Binding ApiState}" Style="{StaticResource Small}" Foreground="{StaticResource Txt}"/>
|
||||
<TextBlock Text="{Binding ApiLatency}" Style="{StaticResource Small}" Margin="8,0,0,0"/>
|
||||
<TextBlock Text="{Binding Uptime, StringFormat='· attivo da {0}'}" Style="{StaticResource Small}" Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Counters}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Style="{StaticResource Card}" Padding="14,12"
|
||||
ToolTip="Meta-modello (regressione logistica online, in ombra finché non supera i cancelli di attivazione), volatilità prevista (EWMA contro HAR-RV) e proposta del bandit sul preset.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Apprendimento" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding MlState}" Style="{StaticResource Small}" Margin="0,6,0,0" Foreground="{StaticResource Txt}" TextTrimming="CharacterEllipsis" MaxHeight="34"/>
|
||||
<TextBlock Text="{Binding VolForecast}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}" TextTrimming="CharacterEllipsis" MaxHeight="34"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ==================== attività ==================== -->
|
||||
<Border Style="{StaticResource Card}" Padding="14,12">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<TextBlock Text="Attività" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Le ultime righe del log. La scheda Log tiene tutta la cronologia, filtra per livello e apre il file su disco."/>
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Events}" MaxHeight="220">
|
||||
<ItemsControl.Template>
|
||||
<ControlTemplate TargetType="ItemsControl">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsPresenter/>
|
||||
</ScrollViewer>
|
||||
</ControlTemplate>
|
||||
</ItemsControl.Template>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="70"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding Time}" FontFamily="{StaticResource Mono}" FontSize="11" Foreground="{StaticResource Faint}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Message}" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard. It reads the view model and forwards every click to
|
||||
/// <see cref="IUiActions"/>: no decision, no engine access, no file system here.
|
||||
/// </summary>
|
||||
public partial class DashboardPage : UserControl
|
||||
{
|
||||
private bool _presetChangeFromUser = true;
|
||||
|
||||
public DashboardPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
private async void OnCloseBasket(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: string basket } button || basket.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Disabled for the round trip so an impatient second click cannot submit a
|
||||
// second closing order against a basket that is already on its way out.
|
||||
button.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.CloseBasketAsync(basket);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
button.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnKillSwitch(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.KillSwitchAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnResetEquityStop(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.ResetEquityStopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPresetChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
// The combo is refreshed from the snapshot every second; only a selection made by
|
||||
// a person becomes a command. The view model's Preset is one-way on purpose.
|
||||
if (!_presetChangeFromUser || !IsLoaded || sender is not ComboBox box || box.SelectedItem is not string chosen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is MainViewModel vm && string.Equals(vm.Preset, chosen, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_presetChangeFromUser = false;
|
||||
try
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.SetPresetAsync(chosen);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_presetChangeFromUser = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,14 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="LIVELLO" Style="{StaticResource Label}" VerticalAlignment="Center"
|
||||
<TextBlock Text="Livello" Style="{StaticResource Label}" VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"/>
|
||||
<ComboBox Width="110" ItemsSource="{Binding Log.LevelFilters}"
|
||||
SelectedItem="{Binding Log.LevelFilter}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="20,0,0,0">
|
||||
<TextBlock Text="CERCA" Style="{StaticResource Label}" VerticalAlignment="Center"
|
||||
<TextBlock Text="Cerca" Style="{StaticResource Label}" VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBox Width="230" Padding="8,5"
|
||||
Text="{Binding Log.Search, UpdateSourceTrigger=PropertyChanged, Delay=250}"/>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.PositionsPage"
|
||||
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="Operatività" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
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>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
|
||||
<StackPanel>
|
||||
|
||||
<!-- ==================== 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="MARGINE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Account.MarginBalance, StringFormat=N2}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="DISPONIBILE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding Account.AvailableBalance, StringFormat=N2}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<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>
|
||||
|
||||
<!-- ==================== 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>
|
||||
|
||||
<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="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="Ingresso" Width="110"
|
||||
Binding="{Binding EntryPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="Ultimo" Width="110"
|
||||
Binding="{Binding LastPrice, Converter={StaticResource Price}}"/>
|
||||
<DataGridTextColumn Header="Controvalore" Width="120"
|
||||
Binding="{Binding MarketValue, StringFormat=N2}"/>
|
||||
<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}}"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="%" Width="*"
|
||||
Binding="{Binding UnrealizedPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</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,18 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="230"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="150"/>
|
||||
<ColumnDefinition Width="260"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
|
||||
<TextBlock Text="Impostazioni" Style="{StaticResource PageTitle}"/>
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
ToolTip="Ogni campo ha una spiegazione: passa il puntatore sopra l'etichetta o sul pallino. I campi con il lucchetto sono fissati dalla strategia e non modificabili. Le credenziali si applicano subito; tutto il resto ha effetto al prossimo avvio."/>
|
||||
ToolTip="Ogni campo ha una spiegazione: passa il puntatore sopra l'etichetta o sul pallino. Le chiavi si applicano subito; tutto il resto ha effetto al prossimo avvio. I parametri della strategia si modificano in strategy.json."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ==================== barra di salvataggio ==================== -->
|
||||
@@ -126,12 +126,12 @@
|
||||
<!-- ==================== credenziali ==================== -->
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="CREDENZIALI BINANCE" Style="{StaticResource Head}"/>
|
||||
<TextBlock Text="Chiavi eToro" 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">
|
||||
<Button Content="Inserisci / sostituisci chiavi" Click="OnLogin" Style="{StaticResource Primary}"/>
|
||||
<Button Content="Rimuovi chiavi salvate" Click="OnLogout" Margin="10,0,0,0" Style="{StaticResource Danger}"/>
|
||||
<Button Content="Inserisci / sostituisci le chiavi" Click="OnLogin" Style="{StaticResource Primary}"/>
|
||||
<Button Content="Rimuovi le chiavi salvate" Click="OnLogout" Margin="10,0,0,0" Style="{StaticResource Danger}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -140,7 +140,7 @@
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="CARTELLA DEI LOG" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Text="Cartella dei log" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock x:Name="LogHint" Style="{StaticResource Hint}" FontSize="12"/>
|
||||
</StackPanel>
|
||||
<Grid>
|
||||
@@ -176,24 +176,28 @@
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="FILE DI CONFIGURAZIONE" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Text="File" Style="{StaticResource Head}" Margin="0"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
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"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
|
||||
<Button Content="Apri il file di configurazione" Click="OnOpenConfig"/>
|
||||
<Button Content="Apri strategy.json" Click="OnOpenStrategy" Margin="10,0,0,0"
|
||||
ToolTip="I parametri della strategia dei basket: preset, soglie, rischio, calendario, sizing. Ogni chiave è documentata nel file."/>
|
||||
<Button Content="Apri la cartella dei dati" Click="OnOpenData" Margin="10,0,0,0"
|
||||
ToolTip="Ledger (data/ledger), barre di mercato, calendario, notizie e modelli."/>
|
||||
<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."/>
|
||||
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 eToro e strategy.json non vengono toccati."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="INFORMAZIONI" Style="{StaticResource Head}"/>
|
||||
<TextBlock Text="Informazioni" Style="{StaticResource Head}"/>
|
||||
<TextBlock x:Name="AboutText" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -173,19 +173,11 @@ public partial class SettingsPage : UserControl
|
||||
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
|
||||
// reasonable while the combination is not — an automatic mode without its
|
||||
// flag, a stake that no longer fits inside the exposure cap — 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();
|
||||
}
|
||||
|
||||
candidate.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or IOException
|
||||
@@ -210,24 +202,9 @@ public partial class SettingsPage : UserControl
|
||||
private void Warn(string message) => MessageBox.Show(
|
||||
Window.GetWindow(this), message, "Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
|
||||
private static string DescribeLogFiles(LoggingOptions logging)
|
||||
{
|
||||
List<string?> parts =
|
||||
[
|
||||
"Cosa viene scritto in questa cartella:",
|
||||
string.Empty,
|
||||
Named(logging.File, "log dell'applicazione"),
|
||||
Named(logging.TradeJournal, "diario delle operazioni (CSV con ;)"),
|
||||
Named(logging.DecisionLog, "una riga per barra valutata"),
|
||||
Named(logging.ExecutionLog, "una riga per segnale arrivato agli ordini"),
|
||||
];
|
||||
|
||||
parts.RemoveAll(static p => p is null);
|
||||
return string.Join("\n", parts);
|
||||
|
||||
static string? Named(string? file, string what) =>
|
||||
string.IsNullOrWhiteSpace(file) ? null : $"{file} — {what}";
|
||||
}
|
||||
private static string DescribeLogFiles(LoggingOptions logging) =>
|
||||
$"In questa cartella: {(string.IsNullOrWhiteSpace(logging.File) ? "nessun file (log su file disattivato)" : logging.File + " — il log dell'applicazione, una tabella con ; e intestazione")}. " +
|
||||
"Il ledger delle decisioni e dei basket sta in data/ledger e non si sposta.";
|
||||
|
||||
private void OnRestoreDefaults(object sender, RoutedEventArgs e) => Actions?.RestoreDefaults();
|
||||
|
||||
@@ -237,6 +214,10 @@ public partial class SettingsPage : UserControl
|
||||
|
||||
private void OnOpenConfig(object sender, RoutedEventArgs e) => Actions?.OpenConfigFile();
|
||||
|
||||
private void OnOpenStrategy(object sender, RoutedEventArgs e) => Actions?.OpenStrategyFile();
|
||||
|
||||
private void OnOpenData(object sender, RoutedEventArgs e) => Actions?.OpenDataFolder();
|
||||
|
||||
private void OnOpenLogFolder(object sender, RoutedEventArgs e) => Actions?.OpenLogFolder();
|
||||
|
||||
private void OnChangeLogDirectory(object sender, RoutedEventArgs e) => Actions?.ChangeLogDirectory();
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
<UserControl x:Class="Encelado.Bot.Ui.Pages.StatusPage"
|
||||
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>
|
||||
|
||||
<!--
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="#1AFF5A7A"/>
|
||||
<Setter Property="BorderBrush" Value="#66FF5A7A"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BannerIsWarning}" Value="True">
|
||||
<Setter Property="Background" Value="#1AFFB347"/>
|
||||
<Setter Property="BorderBrush" Value="#66FFB347"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="{Binding Banner}" TextWrapping="Wrap" FontSize="12.5">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Down}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding BannerIsWarning}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== riga dei numeri ==================== -->
|
||||
<UniformGrid Rows="1" Columns="5" Margin="0,0,-10,14">
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="MARGINE" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Equity, StringFormat=N2}"/>
|
||||
<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}'}"
|
||||
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="NON REALIZZATO" Style="{StaticResource Label}"/>
|
||||
<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 RealizedToday, StringFormat='realizzato {0:+#,##0.00;-#,##0.00;0.00}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}">
|
||||
<StackPanel>
|
||||
<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>
|
||||
|
||||
<!-- ==================== le coppie ==================== -->
|
||||
<TextBlock Text="COPPIE" Style="{StaticResource Head}" Margin="2,0,0,9"/>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Pairs}" ItemTemplate="{StaticResource PairTemplate}"/>
|
||||
|
||||
<Border Padding="18">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border" BasedOn="{StaticResource Card}">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Pairs.Count}" Value="0">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"
|
||||
Text="Nessuna coppia configurata. Aggiungine una da Impostazioni."/>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== attività ==================== -->
|
||||
<StackPanel Orientation="Horizontal" Margin="2,14,0,9">
|
||||
<TextBlock Text="ATTIVITÀ" Style="{StaticResource Head}" Margin="0"/>
|
||||
<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>
|
||||
|
||||
<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="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>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== connessioni ==================== -->
|
||||
<Border Style="{StaticResource Card}" Margin="0,10,0,0" Padding="14,11">
|
||||
<StackPanel>
|
||||
<TextBlock Text="COLLEGAMENTO" Style="{StaticResource Head}"/>
|
||||
<UniformGrid Rows="1" Columns="4">
|
||||
<StackPanel>
|
||||
<TextBlock Text="DATI DI MERCATO" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding MarketDataState}" Style="{StaticResource Sub}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="FLUSSO ORDINI" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding OrderStreamState}" Style="{StaticResource Sub}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Text="TIMEFRAME" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding TimeFrame}" Style="{StaticResource Sub}"/>
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<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>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -1,38 +0,0 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
{
|
||||
public StatusPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
private async void OnClosePair(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: string pair } button || pair.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 (Actions is not null)
|
||||
{
|
||||
await Actions.ClosePairAsync(pair);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
button.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Window x:Class="Encelado.Bot.Ui.PromptWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Encelado"
|
||||
Width="560" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ResizeMode="NoResize"
|
||||
Background="{StaticResource Bg}"
|
||||
UseLayoutRounding="True">
|
||||
|
||||
<Border Padding="24">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="TitleText" FontSize="17" FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="BodyText" Style="{StaticResource Sub}" FontSize="12.5" Margin="0,10,0,0" TextWrapping="Wrap"/>
|
||||
<TextBlock x:Name="LabelText" Style="{StaticResource Label}" Margin="0,16,0,6"/>
|
||||
<TextBox x:Name="InputBox" AcceptsReturn="False"/>
|
||||
<TextBlock x:Name="ErrorText" Foreground="{StaticResource Down}" FontSize="12" Margin="0,8,0,0" Visibility="Collapsed"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
|
||||
<Button Content="Annulla" Click="OnCancel" Width="104"/>
|
||||
<Button x:Name="OkButton" Content="Conferma" Click="OnOk" Style="{StaticResource Primary}" Width="130" Margin="10,0,0,0" IsDefault="True"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// One line of typed input with a validator: the phrase that unlocks a live or an
|
||||
/// automatic mode, or the written reason that lifts an equity stop. A checkbox would
|
||||
/// not do for either — the point is that the operator writes the words.
|
||||
/// </summary>
|
||||
public partial class PromptWindow : Window
|
||||
{
|
||||
private readonly Func<string, string?> _validate;
|
||||
|
||||
public PromptWindow(string title, string body, string label, Func<string, string?> validate, string okText = "Conferma")
|
||||
{
|
||||
InitializeComponent();
|
||||
_validate = validate ?? (static _ => null);
|
||||
TitleText.Text = title;
|
||||
BodyText.Text = body;
|
||||
LabelText.Text = label;
|
||||
OkButton.Content = okText;
|
||||
Loaded += (_, _) => InputBox.Focus();
|
||||
}
|
||||
|
||||
public string Value => InputBox.Text.Trim();
|
||||
|
||||
private void OnOk(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string? problem = _validate(Value);
|
||||
if (problem is not null)
|
||||
{
|
||||
ErrorText.Text = problem;
|
||||
ErrorText.Visibility = Visibility.Visible;
|
||||
return;
|
||||
}
|
||||
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||
|
||||
/// <summary>The exact phrase the specification requires before anything live starts.</summary>
|
||||
public const string LivePhrase = "CONFERMO LIVE";
|
||||
}
|
||||
@@ -4,16 +4,12 @@ using Encelado.Bot.Configuration;
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Every configurable value, with an explanation of what it does <b>to this strategy</b>
|
||||
/// and what happens if it is changed.
|
||||
/// <para>
|
||||
/// The tooltips are the point of this file. A number like <c>entryZ = 2.0</c> means
|
||||
/// nothing on its own; "raise it to 3 and the bot trades a third as often, but each trade
|
||||
/// starts further from the mean and has more room to come back" is something an operator
|
||||
/// can act on. Where a figure came out of a backtest, the tooltip says so — the difference
|
||||
/// between a measured number and an arbitrary one is the only thing that tells you how
|
||||
/// freely you may change it.
|
||||
/// </para>
|
||||
/// The settings form, as data: which fields exist, where each one lives in the JSON,
|
||||
/// what it means and what values it may take. The page renders this; it never knows a
|
||||
/// path by name. Every tooltip says why the value is what it is, not only what it is.
|
||||
/// The strategy's own numbers live in <c>strategy.json</c>, which the page opens as a
|
||||
/// file: every value there is documented in place and a change is a decision the forward
|
||||
/// test judges.
|
||||
/// </summary>
|
||||
public static class SettingsCatalogue
|
||||
{
|
||||
@@ -21,24 +17,16 @@ public static class SettingsCatalogue
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
List<SettingGroup> groups = [];
|
||||
List<SettingGroup> groups =
|
||||
[
|
||||
Execution(config),
|
||||
Etoro(config),
|
||||
Window(config),
|
||||
Logging(config),
|
||||
];
|
||||
|
||||
for (int i = 0; i < config.Pairs.Count; i++)
|
||||
{
|
||||
groups.Add(Pair(config.Pairs[i], i));
|
||||
}
|
||||
|
||||
groups.Add(Sizing(config));
|
||||
groups.Add(Protection(config));
|
||||
groups.Add(Exchange(config));
|
||||
groups.Add(Engine(config));
|
||||
groups.Add(Logging(config));
|
||||
groups.Add(Learning(config));
|
||||
|
||||
// Validated immediately, not on the first edit. A value that has become invalid
|
||||
// because an update changed the rules has to announce itself when the page opens:
|
||||
// waiting for someone to touch it means never telling them, because it is exactly
|
||||
// the field nobody looks at.
|
||||
// Validated immediately, not on the first edit: a value that became invalid
|
||||
// because an update changed the rules has to announce itself when the page opens.
|
||||
foreach (SettingField field in groups.SelectMany(static g => g.Fields))
|
||||
{
|
||||
field.Validate();
|
||||
@@ -47,592 +35,156 @@ public static class SettingsCatalogue
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
private static SettingGroup Pair(PairConfig pair, int index)
|
||||
private static SettingGroup Execution(BotConfig config)
|
||||
{
|
||||
string prefix = string.Create(CultureInfo.InvariantCulture, $"pairs[{index}]");
|
||||
|
||||
SettingGroup g = new(
|
||||
$"Coppia {index + 1} — {pair.Name}",
|
||||
"Lo spread è ln(A) − β·ln(B): A è la gamba su cui si misura, B quella di copertura. " +
|
||||
"β lo calcola il bot a ogni ricalibrazione, non si imposta a mano.");
|
||||
"Esecuzione",
|
||||
"Dove vanno gli ordini. In ogni modalità il bot apre e chiude i basket da solo, senza chiedere conferma " +
|
||||
"per il singolo ordine. I parametri della strategia (preset, soglie, rischio) stanno in strategy.json.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{prefix}.enabled",
|
||||
Label = "Attiva",
|
||||
Initial = pair.Enabled ? "sì" : "no",
|
||||
Path = "run.executionMode",
|
||||
Label = "Modalità",
|
||||
Initial = config.Run.Mode.ToString(),
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["Paper", "Demo", "Live"],
|
||||
Tooltip = "Paper: simulatore locale sopra le quotazioni reali, nessun ordine sul conto. Demo: conto demo di eToro, " +
|
||||
"ordini veri e denaro virtuale. Live: conto reale, richiede il flag qui sotto e la frase CONFERMO LIVE a ogni avvio.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "run.allowLive",
|
||||
Label = "Consenti il conto reale",
|
||||
Initial = config.Run.AllowLive ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Se disattivata, la coppia resta in configurazione ma non viene né sottoscritta " +
|
||||
"né calibrata né operata.\n\n" +
|
||||
"Disattivare una coppia è il modo giusto per toglierla di mezzo temporaneamente: " +
|
||||
"cancellarla dal file perde anche le tarature che le avevi dato.",
|
||||
Tooltip = "Senza questo flag la modalità Live viene rifiutata all'avvio. Anche con il flag, l'avvio chiede di scrivere CONFERMO LIVE.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{prefix}.symbolA",
|
||||
Label = "Gamba A",
|
||||
Initial = PairConfig.Normalize(pair.SymbolA),
|
||||
Kind = SettingKind.Text,
|
||||
Tooltip =
|
||||
"Il simbolo su cui si misura lo spread, nel formato dei futures USDⓈ-M di " +
|
||||
"Binance: tutto attaccato e in maiuscolo, per esempio ETHUSDT.\n\n" +
|
||||
"Non è il formato spot (ETH/USDT) e non è quello dei futures COIN-M (ETHUSD_PERP). " +
|
||||
"Se sbagli, il bot rifiuta di partire e ti dice quale simbolo Binance non quota — " +
|
||||
"meglio così che scoprirlo con una gamba già eseguita.",
|
||||
Path = "run.closeOnShutdown",
|
||||
Label = "Chiudi i basket all'arresto",
|
||||
Initial = config.Run.CloseOnShutdown ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip = "Con 'sì' fermare il bot chiude i basket aperti a mercato. Con 'no' restano sul conto con gli stop nativi sul " +
|
||||
"server, ma senza nessuno che applichi il take-profit o lo stop di basket finché il bot non riparte.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{prefix}.symbolB",
|
||||
Label = "Gamba B (copertura)",
|
||||
Initial = PairConfig.Normalize(pair.SymbolB),
|
||||
Kind = SettingKind.Text,
|
||||
Tooltip =
|
||||
"La gamba di copertura. Va scelta fra strumenti che condividono i fattori che " +
|
||||
"li muovono: due Layer-2 dello stesso ecosistema hanno molte più probabilità di " +
|
||||
"restare cointegrati di due asset accomunati solo dall'essere entrambi crypto.\n\n" +
|
||||
"Metti qui lo strumento più liquido dei due: è quello su cui il rapporto di " +
|
||||
"copertura ti farà tenere più controvalore quando β è sopra 1.",
|
||||
});
|
||||
|
||||
AddModelFields(g, pair, prefix);
|
||||
return g;
|
||||
}
|
||||
|
||||
private static void AddModelFields(SettingGroup g, PairConfig pair, string prefix)
|
||||
{
|
||||
string p = $"{prefix}.parameters";
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{p}.zWindow",
|
||||
Label = "Finestra dello z-score",
|
||||
Initial = Number(pair, "zWindow", 100, SettingKind.Integer),
|
||||
Path = "run.pollSeconds",
|
||||
Label = "Lettura quotazioni ogni",
|
||||
Initial = config.Run.PollSeconds.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "barre",
|
||||
Minimum = 20,
|
||||
Maximum = 5000,
|
||||
Tooltip =
|
||||
"Su quante barre si calcolano la media e la deviazione standard che normalizzano " +
|
||||
"lo spread.\n\n" +
|
||||
"È il valore che il backtest ha corretto in modo più netto. Con una finestra " +
|
||||
"vicina all'emivita del rientro (30-40 barre su questi dati) la media insegue lo " +
|
||||
"scostamento e lo assorbe: lo z-score torna a zero da solo, senza che lo spread " +
|
||||
"si sia mosso, e il margine sparisce. Serve una finestra molte volte più lunga " +
|
||||
"dell'emivita — su barre da 15 minuti, 1000-1500 barre.\n\n" +
|
||||
"Le 100 barre indicate dalla guida sono risultate le peggiori su ogni timeframe " +
|
||||
"provato.",
|
||||
Suffix = "secondi",
|
||||
Minimum = 2,
|
||||
Maximum = 30,
|
||||
Tooltip = "Una richiesta per tutti gli strumenti. 3 s = 20 richieste al minuto su una quota condivisa di 120: " +
|
||||
"resta spazio per candele e costi. TP e stop di basket vengono controllati a ogni lettura.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{p}.entryZ",
|
||||
Label = "Soglia di ingresso",
|
||||
Initial = Number(pair, "entryZ", 2.0, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "deviazioni standard",
|
||||
Minimum = 0.5,
|
||||
Maximum = 6,
|
||||
Tooltip =
|
||||
"Quanto lontano dalla media deve andare lo spread perché il bot apra.\n\n" +
|
||||
"Se lo abbassi a 1.5: molte più operazioni, ciascuna con meno strada da " +
|
||||
"recuperare — e ogni giro completo attraversa lo spread quattro volte, quindi le " +
|
||||
"commissioni si mangiano il margine prima della direzione.\n" +
|
||||
"Se lo alzi a 3: molte meno operazioni, ciascuna con più spazio davanti. " +
|
||||
"Nei test 2.5-3.0 è andato costantemente meglio di 2.0.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{p}.exitZ",
|
||||
Label = "Soglia di uscita",
|
||||
Initial = Number(pair, "exitZ", 0.2, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "deviazioni standard",
|
||||
Minimum = 0,
|
||||
Maximum = 3,
|
||||
Tooltip =
|
||||
"Quanto deve rientrare lo spread perché il bot chiuda in guadagno.\n\n" +
|
||||
"0.2 significa aspettare il ritorno quasi completo alla media. Alzarlo a 0.5 " +
|
||||
"incassa prima, con meno profitto per operazione ma meno esposizione al rischio " +
|
||||
"che la relazione si rompa mentre sei dentro.\n\n" +
|
||||
"Attenzione a portarlo a 0: la posizione resterebbe aperta fino allo stop o fino " +
|
||||
"a un attraversamento completo dall'altra parte, che è una strategia diversa.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{p}.stopZ",
|
||||
Label = "Stop statistico",
|
||||
Initial = Number(pair, "stopZ", 3.5, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "deviazioni standard",
|
||||
Minimum = 1,
|
||||
Maximum = 10,
|
||||
Tooltip =
|
||||
"Dove lo scostamento smette di essere un'opportunità e diventa la prova che il " +
|
||||
"modello non vale più.\n\n" +
|
||||
"Non è uno stop di prezzo: la posizione è delta-neutral e il prezzo assoluto non " +
|
||||
"c'entra. È l'affermazione che la relazione su cui la posizione si basava ha " +
|
||||
"smesso di reggere.\n\n" +
|
||||
"Deve stare sopra la soglia di ingresso, altrimenti ogni ingresso uscirebbe " +
|
||||
"subito. Nei test 6.0 ha battuto 3.5 e 4.0 su barre da 15 minuti: uno stop " +
|
||||
"stretto su uno spread che rientra lentamente realizza perdite che sarebbero " +
|
||||
"rientrate.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{p}.maxPValue",
|
||||
Label = "Soglia di cointegrazione",
|
||||
Initial = Number(pair, "maxPValue", 0.05, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "p-value",
|
||||
Minimum = 0.001,
|
||||
Maximum = 1,
|
||||
Tooltip =
|
||||
"Quanto deve essere convincente il test di cointegrazione perché la coppia sia " +
|
||||
"operabile.\n\n" +
|
||||
"È il filtro che tiene in piedi tutta la strategia. Disattivarlo — o alzarlo " +
|
||||
"sopra 0.2 — è stato misurato: senza, il risultato passa da leggermente positivo " +
|
||||
"a −73%/−87% su ogni combinazione provata. Su questi dati la coppia passa il test " +
|
||||
"solo l'8-17% del tempo, ed è per questo che il bot resta fermo a lungo.\n\n" +
|
||||
"Il bot resta fermo perché il test dice di restare fermo: non è un guasto.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{p}.minHalfLife",
|
||||
Label = "Emivita minima",
|
||||
Initial = Number(pair, "minHalfLife", 3, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "barre",
|
||||
Minimum = 0,
|
||||
Maximum = 1000,
|
||||
Tooltip =
|
||||
"Sotto questo tempo di rientro la coppia non viene operata.\n\n" +
|
||||
"Uno spread che rientra in due barre rientra troppo in fretta perché il guadagno " +
|
||||
"superi le quattro traversate dello spread denaro-lettera che un giro completo " +
|
||||
"costa.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{p}.maxHalfLife",
|
||||
Label = "Emivita massima",
|
||||
Initial = Number(pair, "maxHalfLife", 200, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "barre",
|
||||
Minimum = 1,
|
||||
Maximum = 5000,
|
||||
Tooltip =
|
||||
"Sopra questo tempo di rientro la coppia non viene operata.\n\n" +
|
||||
"Uno spread che impiega mille barre a tornare alla media tiene impegnato il " +
|
||||
"margine più a lungo di quanto duri la relazione che lo giustifica: prima che " +
|
||||
"rientri, la ricalibrazione l'avrà già respinto.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = $"{p}.maxBarsInTrade",
|
||||
Label = "Stop temporale",
|
||||
Initial = Number(pair, "maxBarsInTrade", 0, SettingKind.Integer),
|
||||
Path = "run.statusSeconds",
|
||||
Label = "Riga di stato ogni",
|
||||
Initial = config.Run.StatusSeconds.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "barre (0 = disattivo)",
|
||||
Minimum = 0,
|
||||
Maximum = 100_000,
|
||||
Tooltip =
|
||||
"Dopo quante barre una posizione viene chiusa comunque, anche se lo spread non " +
|
||||
"è né rientrato né arrivato allo stop.\n\n" +
|
||||
"Serve contro il caso peggiore di questa strategia: uno spread che si ferma a " +
|
||||
"metà strada e ci resta per giorni, immobilizzando margine senza né guadagnare " +
|
||||
"né perdere abbastanza da far scattare lo stop.",
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
private static SettingGroup Sizing(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Dimensionamento",
|
||||
"Quanto capitale impegna una coppia. Il controvalore effettivo è lo stake moltiplicato " +
|
||||
"per la leva, diviso fra le due gambe secondo il rapporto di copertura.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.stakePct",
|
||||
Label = "Stake per coppia",
|
||||
Initial = SettingField.Format(config.Risk.StakePct, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% dell'equity, come margine",
|
||||
Minimum = 0,
|
||||
Maximum = 100,
|
||||
Tooltip =
|
||||
"La frazione di equity impegnata come MARGINE su una coppia.\n\n" +
|
||||
"Con leva 2 il controvalore al lavoro è il doppio. Con quattro coppie al 15% il " +
|
||||
"margine totale impegnato arriva al 60% dell'equity, e il controvalore lordo a " +
|
||||
"1,2 volte l'equity.\n\n" +
|
||||
"Va tenuto basso abbastanza che l'apertura contemporanea di TUTTE le coppie " +
|
||||
"resti dentro il margine disponibile: una coppia che non riesce ad aprirsi per " +
|
||||
"mancanza di margine è un'occasione persa, ma una gamba che non riesce ad " +
|
||||
"aprirsi lascia l'altra scoperta.",
|
||||
Suffix = "secondi",
|
||||
Minimum = 10,
|
||||
Maximum = 3600,
|
||||
Tooltip = "Ogni quanto il bot scrive nel log una riga con equity, drawdown e lo stato di ogni basket.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.stakeAmount",
|
||||
Label = "Stake fisso",
|
||||
Initial = SettingField.Format(config.Risk.StakeAmount, SettingKind.Number),
|
||||
Path = "run.paperStartingBalance",
|
||||
Label = "Saldo iniziale paper",
|
||||
Initial = SettingField.Format(config.Run.PaperStartingBalance, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "USDT (0 = usa la percentuale)",
|
||||
Minimum = 0,
|
||||
Maximum = 1_000_000,
|
||||
Tooltip =
|
||||
"Un importo fisso di margine per coppia, in USDT. Se è maggiore di zero " +
|
||||
"sostituisce la percentuale.\n\n" +
|
||||
"Utile per non far crescere la size mentre il conto cresce, cioè per misurare la " +
|
||||
"strategia a dimensione costante.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.fundingTiltPct",
|
||||
Label = "Correzione per funding",
|
||||
Initial = SettingField.Format(config.Risk.FundingTiltPct, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% in più o in meno",
|
||||
Minimum = 0,
|
||||
Maximum = 50,
|
||||
Tooltip =
|
||||
"Di quanto cambia la dimensione a seconda del funding netto atteso.\n\n" +
|
||||
"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 e " +
|
||||
"vale la pena tenerla un po' più grande; quando è contro, lo spread deve fare " +
|
||||
"più strada solo per pareggiare.\n\n" +
|
||||
"È un gradino, non una scala: una posizione non diventa il doppio più probabile " +
|
||||
"perché il funding è il doppio più favorevole. 0 disattiva la correzione.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxGrossExposurePct",
|
||||
Label = "Esposizione lorda massima",
|
||||
Initial = SettingField.Format(config.Risk.MaxGrossExposurePct, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "× l'equity",
|
||||
Minimum = 0,
|
||||
Maximum = 20,
|
||||
Tooltip =
|
||||
"Il controvalore totale di tutte le gambe aperte, come multiplo dell'equity.\n\n" +
|
||||
"Con la leva può legittimamente superare 1. È il tetto che impedisce a una serie " +
|
||||
"di segnali simultanei di impegnare tutto il conto in una volta.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxOpenPairs",
|
||||
Label = "Coppie aperte insieme",
|
||||
Initial = config.Risk.MaxOpenPairs.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "0 = nessun limite",
|
||||
Minimum = 0,
|
||||
Maximum = 50,
|
||||
Tooltip =
|
||||
"Quante coppie possono essere aperte contemporaneamente.\n\n" +
|
||||
"Le coppie di un paniere non sono indipendenti come sembrano: se tre di esse " +
|
||||
"hanno BTCUSDT come gamba di copertura, un movimento violento di BTC le tocca " +
|
||||
"tutte insieme. Il limite serve a questo, non al margine.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Protection(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Protezione",
|
||||
"I limiti che fermano il bot quando qualcosa va storto. Su futures con leva sono " +
|
||||
"l'ultima fermata prima di una liquidazione.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxDailyLossPct",
|
||||
Label = "Perdita giornaliera massima",
|
||||
Initial = SettingField.Format(config.Risk.MaxDailyLossPct, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% dell'equity di inizio giornata",
|
||||
Minimum = 0.1,
|
||||
Maximum = 100,
|
||||
Tooltip =
|
||||
"Quando l'equity scende di questa percentuale rispetto all'inizio della giornata, " +
|
||||
"il bot chiude tutto e non riapre fino al giorno dopo.\n\n" +
|
||||
"Non è disattivabile. Una strategia delta-neutral con leva perde in modo lento e " +
|
||||
"regolare finché la relazione regge, e molto in fretta quando si rompe: questo è " +
|
||||
"il limite che distingue le due situazioni senza doverle capire.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxMarginRatio",
|
||||
Label = "Margine di mantenimento massimo",
|
||||
Initial = SettingField.Format(config.Risk.MaxMarginRatio, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% dell'equity",
|
||||
Suffix = "USD",
|
||||
Minimum = 1,
|
||||
Maximum = 100,
|
||||
Tooltip =
|
||||
"Sopra questo rapporto fra margine di mantenimento ed equity non si aprono nuove " +
|
||||
"coppie.\n\n" +
|
||||
"A 1 il conto viene liquidato. Fermarsi a metà strada lascia spazio a un " +
|
||||
"movimento avverso senza che l'unico esito possibile sia la liquidazione.",
|
||||
Tooltip = "Il conto virtuale del simulatore locale (solo in modalità Paper).",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxRelativeSpread",
|
||||
Label = "Spread massimo del book",
|
||||
Initial = SettingField.Format(config.Risk.MaxRelativeSpread, SettingKind.Percent),
|
||||
Kind = SettingKind.Percent,
|
||||
Suffix = "% fra denaro e lettera",
|
||||
Path = "run.paperSlippagePips",
|
||||
Label = "Slippage paper",
|
||||
Initial = SettingField.Format(config.Run.PaperSlippagePips, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "pip per gamba",
|
||||
Minimum = 0,
|
||||
Maximum = 10,
|
||||
Tooltip =
|
||||
"Il book più largo che una gamba può mostrare ed essere comunque entrata.\n\n" +
|
||||
"Conta molto più che su un modello direzionale: un giro completo attraversa lo " +
|
||||
"spread QUATTRO volte — dentro su due gambe, fuori su due gambe — quindi un book " +
|
||||
"da 10 punti base costa 40 punti base contro un rientro che spesso vale meno di " +
|
||||
"così. Un book largo non è una piccola tassa su questa strategia: è la " +
|
||||
"differenza fra avere un margine e non averlo.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.minOrderNotional",
|
||||
Label = "Controvalore minimo per gamba",
|
||||
Initial = SettingField.Format(config.Risk.MinOrderNotional, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "USDT",
|
||||
Minimum = 0,
|
||||
Maximum = 100_000,
|
||||
Tooltip =
|
||||
"Sotto questo controvalore una gamba non viene inviata.\n\n" +
|
||||
"Binance ha i suoi minimi per simbolo, più alti su BTCUSDT. Questo è il minimo " +
|
||||
"del bot, che serve a non aprire posizioni così piccole da essere interamente " +
|
||||
"mangiate dalle commissioni.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "risk.maxHedgeRatio",
|
||||
Label = "Rapporto di copertura massimo",
|
||||
Initial = SettingField.Format(config.Risk.MaxHedgeRatio, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "β massimo (e 1/β minimo)",
|
||||
Minimum = 1,
|
||||
Maximum = 100,
|
||||
Tooltip =
|
||||
"β fuori dall'intervallo [1/questo, questo] fa rifiutare l'ingresso.\n\n" +
|
||||
"Un β di 12 non è una copertura: è una scommessa sulla seconda gamba travestita " +
|
||||
"da copertura. Quando la regressione produce un numero del genere significa che " +
|
||||
"le due serie hanno smesso di somigliarsi, e il test di cointegrazione di solito " +
|
||||
"lo dice già — questo è il controllo di riserva.",
|
||||
Tooltip = "Quanto il simulatore peggiora ogni esecuzione oltre lo spread reale del momento.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Exchange(BotConfig config)
|
||||
private static SettingGroup Etoro(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Binance",
|
||||
"Come il bot si presenta all'exchange. La leva e il tipo di margine vengono applicati " +
|
||||
"a ogni simbolo all'avvio.");
|
||||
"eToro",
|
||||
"Il collegamento a eToro Public API. Le chiavi non sono qui: si inseriscono dalla finestra di accesso e vivono " +
|
||||
"cifrate nei dati dell'utente, una coppia per ambiente.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "binance.testnet",
|
||||
Label = "Testnet",
|
||||
Initial = config.Binance.Testnet ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Con 'sì' il bot opera sulla testnet dei futures Binance: stesse API, stessi tipi " +
|
||||
"di ordine, denaro finto.\n\n" +
|
||||
"Le chiavi dei due ambienti sono diverse e vengono salvate separatamente, quindi " +
|
||||
"cambiando questo valore ti verranno chieste le chiavi dell'altro ambiente.\n\n" +
|
||||
"Passare a 'no' significa operare con denaro reale.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "binance.leverage",
|
||||
Label = "Leva",
|
||||
Initial = config.Binance.Leverage.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "×",
|
||||
Minimum = 1,
|
||||
Maximum = 20,
|
||||
Tooltip =
|
||||
"La leva applicata a ogni simbolo.\n\n" +
|
||||
"Un libro delta-neutral sembra non averne bisogno, e in media è vero: è quando la " +
|
||||
"media non vale — una gamba che fa un salto isolato per una notizia sua — che la " +
|
||||
"leva decide se il conto sopravvive. La guida indica 2x-3x, ed è un tetto, non " +
|
||||
"un obiettivo.\n\n" +
|
||||
"Alzarla NON aumenta il margine della strategia: moltiplica sia il guadagno sia " +
|
||||
"la perdita, lasciando il rapporto fra i due dov'era e avvicinando la " +
|
||||
"liquidazione.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "binance.marginType",
|
||||
Label = "Tipo di margine",
|
||||
Initial = config.Binance.MarginType.ToUpperInvariant(),
|
||||
Path = "etoro.environment",
|
||||
Label = "Ambiente",
|
||||
Initial = config.Etoro.IsDemo ? "demo" : "real",
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["CROSSED", "ISOLATED"],
|
||||
Tooltip =
|
||||
"CROSSED mette le due gambe nello stesso pool di margine, così si compensano: è " +
|
||||
"quello che una coppia coperta vuole, perché la perdita su una gamba è quasi " +
|
||||
"sempre accompagnata dal guadagno sull'altra.\n\n" +
|
||||
"ISOLATED dà a ogni posizione il proprio margine e il proprio prezzo di " +
|
||||
"liquidazione: più prudente in astratto, e su una coppia coperta è in realtà " +
|
||||
"peggio, perché una gamba può essere liquidata mentre l'altra è in guadagno.",
|
||||
Choices = ["demo", "real"],
|
||||
Tooltip = "demo: il conto virtuale di eToro, ordini veri e soldi finti. real: il conto reale, che richiede " +
|
||||
"la modalità Live, run.allowLive e la frase CONFERMO LIVE a ogni avvio. Cambiare ambiente richiede le chiavi di quell'ambiente.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "etoro.requestTimeoutSeconds",
|
||||
Label = "Timeout richieste",
|
||||
Initial = config.Etoro.RequestTimeoutSeconds.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "secondi",
|
||||
Minimum = 3,
|
||||
Maximum = 120,
|
||||
Tooltip = "Oltre questo tempo una richiesta HTTP viene abbandonata (e, se era una lettura, ritentata).",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "etoro.fillTimeoutSeconds",
|
||||
Label = "Attesa esito ordine",
|
||||
Initial = config.Etoro.FillTimeoutSeconds.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "secondi",
|
||||
Minimum = 1,
|
||||
Maximum = 60,
|
||||
Tooltip = "eToro lavora gli ordini in modo asincrono: il bot interroga l'esito per questo tempo, poi tratta " +
|
||||
"l'ordine come non confermato e riconcilia con le posizioni sul conto. È anche il timeout della seconda gamba (leg-risk).",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Engine(BotConfig config)
|
||||
private static SettingGroup Window(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Motore",
|
||||
"Come il bot legge il mercato e invia gli ordini.");
|
||||
"Finestra",
|
||||
"Come la finestra mostra le cose. Niente qui cambia quello che il bot fa.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.timeFrame",
|
||||
Label = "Timeframe",
|
||||
Initial = config.Engine.TimeFrame,
|
||||
Path = "ui.timeZone",
|
||||
Label = "Fuso orario",
|
||||
Initial = config.Ui.TimeZone,
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["5m", "15m", "30m", "1h", "4h"],
|
||||
Tooltip =
|
||||
"L'intervallo delle barre su cui il bot decide.\n\n" +
|
||||
"Binance chiude la barra e lo comunica sullo stream, quindi questo è l'intervallo " +
|
||||
"a cui ci si iscrive, non una dimensione da ricostruire.\n\n" +
|
||||
"'1m' è rifiutato: a un minuto il costo di quattro attraversamenti dello spread " +
|
||||
"per giro supera il rientro medio. Nei test 5m è risultato il peggiore proprio " +
|
||||
"per questo — nessuna combinazione di soglie ha retto — mentre 15m ha prodotto " +
|
||||
"abbastanza operazioni da poter essere misurato.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.calibrationBars",
|
||||
Label = "Barre per la calibrazione",
|
||||
Initial = config.Engine.CalibrationBars.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "barre",
|
||||
Minimum = 100,
|
||||
Maximum = 1500,
|
||||
Tooltip =
|
||||
"Su quante barre gira la regressione di cointegrazione che produce β e il " +
|
||||
"p-value.\n\n" +
|
||||
"Più barre danno un β più affidabile e più vecchio. Meno barre lo rendono " +
|
||||
"reattivo e rumoroso. Il tetto di 1500 è quello di Binance in una richiesta sola.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.recalibrateHours",
|
||||
Label = "Ricalibrazione",
|
||||
Initial = SettingField.Format(config.Engine.RecalibrateHours, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "ore",
|
||||
Minimum = 0.25,
|
||||
Maximum = 168,
|
||||
Tooltip =
|
||||
"Ogni quanto l'intero paniere viene rifittato e ritestato.\n\n" +
|
||||
"Quando β cambia in modo sensibile la finestra dello z-score viene azzerata e " +
|
||||
"ricostruita, perché uno spread calcolato con un β nuovo è una serie diversa. " +
|
||||
"Ricalibrare troppo spesso significa rifittare sul rumore e ricominciare da capo " +
|
||||
"in continuazione.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.entryOrderType",
|
||||
Label = "Tipo di ordine",
|
||||
Initial = config.Engine.EntryOrderType,
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["limit", "market"],
|
||||
Tooltip =
|
||||
"'limit' invia un limite marcabile, prezzato oltre il touch di quanto dice " +
|
||||
"l'offset: si comporta come un ordine a mercato ma non può eseguire a un prezzo " +
|
||||
"assurdo se il book si svuota per un istante.\n\n" +
|
||||
"'market' attraversa e basta: esegue sempre, a qualunque prezzo ci sia.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.limitOffsetBps",
|
||||
Label = "Offset del limite",
|
||||
Initial = SettingField.Format(config.Engine.LimitOffsetBps, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Suffix = "punti base oltre il touch",
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
Tooltip =
|
||||
"Di quanto il limite viene prezzato oltre il lato del book che dovrà attraversare.\n\n" +
|
||||
"Troppo poco e la gamba non esegue, lasciando l'altra scoperta. Troppo e si paga " +
|
||||
"slippage che si sarebbe potuto evitare. Su perpetui liquidi 2 punti base bastano.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.dryRun",
|
||||
Label = "Dry-run",
|
||||
Initial = config.Engine.DryRun ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Calcola e registra tutto — calibrazioni, z-score, segnali, dimensionamento — ma " +
|
||||
"non invia nessun ordine.\n\n" +
|
||||
"È il modo per osservare una configurazione nuova senza rischiare niente, e per " +
|
||||
"verificare che le coppie che hai scelto passino davvero il test di " +
|
||||
"cointegrazione sui dati di adesso.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.maxQuoteAgeSeconds",
|
||||
Label = "Età massima del book",
|
||||
Initial = config.Engine.MaxQuoteAgeSeconds.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "secondi (0 = disattivo)",
|
||||
Minimum = 0,
|
||||
Maximum = 600,
|
||||
Tooltip =
|
||||
"Se il book della gamba più ferma è più vecchio di così, l'ingresso viene " +
|
||||
"rifiutato e il motivo scritto nel log.\n\n" +
|
||||
"Serve contro il caso in cui lo stream è caduto senza accorgersene: dimensionare " +
|
||||
"su un prezzo di trenta secondi fa, su due gambe, produce una copertura sbagliata " +
|
||||
"in partenza.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.closeOnShutdown",
|
||||
Label = "Chiudi tutto in uscita",
|
||||
Initial = config.Engine.CloseOnShutdown ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Se attivo, chiudere l'applicazione chiude anche tutte le posizioni aperte.\n\n" +
|
||||
"Con 'no' le posizioni restano sul conto Binance e continuano a vivere senza " +
|
||||
"supervisione: nessuno le chiuderà al rientro dello spread e nessuno applicherà " +
|
||||
"lo stop statistico finché il bot non riparte.",
|
||||
Choices = UiClock.Choices(),
|
||||
Tooltip = "Il fuso con cui la finestra mostra ogni orario (orologio in alto, eventi, righe di attività). " +
|
||||
"'computer' usa quello impostato in Windows. Il file di log porta l'offset e il ledger è in UTC: " +
|
||||
"cambiare questo valore non tocca nessun file. Ha effetto al prossimo avvio.",
|
||||
});
|
||||
|
||||
return g;
|
||||
@@ -642,8 +194,7 @@ public static class SettingsCatalogue
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Registrazione",
|
||||
"Che cosa viene scritto su disco. I file CSV sono il materiale per capire perché il " +
|
||||
"bot ha fatto — o non ha fatto — qualcosa.");
|
||||
"Il log dell'applicazione. Il ledger delle decisioni e dei basket sta in data/ledger e non si configura.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
@@ -652,55 +203,8 @@ public static class SettingsCatalogue
|
||||
Initial = config.Logging.Level,
|
||||
Kind = SettingKind.Choice,
|
||||
Choices = ["trace", "debug", "info", "warn", "error", "none"],
|
||||
Tooltip =
|
||||
"Quanto scrivere.\n\n" +
|
||||
"'info' basta: ogni rifiuto che impedisce un ordine viene scritto a questo " +
|
||||
"livello o sopra, con il nome della coppia e il motivo esatto. 'debug' aggiunge " +
|
||||
"il percorso dei dati, non le decisioni.\n\n" +
|
||||
"'trace' con logMarketData attivo registra ogni singola quotazione: file enormi, " +
|
||||
"serve solo per diagnosticare il flusso.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "engine.logEveryBar",
|
||||
Label = "Registra ogni barra",
|
||||
Initial = config.Engine.LogEveryBar ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Scrive una riga per ogni barra chiusa, per ogni simbolo, anche quando non " +
|
||||
"succede niente.\n\n" +
|
||||
"Su una strategia che opera poche volte all'anno è la differenza fra un log che " +
|
||||
"mostra il mercato muoversi e uno che sembra fermo.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "storage.enabled",
|
||||
Label = "Database locale",
|
||||
Initial = config.Storage.Enabled ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Con 'sì' ogni tabella del journal viene scritta anche nel database SQLite, " +
|
||||
"e da lì il bot legge i modelli e il campione per coppia.\n\n" +
|
||||
"Con 'no' restano solo i file CSV e la parte di apprendimento automatico è " +
|
||||
"disattivata: senza database non c'è dove tenere dataset e modelli.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "storage.databasePath",
|
||||
Label = "Percorso del database",
|
||||
Initial = config.Storage.DatabasePath,
|
||||
Kind = SettingKind.Text,
|
||||
AllowEmpty = true,
|
||||
Tooltip =
|
||||
"Il file SQLite. Vuoto significa %ProgramData%\\Encelado\\encelado.db: una " +
|
||||
"cartella di sistema, separata sia da Documenti (dove sta la configurazione e " +
|
||||
"che viene sincronizzata e copiata) sia dai dati utente (dove stanno le chiavi " +
|
||||
"cifrate).\n\n" +
|
||||
"Un percorso assoluto viene usato così com'è. Il file e la cartella vengono " +
|
||||
"creati al primo avvio.",
|
||||
Tooltip = "'info' basta: ogni rifiuto che impedisce un ordine viene scritto a questo livello o sopra, " +
|
||||
"con il basket e il motivo esatto.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
@@ -712,13 +216,7 @@ public static class SettingsCatalogue
|
||||
Suffix = "MB (0 = nessuna rotazione)",
|
||||
Minimum = 0,
|
||||
Maximum = 4096,
|
||||
Tooltip =
|
||||
"Quando encelado.log supera questa dimensione viene chiuso e rinominato in " +
|
||||
"encelado.1.log, e se ne apre uno nuovo. I file precedenti scalano di un " +
|
||||
"numero: encelado.1.log diventa encelado.2.log e così via.\n\n" +
|
||||
"Con 0 il file cresce senza limite. Un bot lasciato girare per settimane a " +
|
||||
"livello 'debug' produce centinaia di megabyte: la rotazione è quello che " +
|
||||
"impedisce al disco di riempirsi senza perdere la storia recente.",
|
||||
Tooltip = "Superata questa dimensione il file viene ruotato: encelado.1.log, encelado.2.log e così via.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
@@ -730,10 +228,7 @@ public static class SettingsCatalogue
|
||||
Suffix = "file ruotati",
|
||||
Minimum = 1,
|
||||
Maximum = 500,
|
||||
Tooltip =
|
||||
"Quanti file ruotati tenere prima di cancellare il più vecchio.\n\n" +
|
||||
"Moltiplicato per la dimensione massima dà lo spazio totale che i log possono " +
|
||||
"occupare: con 32 MB e 10 file, al massimo 352 MB compreso quello attivo.",
|
||||
Tooltip = "Quanti file ruotati tenere prima di cancellare il più vecchio.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
@@ -745,116 +240,9 @@ public static class SettingsCatalogue
|
||||
Suffix = "righe",
|
||||
Minimum = 100,
|
||||
Maximum = 200_000,
|
||||
Tooltip =
|
||||
"Quante righe tiene la scheda Log. È il tetto di memoria del log dentro " +
|
||||
"l'applicazione: il file su disco resta completo comunque.",
|
||||
Tooltip = "Quante righe tiene la scheda Log. Il file su disco resta completo comunque.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
private static SettingGroup Learning(BotConfig config)
|
||||
{
|
||||
SettingGroup g = new(
|
||||
"Apprendimento automatico",
|
||||
"Il meta-modello: un classificatore addestrato sugli esiti dei segnali passati che " +
|
||||
"può rifiutare un ingresso o ridurne la size. Non propone mai operazioni. Si addestra " +
|
||||
"con lo strumento di ricerca (backtest dataset / train) e vive nel database locale.");
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "ml.enabled",
|
||||
Label = "Consulta il meta-modello",
|
||||
Initial = config.Ml.Enabled ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Con 'sì', prima di ogni ingresso il bot chiede al campione della coppia la " +
|
||||
"probabilità che l'operazione ripaghi i costi, e rifiuta quelle sotto soglia.\n\n" +
|
||||
"Senza un campione nel database questa voce non cambia nulla: il bot opera la " +
|
||||
"sola regola statistica, come prima.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "ml.minProbability",
|
||||
Label = "Probabilità minima",
|
||||
Initial = SettingField.Format(config.Ml.MinProbability, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Minimum = 0.5,
|
||||
Maximum = 0.99,
|
||||
Tooltip =
|
||||
"Sotto questa probabilità predetta l'ingresso viene rifiutato.\n\n" +
|
||||
"0.55 vuol dire: opero solo quando il modello vede un vantaggio, anche piccolo, " +
|
||||
"rispetto a lanciare una moneta. Alzarla riduce le operazioni e, se il modello " +
|
||||
"è buono, ne migliora la qualità; non può scendere sotto 0.5.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "ml.sizeByProbability",
|
||||
Label = "Size in base alla convinzione",
|
||||
Initial = config.Ml.SizeByProbability ? "sì" : "no",
|
||||
Kind = SettingKind.Boolean,
|
||||
Tooltip =
|
||||
"Con 'sì' la size è proporzionale alla convinzione del modello (la regola " +
|
||||
"2·Φ(z)−1 di López de Prado), mai sopra quella decisa dal risk engine e mai " +
|
||||
"sotto la frazione minima. Con 'no' è tutto o niente.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "ml.minSizeFraction",
|
||||
Label = "Frazione minima di size",
|
||||
Initial = SettingField.Format(config.Ml.MinSizeFraction, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Minimum = 0.05,
|
||||
Maximum = 1,
|
||||
Tooltip =
|
||||
"La size più piccola che il modello può lasciare, come frazione di quella del " +
|
||||
"risk engine. Sotto un quarto le commissioni fisse e i lotti minimi di Binance " +
|
||||
"iniziano a pesare più dell'informazione.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "ml.driftPsiAlert",
|
||||
Label = "Soglia di deriva (PSI)",
|
||||
Initial = SettingField.Format(config.Ml.DriftPsiAlert, SettingKind.Number),
|
||||
Kind = SettingKind.Number,
|
||||
Minimum = 0.01,
|
||||
Maximum = 5,
|
||||
Tooltip =
|
||||
"Ogni tot segnali le feature viste dal vivo vengono confrontate con quelle di " +
|
||||
"addestramento. Un Population Stability Index sopra questa soglia segna la " +
|
||||
"feature come 'in deriva'.\n\n0.25 è la convenzione: sopra, la popolazione è " +
|
||||
"cambiata e le probabilità del modello descrivono un mercato che non c'è più.",
|
||||
});
|
||||
|
||||
g.Fields.Add(new SettingField
|
||||
{
|
||||
Path = "ml.driftAlertsToSuspend",
|
||||
Label = "Feature in deriva per sospendere",
|
||||
Initial = config.Ml.DriftAlertsToSuspend.ToString(CultureInfo.CurrentCulture),
|
||||
Kind = SettingKind.Integer,
|
||||
Suffix = "feature (0 = mai)",
|
||||
Minimum = 0,
|
||||
Maximum = 100,
|
||||
Tooltip =
|
||||
"Con questo numero di feature in deriva il modello viene sospeso fino alla " +
|
||||
"ricalibrazione successiva, e nel frattempo opera la sola regola statistica.\n\n" +
|
||||
"È la demozione automatica della guida: un modello che vede un mercato diverso " +
|
||||
"da quello su cui ha imparato smette di avere voce, senza che nessuno debba " +
|
||||
"accorgersene di notte.",
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
private static string Number(PairConfig pair, string key, double fallback, SettingKind kind)
|
||||
{
|
||||
double value = pair.Parameters.TryGetValue(key, out double found) ? found : fallback;
|
||||
return SettingField.Format(value, kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// A lightweight equity curve: filled area plus a stroked line, coloured by whether
|
||||
/// the series finished above or below where it started.
|
||||
/// <para>
|
||||
/// Drawn directly in <see cref="OnRender"/> rather than with a charting library. The
|
||||
/// series is a few hundred points refreshed once a second — a retained-mode chart
|
||||
/// would cost far more than the drawing itself, and this keeps the app dependency
|
||||
/// free.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SparkChart : FrameworkElement
|
||||
{
|
||||
private static readonly Typeface LabelFace = new("Cascadia Mono, Consolas");
|
||||
|
||||
public static readonly DependencyProperty ValuesProperty = DependencyProperty.Register(
|
||||
nameof(Values), typeof(IReadOnlyList<double>), typeof(SparkChart),
|
||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty ShowScaleProperty = DependencyProperty.Register(
|
||||
nameof(ShowScale), typeof(bool), typeof(SparkChart),
|
||||
new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public static readonly DependencyProperty EmptyTextProperty = DependencyProperty.Register(
|
||||
nameof(EmptyText), typeof(string), typeof(SparkChart),
|
||||
new FrameworkPropertyMetadata("nessun dato", FrameworkPropertyMetadataOptions.AffectsRender));
|
||||
|
||||
public IReadOnlyList<double>? Values
|
||||
{
|
||||
get => (IReadOnlyList<double>?)GetValue(ValuesProperty);
|
||||
set => SetValue(ValuesProperty, value);
|
||||
}
|
||||
|
||||
public bool ShowScale
|
||||
{
|
||||
get => (bool)GetValue(ShowScaleProperty);
|
||||
set => SetValue(ShowScaleProperty, 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 <= 4 || h <= 4)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<double>? values = Values;
|
||||
if (values is null || values.Count < 2)
|
||||
{
|
||||
DrawCentredText(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
double lo = double.MaxValue, hi = double.MinValue;
|
||||
foreach (double v in values)
|
||||
{
|
||||
if (!double.IsFinite(v))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lo = Math.Min(lo, v);
|
||||
hi = Math.Max(hi, v);
|
||||
}
|
||||
|
||||
if (lo > hi)
|
||||
{
|
||||
DrawCentredText(dc, EmptyText, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
// A perfectly flat series would divide by zero; give it a nominal band.
|
||||
double span = hi - lo;
|
||||
if (span <= 0)
|
||||
{
|
||||
span = Math.Max(Math.Abs(hi) * 0.001, 1);
|
||||
lo -= span / 2;
|
||||
hi += span / 2;
|
||||
span = hi - lo;
|
||||
}
|
||||
|
||||
const double pad = 8;
|
||||
double plotH = Math.Max(1, h - (pad * 2));
|
||||
double X(int i) => i / (double)(values.Count - 1) * w;
|
||||
double Y(double v) => pad + ((1 - ((v - lo) / span)) * plotH);
|
||||
|
||||
bool gained = values[^1] >= values[0];
|
||||
Color colour = gained ? Color.FromRgb(0x2E, 0xE6, 0xA8) : Color.FromRgb(0xFF, 0x5A, 0x7A);
|
||||
|
||||
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 < values.Count; i++)
|
||||
{
|
||||
ctx.LineTo(new Point(X(i), Y(values[i])), isStroked: true, isSmoothJoin: true);
|
||||
}
|
||||
}
|
||||
|
||||
line.Freeze();
|
||||
|
||||
StreamGeometry area = new();
|
||||
using (StreamGeometryContext ctx = area.Open())
|
||||
{
|
||||
ctx.BeginFigure(new Point(X(0), h), isFilled: true, isClosed: true);
|
||||
for (int i = 0; i < values.Count; i++)
|
||||
{
|
||||
ctx.LineTo(new Point(X(i), Y(values[i])), isStroked: false, isSmoothJoin: false);
|
||||
}
|
||||
|
||||
ctx.LineTo(new Point(w, h), isStroked: false, isSmoothJoin: false);
|
||||
}
|
||||
|
||||
area.Freeze();
|
||||
|
||||
LinearGradientBrush fill = new(
|
||||
Color.FromArgb(0x46, 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();
|
||||
|
||||
SolidColorBrush stroke = new(colour);
|
||||
stroke.Freeze();
|
||||
Pen pen = new(stroke, 1.8);
|
||||
pen.Freeze();
|
||||
|
||||
dc.DrawGeometry(fill, null, area);
|
||||
dc.DrawGeometry(null, pen, line);
|
||||
|
||||
if (ShowScale)
|
||||
{
|
||||
DrawLabel(dc, hi.ToString("N2", CultureInfo.CurrentCulture), 4, 2);
|
||||
DrawLabel(dc, lo.ToString("N2", CultureInfo.CurrentCulture), 4, h - 16);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawLabel(DrawingContext dc, string text, double x, double y)
|
||||
{
|
||||
FormattedText ft = new(
|
||||
text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, LabelFace, 10,
|
||||
Palette.Faint, VisualTreeHelper.GetDpi(this).PixelsPerDip);
|
||||
|
||||
dc.DrawText(ft, new Point(x, y));
|
||||
}
|
||||
|
||||
private void DrawCentredText(DrawingContext dc, string text, double w, double h)
|
||||
{
|
||||
FormattedText ft = new(
|
||||
text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, LabelFace, 11,
|
||||
Palette.Faint, VisualTreeHelper.GetDpi(this).PixelsPerDip);
|
||||
|
||||
dc.DrawText(ft, new Point((w - ft.Width) / 2, (h - ft.Height) / 2));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Bot.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// The one place that turns a UTC instant into the time the operator wants to read.
|
||||
/// Everything inside the engine is UTC on purpose; the screen is the only thing that
|
||||
/// differs, and it differs by exactly this zone — the computer's unless the
|
||||
/// configuration says otherwise.
|
||||
/// </summary>
|
||||
public static class UiClock
|
||||
{
|
||||
private static TimeZoneInfo _zone = TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>The zone in use. Set once at startup from <c>ui.timeZone</c>.</summary>
|
||||
public static TimeZoneInfo Zone
|
||||
{
|
||||
get => _zone;
|
||||
set => _zone = value ?? TimeZoneInfo.Local;
|
||||
}
|
||||
|
||||
/// <summary>A short label for the top bar, e.g. <c>UTC+02:00</c>.</summary>
|
||||
public static string Label
|
||||
{
|
||||
get
|
||||
{
|
||||
TimeSpan offset = _zone.GetUtcOffset(DateTime.UtcNow);
|
||||
return offset == TimeSpan.Zero ? "UTC" : string.Create(CultureInfo.InvariantCulture, $"UTC{(offset < TimeSpan.Zero ? "-" : "+")}{offset:hh\\:mm}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The zone's own name (the Windows display name, or the id).</summary>
|
||||
public static string ZoneName => _zone == TimeZoneInfo.Local ? $"{_zone.Id} (fuso del computer)" : _zone.Id;
|
||||
|
||||
public static DateTime ToZone(DateTime utc)
|
||||
{
|
||||
DateTime u = utc.Kind == DateTimeKind.Utc ? utc : DateTime.SpecifyKind(utc, DateTimeKind.Utc);
|
||||
return TimeZoneInfo.ConvertTimeFromUtc(u, _zone);
|
||||
}
|
||||
|
||||
public static string Format(DateTime utc, string format) =>
|
||||
utc == default ? "—" : ToZone(utc).ToString(format, CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Time of day for today's instants, date and time otherwise.</summary>
|
||||
public static string Smart(DateTime utc)
|
||||
{
|
||||
if (utc == default)
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
|
||||
DateTime local = ToZone(utc);
|
||||
DateTime today = ToZone(DateTime.UtcNow).Date;
|
||||
return local.Date == today ? local.ToString("HH:mm:ss", CultureInfo.CurrentCulture) : local.ToString("dd/MM HH:mm", CultureInfo.CurrentCulture);
|
||||
}
|
||||
|
||||
/// <summary>The zones offered in the settings page: the computer's, UTC, then every zone Windows knows.</summary>
|
||||
public static IReadOnlyList<string> Choices()
|
||||
{
|
||||
List<string> ids = ["computer", "UTC"];
|
||||
try
|
||||
{
|
||||
ids.AddRange(TimeZoneInfo.GetSystemTimeZones().Select(static z => z.Id).Where(static id => !id.Equals("UTC", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidTimeZoneException or TimeZoneNotFoundException or IOException)
|
||||
{
|
||||
// The list is a convenience; the two fixed entries always work.
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Statistics;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>
|
||||
/// The full set of numbers a return series is judged by, computed once and the same
|
||||
/// way for every strategy in the project — baseline, pairs, meta-model, RL — so that a
|
||||
/// comparison between them is a comparison and not an accident of who rounded what.
|
||||
/// </summary>
|
||||
public sealed record ReturnMetrics(
|
||||
int Periods,
|
||||
double PeriodsPerYear,
|
||||
double NetReturn,
|
||||
double Cagr,
|
||||
double AnnualSharpe,
|
||||
double AnnualSortino,
|
||||
double MaxDrawdown,
|
||||
double Calmar,
|
||||
double Skewness,
|
||||
double Kurtosis,
|
||||
double Psr,
|
||||
double Dsr,
|
||||
int Trials,
|
||||
int Trades)
|
||||
{
|
||||
public double Years => PeriodsPerYear > 0 ? Periods / PeriodsPerYear : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Per-period returns in, everything out. <paramref name="trials"/> is how many
|
||||
/// configurations were tried before this one was reported — the deflated Sharpe is
|
||||
/// meaningless without it, and one is only honest when nothing else was looked at.
|
||||
/// </summary>
|
||||
public static ReturnMetrics From(
|
||||
IReadOnlyList<double> periodReturns, double periodsPerYear, int trades, int trials = 1,
|
||||
double trialSharpeVariance = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(periodReturns);
|
||||
|
||||
int n = periodReturns.Count;
|
||||
if (n < 2)
|
||||
{
|
||||
return new ReturnMetrics(n, periodsPerYear, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, trials, trades);
|
||||
}
|
||||
|
||||
double[] equity = new double[n + 1];
|
||||
equity[0] = 1;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
equity[i + 1] = equity[i] * (1 + periodReturns[i]);
|
||||
}
|
||||
|
||||
double years = periodsPerYear > 0 ? n / periodsPerYear : 0;
|
||||
double net = equity[^1] - 1;
|
||||
double cagr = Performance.Cagr(1, equity[^1], years);
|
||||
double sharpe = Performance.Sharpe(periodReturns);
|
||||
double sortino = Performance.Sortino(periodReturns);
|
||||
double dd = Performance.MaxDrawdown(equity);
|
||||
double skew = Performance.Skewness(periodReturns);
|
||||
double kurt = Performance.Kurtosis(periodReturns);
|
||||
|
||||
// The PSR and DSR are computed on the per-period Sharpe, with the per-period
|
||||
// observation count — annualising first would inflate the confidence by the
|
||||
// square root of the periods per year, which is the classic way to make a noisy
|
||||
// intraday series look certain.
|
||||
double psr = Performance.ProbabilisticSharpe(sharpe, 0, n, skew, kurt);
|
||||
double dsr = Performance.DeflatedSharpe(sharpe, n, skew, kurt, Math.Max(1, trials), trialSharpeVariance);
|
||||
|
||||
return new ReturnMetrics(
|
||||
n, periodsPerYear, net, cagr,
|
||||
Performance.Annualise(sharpe, periodsPerYear),
|
||||
Performance.Annualise(sortino, periodsPerYear),
|
||||
dd, Performance.Calmar(cagr, dd), skew, kurt, psr, dsr, trials, trades);
|
||||
}
|
||||
|
||||
public string Describe() => string.Create(CultureInfo.InvariantCulture,
|
||||
$"netto {NetReturn:P2}, CAGR {Cagr:P2}, Sharpe {AnnualSharpe:F2}, Sortino {AnnualSortino:F2}, " +
|
||||
$"DD {MaxDrawdown:P2}, Calmar {Calmar:F2}, PSR {Psr:F3}, DSR {Dsr:F3} su {Trials} prove, {Trades} op");
|
||||
}
|
||||
|
||||
/// <summary>What a baseline did, and the returns it did it with.</summary>
|
||||
public sealed record BaselineReport(string Name, ReturnMetrics Metrics, double[] PeriodReturns, string Motivazione);
|
||||
|
||||
/// <summary>
|
||||
/// The two strategies every other one has to beat before it is worth discussing.
|
||||
/// <para>
|
||||
/// Buy-and-hold is the null hypothesis of a bull market: any long-biased system that
|
||||
/// cannot beat it after costs has added nothing but fees. The moving-average crossover
|
||||
/// is the null hypothesis of a trend rule: it has been public for sixty years, costs
|
||||
/// nothing, and a "machine learning" model that fails to beat it has learnt nothing
|
||||
/// worth the name. Both pay the same costs the real strategies do.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Baseline
|
||||
{
|
||||
/// <summary>Long from the first bar to the last, one round trip of costs.</summary>
|
||||
public static BaselineReport BuyAndHold(IReadOnlyList<Bar> bars, double costPerSide, double periodsPerYear)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bars);
|
||||
|
||||
double[] returns = new double[Math.Max(0, bars.Count - 1)];
|
||||
for (int i = 1; i < bars.Count; i++)
|
||||
{
|
||||
returns[i - 1] = bars[i - 1].Close > 0 ? (bars[i].Close / bars[i - 1].Close) - 1 : 0;
|
||||
}
|
||||
|
||||
if (returns.Length > 0)
|
||||
{
|
||||
returns[0] -= costPerSide;
|
||||
returns[^1] -= costPerSide;
|
||||
}
|
||||
|
||||
ReturnMetrics m = ReturnMetrics.From(returns, periodsPerYear, 1);
|
||||
return new BaselineReport("buy&hold", m, returns, string.Create(CultureInfo.InvariantCulture,
|
||||
$"comprato alla prima barra e tenuto: {m.Describe()}; è il rendimento del mercato stesso, " +
|
||||
$"al netto di un solo giro di costi ({costPerSide:P3} per lato)"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long when the fast average is above the slow one, flat otherwise. Decisions are
|
||||
/// taken on the close and applied from the next bar, so nothing is bought at a price
|
||||
/// that was not yet known.
|
||||
/// </summary>
|
||||
public static BaselineReport SmaCrossover(
|
||||
IReadOnlyList<Bar> bars, int fast, int slow, double costPerSide, double periodsPerYear, bool allowShort = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bars);
|
||||
|
||||
if (fast < 1 || slow <= fast)
|
||||
{
|
||||
throw new ArgumentException("la media lenta deve essere più lunga di quella veloce");
|
||||
}
|
||||
|
||||
int n = bars.Count;
|
||||
double[] returns = new double[Math.Max(0, n - 1)];
|
||||
double sumFast = 0, sumSlow = 0;
|
||||
int position = 0;
|
||||
int trades = 0;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double close = bars[i].Close;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
double r = bars[i - 1].Close > 0 ? (close / bars[i - 1].Close) - 1 : 0;
|
||||
returns[i - 1] = position * r;
|
||||
}
|
||||
|
||||
sumFast += close;
|
||||
sumSlow += close;
|
||||
if (i >= fast)
|
||||
{
|
||||
sumFast -= bars[i - fast].Close;
|
||||
}
|
||||
|
||||
if (i >= slow)
|
||||
{
|
||||
sumSlow -= bars[i - slow].Close;
|
||||
}
|
||||
|
||||
if (i < slow - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double meanFast = sumFast / fast;
|
||||
double meanSlow = sumSlow / slow;
|
||||
int wanted = meanFast > meanSlow ? 1 : allowShort ? -1 : 0;
|
||||
|
||||
if (wanted != position && i > 0)
|
||||
{
|
||||
// Each leg crossed costs one side; a reversal crosses two.
|
||||
returns[i - 1] -= costPerSide * Math.Abs(wanted - position);
|
||||
position = wanted;
|
||||
trades++;
|
||||
}
|
||||
}
|
||||
|
||||
ReturnMetrics m = ReturnMetrics.From(returns, periodsPerYear, trades);
|
||||
return new BaselineReport(
|
||||
string.Create(CultureInfo.InvariantCulture, $"sma {fast}/{slow}{(allowShort ? " long/short" : "")}"),
|
||||
m, returns, string.Create(CultureInfo.InvariantCulture,
|
||||
$"incrocio di medie mobili {fast}/{slow}: {m.Describe()}; regola pubblica da sessant'anni, " +
|
||||
$"se un modello non la batte non ha imparato nulla"));
|
||||
}
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Market;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>
|
||||
/// Reads OHLCV bars from a CSV file so a strategy can be tested against a long history
|
||||
/// without paying for a data subscription.
|
||||
/// <para>
|
||||
/// The column layout is detected from the header, which makes it tolerant of the two
|
||||
/// shapes that turn up in practice: exchange dumps (Binance klines, where the timestamp
|
||||
/// is Unix milliseconds) and generic exports (an ISO date column). Parsing is done on
|
||||
/// spans, one line at a time, so a 300 000-row file costs a single buffered pass.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CsvBarSource
|
||||
{
|
||||
private static readonly string[] TimeNames =
|
||||
["timestamp", "time", "open_time", "opentime", "date", "datetime"];
|
||||
|
||||
/// <summary>Millisecond epochs below this are almost certainly seconds instead.</summary>
|
||||
private const long MillisecondThreshold = 100_000_000_000L;
|
||||
|
||||
public static IReadOnlyList<Bar> Load(string path, CancellationToken ct = default)
|
||||
{
|
||||
// Roughly one bar per 60 bytes; a good enough hint to avoid repeated regrowth.
|
||||
List<Bar> bars = new((int)Math.Min(int.MaxValue, Measure(path) / 60) + 16);
|
||||
|
||||
foreach (Bar bar in Read(path, ct))
|
||||
{
|
||||
bars.Add(bar);
|
||||
}
|
||||
|
||||
if (bars.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("The CSV contained no parsable rows.");
|
||||
}
|
||||
|
||||
// Exchange dumps are normally already ordered, but never assume it.
|
||||
for (int i = 1; i < bars.Count; i++)
|
||||
{
|
||||
if (bars[i].TimeUtc < bars[i - 1].TimeUtc)
|
||||
{
|
||||
bars.Sort(static (a, b) => a.TimeUtc.CompareTo(b.TimeUtc));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return bars;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a fine-grained file and folds it into coarser bars in a single streaming
|
||||
/// pass, so memory tracks the number of bars produced rather than the number read.
|
||||
/// <para>
|
||||
/// This is what makes a 342 MB minute file usable: loaded as-is it is 6.8 million
|
||||
/// <see cref="Bar"/> values and roughly half a gigabyte of live objects, but folded
|
||||
/// into days it is under five thousand bars. Buckets are keyed rather than assumed
|
||||
/// to arrive in order, so a file that jumps backwards produces correct bars instead
|
||||
/// of quietly corrupting the one currently open.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="bucket">Bar width, aligned to the Unix epoch. One day gives UTC midnight opens.</param>
|
||||
public static IReadOnlyList<Bar> LoadAggregated(
|
||||
string path, TimeSpan bucket, CancellationToken ct = default)
|
||||
{
|
||||
if (bucket <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bucket), bucket, "The bar width must be positive.");
|
||||
}
|
||||
|
||||
long ticks = bucket.Ticks;
|
||||
Dictionary<DateTime, Aggregate> buckets = [];
|
||||
|
||||
foreach (Bar bar in Read(path, ct))
|
||||
{
|
||||
DateTime key = new(bar.TimeUtc.Ticks - (bar.TimeUtc.Ticks % ticks), DateTimeKind.Utc);
|
||||
|
||||
ref Aggregate slot = ref System.Runtime.InteropServices.CollectionsMarshal
|
||||
.GetValueRefOrAddDefault(buckets, key, out bool existed);
|
||||
|
||||
if (existed)
|
||||
{
|
||||
slot.Add(bar);
|
||||
}
|
||||
else
|
||||
{
|
||||
slot = Aggregate.Start(bar);
|
||||
}
|
||||
}
|
||||
|
||||
if (buckets.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("The CSV contained no parsable rows.");
|
||||
}
|
||||
|
||||
List<Bar> bars = new(buckets.Count);
|
||||
foreach (KeyValuePair<DateTime, Aggregate> entry in buckets)
|
||||
{
|
||||
bars.Add(entry.Value.ToBar(entry.Key));
|
||||
}
|
||||
|
||||
bars.Sort(static (a, b) => a.TimeUtc.CompareTo(b.TimeUtc));
|
||||
return bars;
|
||||
}
|
||||
|
||||
/// <summary>One bucket under construction. A struct so the dictionary holds no references.</summary>
|
||||
private struct Aggregate
|
||||
{
|
||||
private double _open;
|
||||
private double _high;
|
||||
private double _low;
|
||||
private double _close;
|
||||
private double _volume;
|
||||
private double _takerBuy;
|
||||
private double _notional;
|
||||
private int _trades;
|
||||
private long _firstTicks;
|
||||
private long _lastTicks;
|
||||
|
||||
public static Aggregate Start(in Bar bar)
|
||||
{
|
||||
Aggregate a = new()
|
||||
{
|
||||
_open = bar.Open,
|
||||
_high = bar.High,
|
||||
_low = bar.Low,
|
||||
_close = bar.Close,
|
||||
_volume = bar.Volume,
|
||||
_takerBuy = bar.TakerBuyVolume,
|
||||
_notional = Typical(bar) * bar.Volume,
|
||||
_trades = bar.TradeCount,
|
||||
_firstTicks = bar.TimeUtc.Ticks,
|
||||
_lastTicks = bar.TimeUtc.Ticks,
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
public void Add(in Bar bar)
|
||||
{
|
||||
if (bar.High > _high) { _high = bar.High; }
|
||||
if (bar.Low < _low) { _low = bar.Low; }
|
||||
|
||||
// Open and close follow the clock, not the arrival order, so that an
|
||||
// out-of-order file still yields the true first and last price. Both edges
|
||||
// are tracked: comparing only against the latest would let the second of two
|
||||
// out-of-order rows overwrite the open set by the first.
|
||||
long t = bar.TimeUtc.Ticks;
|
||||
|
||||
if (t > _lastTicks)
|
||||
{
|
||||
_close = bar.Close;
|
||||
_lastTicks = t;
|
||||
}
|
||||
|
||||
if (t < _firstTicks)
|
||||
{
|
||||
_open = bar.Open;
|
||||
_firstTicks = t;
|
||||
}
|
||||
|
||||
_volume += bar.Volume;
|
||||
_takerBuy += bar.TakerBuyVolume;
|
||||
_notional += Typical(bar) * bar.Volume;
|
||||
_trades += bar.TradeCount;
|
||||
}
|
||||
|
||||
public readonly Bar ToBar(DateTime time) =>
|
||||
new(time, _open, _high, _low, _close, _volume,
|
||||
_volume > 0 ? _notional / _volume : 0, _trades, _takerBuy);
|
||||
|
||||
private static double Typical(in Bar bar) => (bar.High + bar.Low + bar.Close) / 3.0;
|
||||
}
|
||||
|
||||
private static long Measure(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException($"CSV not found: {path}", path);
|
||||
}
|
||||
|
||||
return new FileInfo(path).Length;
|
||||
}
|
||||
|
||||
/// <summary>Streams parsed bars, skipping rows that do not make sense.</summary>
|
||||
private static IEnumerable<Bar> Read(string path, CancellationToken ct)
|
||||
{
|
||||
Measure(path);
|
||||
|
||||
using StreamReader reader = new(path);
|
||||
|
||||
string? header = reader.ReadLine()
|
||||
?? throw new InvalidDataException("The CSV is empty.");
|
||||
|
||||
Layout layout = Layout.Detect(header);
|
||||
|
||||
int lineNumber = 1;
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
lineNumber++;
|
||||
|
||||
if ((lineNumber & 0xFFFF) == 0)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layout.TryParse(line, out Bar bar))
|
||||
{
|
||||
yield return bar;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Column positions resolved once from the header row.</summary>
|
||||
private sealed class Layout
|
||||
{
|
||||
private int _time = -1;
|
||||
private int _open = -1;
|
||||
private int _high = -1;
|
||||
private int _low = -1;
|
||||
private int _close = -1;
|
||||
private int _volume = -1;
|
||||
private int _trades = -1;
|
||||
private int _takerBuy = -1;
|
||||
private int _columns;
|
||||
|
||||
public static Layout Detect(string header)
|
||||
{
|
||||
Layout layout = new();
|
||||
int index = 0;
|
||||
|
||||
foreach (Range range in Split(header))
|
||||
{
|
||||
string name = header[range].Trim().Trim('"').ToLowerInvariant();
|
||||
|
||||
if (layout._time < 0 && Array.IndexOf(TimeNames, name) >= 0)
|
||||
{
|
||||
layout._time = index;
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "open" or "o": layout._open = index; break;
|
||||
case "high" or "h": layout._high = index; break;
|
||||
case "low" or "l": layout._low = index; break;
|
||||
case "close" or "c": layout._close = index; break;
|
||||
case "volume" or "v" or "base_asset_volume": layout._volume = index; break;
|
||||
case "number_of_trades" or "trades" or "count" or "n": layout._trades = index; break;
|
||||
|
||||
// The aggressor breakdown. Without it there is no volume delta
|
||||
// and the order-flow strategy has nothing to work with.
|
||||
case "taker_buy_base_asset_volume" or "taker_buy_volume" or "taker_buy_base":
|
||||
layout._takerBuy = index;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
layout._columns = index;
|
||||
|
||||
if (layout._time < 0 || layout._open < 0 || layout._high < 0 ||
|
||||
layout._low < 0 || layout._close < 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The CSV header must contain a time column plus open, high, low and close. " +
|
||||
$"Found: {header}");
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
public bool TryParse(string line, out Bar bar)
|
||||
{
|
||||
bar = default;
|
||||
|
||||
Span<Range> fields = stackalloc Range[Math.Max(_columns, 16)];
|
||||
int count = 0;
|
||||
foreach (Range range in Split(line))
|
||||
{
|
||||
if (count == fields.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
fields[count++] = range;
|
||||
}
|
||||
|
||||
if (count <= _close)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryTime(line.AsSpan(fields[_time]), out DateTime time) ||
|
||||
!TryDouble(line.AsSpan(fields[_open]), out double open) ||
|
||||
!TryDouble(line.AsSpan(fields[_high]), out double high) ||
|
||||
!TryDouble(line.AsSpan(fields[_low]), out double low) ||
|
||||
!TryDouble(line.AsSpan(fields[_close]), out double close))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (open <= 0 || high <= 0 || low <= 0 || close <= 0 || high < low)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double volume = 0;
|
||||
if (_volume >= 0 && _volume < count)
|
||||
{
|
||||
TryDouble(line.AsSpan(fields[_volume]), out volume);
|
||||
}
|
||||
|
||||
int trades = 0;
|
||||
if (_trades >= 0 && _trades < count &&
|
||||
TryDouble(line.AsSpan(fields[_trades]), out double t))
|
||||
{
|
||||
trades = (int)t;
|
||||
}
|
||||
|
||||
double takerBuy = 0;
|
||||
if (_takerBuy >= 0 && _takerBuy < count)
|
||||
{
|
||||
TryDouble(line.AsSpan(fields[_takerBuy]), out takerBuy);
|
||||
}
|
||||
|
||||
// No VWAP column in these dumps: 0 makes the indicators fall back to the
|
||||
// typical price, which is the correct approximation.
|
||||
bar = new Bar(time, open, high, low, close, volume, 0, trades, takerBuy);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryTime(ReadOnlySpan<char> text, out DateTime time)
|
||||
{
|
||||
text = text.Trim().Trim('"');
|
||||
|
||||
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long epoch))
|
||||
{
|
||||
// Binance dumps use milliseconds; plenty of other exporters use seconds.
|
||||
time = epoch >= MillisecondThreshold
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(epoch).UtcDateTime
|
||||
: DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(text, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out time))
|
||||
{
|
||||
time = DateTime.SpecifyKind(time, DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
|
||||
time = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDouble(ReadOnlySpan<char> text, out double value) =>
|
||||
double.TryParse(text.Trim().Trim('"'), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
/// <summary>Splits on commas or semicolons without allocating substrings.</summary>
|
||||
private static IEnumerable<Range> Split(string line)
|
||||
{
|
||||
int start = 0;
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
if (line[i] is ',' or ';')
|
||||
{
|
||||
yield return new Range(start, i);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
yield return new Range(start, line.Length);
|
||||
}
|
||||
}
|
||||
@@ -1,667 +0,0 @@
|
||||
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,143 +0,0 @@
|
||||
using Encelado.Core.Market;
|
||||
using Encelado.Core.Strategies;
|
||||
using Encelado.Core.Strategies.Pairs;
|
||||
|
||||
namespace Encelado.Core.Backtest;
|
||||
|
||||
/// <summary>
|
||||
/// What the primary model said at every bar, and the calibration it said it with.
|
||||
/// <para>
|
||||
/// <see cref="Sides"/> is +1 where the strategy would open a long spread, −1 a short
|
||||
/// one, 0 where it would do nothing — always evaluated <i>as if flat</i>, so a bar
|
||||
/// inside an open position still reports whether the entry rule fires there. That is
|
||||
/// the set meta-labelling needs: every moment the primary would have acted, not only
|
||||
/// the ones where the replay happened to be free to act.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record PrimaryReplayResult(
|
||||
int[] Sides,
|
||||
double[] Beta,
|
||||
double[] Alpha,
|
||||
double[] HalfLife,
|
||||
double[] ZScore,
|
||||
double[] PValue,
|
||||
int Start)
|
||||
{
|
||||
public int Count => Sides.Length;
|
||||
|
||||
public int SignalCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int n = 0;
|
||||
foreach (int s in Sides)
|
||||
{
|
||||
if (s != 0)
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays the statistical-arbitrage rule as a <i>signal generator</i> rather than as a
|
||||
/// portfolio: same walk-forward calibrations, same warm-ups, same strategy object as
|
||||
/// <see cref="PairBacktest.Run"/>, but the output is the per-bar opinion of the rule
|
||||
/// instead of the equity curve of trading it.
|
||||
/// </summary>
|
||||
public static class PrimaryReplay
|
||||
{
|
||||
public static PrimaryReplayResult Run(
|
||||
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);
|
||||
int[] sides = new int[n];
|
||||
double[] beta = new double[n];
|
||||
double[] alpha = new double[n];
|
||||
double[] halfLife = new double[n];
|
||||
double[] z = new double[n];
|
||||
double[] pValue = new double[n];
|
||||
Array.Fill(beta, double.NaN);
|
||||
Array.Fill(alpha, double.NaN);
|
||||
Array.Fill(halfLife, double.NaN);
|
||||
Array.Fill(z, double.NaN);
|
||||
Array.Fill(pValue, double.NaN);
|
||||
|
||||
int start = settings.CalibrationBars;
|
||||
if (n < start + 50)
|
||||
{
|
||||
return new PrimaryReplayResult(sides, beta, alpha, halfLife, z, pValue, n);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
int stride = Math.Max(1, settings.RecalibrateEveryBars);
|
||||
|
||||
for (int t = start; t < n; t++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if ((t - start) % stride == 0)
|
||||
{
|
||||
PairCalibration fit = schedule is not null && schedule.TryGet(t, out PairCalibration cached)
|
||||
? cached
|
||||
: PairBacktest.Calibrate(closeA, closeB, t - settings.CalibrationBars, t);
|
||||
|
||||
strategy.Recalibrate(fit);
|
||||
|
||||
if (!strategy.IsReady)
|
||||
{
|
||||
int from = Math.Max(0, t - settings.CalibrationBars);
|
||||
for (int i = from; i < t; i++)
|
||||
{
|
||||
_ = strategy.OnBar(closeA[i], closeB[i], PairPositionView.Flat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PairSignal signal = strategy.OnBar(closeA[t], closeB[t], PairPositionView.Flat);
|
||||
|
||||
sides[t] = signal.Kind switch
|
||||
{
|
||||
PairSignalKind.EnterLongSpread => 1,
|
||||
PairSignalKind.EnterShortSpread => -1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
PairCalibration c = strategy.Calibration;
|
||||
if (c.IsValid)
|
||||
{
|
||||
beta[t] = c.Beta;
|
||||
alpha[t] = c.Alpha;
|
||||
halfLife[t] = c.HalfLifeBars;
|
||||
pValue[t] = c.PValue;
|
||||
}
|
||||
|
||||
z[t] = strategy.IsReady ? strategy.ZScore : double.NaN;
|
||||
}
|
||||
|
||||
return new PrimaryReplayResult(sides, beta, alpha, halfLife, z, pValue, start);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user