3 Commits
Author SHA1 Message Date
Alby96andClaude Fable 5.1 4c26fd3209 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>
2026-09-16 15:45:55 +02:00
Alby96andClaude Fable 5.1 b39e08b15c Aggiunge il meta-modello, il database locale e la pipeline di ricerca della guida
Il bot ora ha un secondo parere prima di ogni ingresso: un classificatore GBDT
(scritto in C#, senza dipendenze native) addestrato sugli esiti dei segnali passati
con triple-barrier e meta-labeling, validato con CPCV, PBO e Sharpe deflazionato
contando tutte le configurazioni provate. Il modello non propone mai operazioni:
può solo rifiutarne una sotto la probabilità minima o ridurne la size, e si
sospende da solo quando le feature dal vivo derivano da quelle di addestramento.
Senza un campione promosso il bot opera come prima.

Perché tutto questo serve, e nell'ordine in cui è stato fatto:

- I log del giro reale sul testnet mostravano zero barre chiuse in tre giorni: il
  decodificatore saltava l'oggetto annidato dei kline. Corretto con test di
  regressione. Lo stesso giro restava a 1499/1500 barre di riscaldamento perché
  Binance ne serve al massimo 1500 per richiesta: il client ora pagina e il motore
  chiede quante ne servono davvero.
- Il log è diventato una tabella `;` con data, livello, sorgente, evento ed
  eccezione (grep `;ERR;` trova ogni errore), con rotazione a dimensione impostabile
  dalla finestra. Anche decisions.csv/executions.csv/trades.csv hanno intestazione
  stabile, id monotoni e colonna `motivazione`, e vengono scritti anche in SQLite.
- La configurazione vive in Documenti\Encelado (con migrazione dal file accanto
  all'eseguibile), le credenziali restano in LocalAppData, il database in
  %ProgramData%\Encelado: tre cartelle per tre ruoli diversi.
- In modalità demo gli ordini partono davvero sul testnet (dryRun spento di
  fabbrica): è l'unico modo di provare il percorso di esecuzione come in produzione.
- Lo strumento di backtest copre le fasi 0-4 della guida: qualità dei dati,
  baseline buy&hold/SMA con PSR e DSR, Engle-Granger + Johansen + Kalman con costo
  di break-even, dataset e addestramento del meta-modello, DQN su molti seed.
  Ogni tabella è CSV `;` con motivazione, e la promozione a campione avviene solo
  se il modello supera i criteri della Fase 3.

Sui dati disponibili nessuna coppia supera quei criteri, quindi nessun campione è
stato promosso: il bot resta sulla sola regola statistica, che a sua volta non
regge fuori campione. Il risultato è documentato, non nascosto.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 20:19:14 +02:00
Alby96andClaude Opus 5 a4e297f77a Passa a Binance Futures con arbitraggio statistico su coppie cointegrate
Il bot smette di operare direzionalmente su un singolo asset e passa a coppie
delta-neutral: long una gamba, short l'altra nel rapporto che il test di
cointegrazione produce, scommettendo solo sul fatto che la distanza fra le due
si richiuda. È questo che gli permette di girare da una connessione domestica,
perché su barre da 15 minuti la latenza smette di contare.

Cosa cambia
- Encelado.Alpaca sostituito da Encelado.Binance: REST firmato in HMAC-SHA256
  con correzione dello scarto d'orologio, uno stream combinato per kline, book
  e mark price, e lo stream ordini autenticato con listen key rinnovata.
- Nuovo livello statistico in Core: OLS, test di Dickey-Fuller aumentato con
  scelta del ritardo per AIC, ed Engle-Granger con i valori critici di MacKinnon
  per la cointegrazione.
- Il rischio ragiona per coppia: divide il controvalore fra le gambe secondo β,
  così le due si annullano invece di lasciare un residuo direzionale, e corregge
  la dimensione con il funding netto atteso.
- Interfaccia da sette pagine a quattro. I grafici a candele sono spariti: su una
  coppia coperta la candela di una gamba non dice niente, lo z-score sì.
- Ripristino dei valori predefiniti da Impostazioni, con copia datata del file
  precedente. Ripristina il documento, commenti compresi, non solo i numeri.

L'ordine che non partiva
Il segnale diceva di entrare e non succedeva niente perché il router registrava
quasi tutti i rifiuti a livello debug: alla verbosità predefinita il bot
annunciava l'ingresso, rinunciava per un motivo che nessuno poteva vedere, e
sembrava aver ignorato la propria decisione. Adesso ogni intento produce una
riga a info o warn con il nome della coppia e il motivo esatto, la frase che
l'operatore legge e la decisione che il motore prende vengono dallo stesso
stato, e una barra che lo stream non consegna viene recuperata via REST.

Che cosa dice il backtest
Il banco di prova rigioca le coppie attraverso la STESSA classe che gira in
produzione, con la calibrazione che cammina in avanti. Su 6,6 anni di ETHUSDT,
BTCUSDT, SOLUSDT e AVAXUSDT: a 5 minuti nessuna combinazione di soglie supera
i filtri di taratura; a 15 minuti e a un'ora la griglia trova combinazioni che
rendono in taratura e in verifica, ma nessuna delle prime dieci resta positiva
sulla terza fetta. La finestra dello z-score va molte volte oltre l'emivita del
rientro — le 100 barre della guida sono le peggiori misurate — e il filtro di
cointegrazione è ciò che tiene in piedi tutto: senza, ogni combinazione passa
da leggermente positiva a −73%/−87%.

Per questo dryRun parte attivo. I valori consegnati sono i meglio supportati
fra quelli provati, non una strategia dimostrata, e il file lo dice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:01:47 +02:00
208 changed files with 26411 additions and 19603 deletions
+3
View File
@@ -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/
+5 -5
View File
@@ -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"
}
]
}
+22
View File
@@ -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).
+67
View File
@@ -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`).
+1 -1
View File
@@ -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 -1
View File
@@ -1,8 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Encelado.Alpaca/Encelado.Alpaca.csproj" />
<Project Path="src/Encelado.Bot/Encelado.Bot.csproj" />
<Project Path="src/Encelado.Core/Encelado.Core.csproj" />
<Project Path="src/Encelado.Etoro/Encelado.Etoro.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Encelado.Tests/Encelado.Tests.csproj" />
+1 -1
View File
@@ -1,2 +1,2 @@
Altre cose da fare:
Ottimo! Ora effettua le seguenti modifiche:
-
+4 -4
View File
@@ -1,4 +1,4 @@
; ─────────────────────────────────────────────────────────────────────────────
; ─────────────────────────────────────────────────────────────────────────────
; Encelado — script di installazione (Inno Setup 6)
;
; Non si compila a mano: lo lancia build/Release.proj, che prima pubblica
@@ -36,7 +36,7 @@
#define AppName "Encelado"
#define AppPublisher "Alberto Balbo"
#define AppExeName "Encelado.exe"
#define AppDescription "Bot di trading automatico su Alpaca"
#define AppDescription "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 Alpaca 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 Alpaca 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.',
+17 -8
View File
@@ -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,9 +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 un file di dati
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="C:\dati\btcusd.csv"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=frequency
# 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 |
@@ -52,9 +53,17 @@ dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=frequency
| `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` | — | Il CSV da rigiocare. Obbligatorio per `Backtest`. |
| `Comando` | `split` | `split`, `walk`, `frequency`, `costs`, `sweep`, `explore`. |
| `Barre` | `1d` | Ampiezza delle barre nel backtest. |
| `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
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
+16 -13
View File
@@ -15,7 +15,8 @@
progetti. Le differenze rispetto a quel file sono tre, tutte segnate sul
posto: la versione vive in Directory.Build.props e non nel .csproj, la
pubblicazione produce una cartella e non un singolo eseguibile, e il target
Backtest rigioca serie storiche di prezzi invece dei dossier delle aste.
Backtest rigioca coppie di serie storiche di prezzi invece dei dossier
delle aste.
── Perché MSBuild e non uno script ──────────────────────────────────────
La catena vive accanto al codice che rilascia ed è versionata con lui: fra
@@ -414,36 +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 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.
I parametri li legge da config/encelado.json, non li ridichiara: il banco e
il bot non possono divergere senza che qualcuno se ne accorga.
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)' == ''">split</Comando>
<Barre Condition="'$(Barre)' == ''">1d</Barre>
<Comando Condition="'$(Comando)' == ''">baskets</Comando>
<BacktestExe>$(Radice)\tools\Encelado.Backtest\bin\Release\net10.0\backtest.exe</BacktestExe>
</PropertyGroup>
<Error Condition="'$(Dati)' == ''"
Text="Serve un file di dati: -p:Dati=&quot;C:\percorso\btcusd.csv&quot;.%0AComandi disponibili in -p:Comando= : split, sweep, walk, frequency, costs, explore." />
Text="Serve la cartella dei dati: -p:Dati=&quot;%USERPROFILE%\Documents\Encelado\data\market&quot; (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="File di dati non trovato: $(Dati)" />
<Error Condition="!Exists('$(Dati)')" Text="Cartella dati non trovata: $(Dati)" />
<Message Importance="High" Text="== Rigiocata sui prezzi ==" />
<Message Importance="High" Text="== 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 &quot;$(BacktestProj)&quot; -c Release --nologo -v q" />
<Exec WorkingDirectory="$(Radice)"
Command="&quot;$(BacktestExe)&quot; $(Comando) --file &quot;$(Dati)&quot; --tf $(Barre) $(Extra)" />
Command="&quot;$(BacktestExe)&quot; $(Comando) --data &quot;$(Dati)&quot; $(Extra)" />
</Target>
<!-- ═════════════════════ Eseguibile ═════════════════════ -->
+41 -139
View File
@@ -1,150 +1,52 @@
{
"_comment": "Encelado — configurazione unica. Ogni numero qui sotto è stato verificato su due dataset indipendenti: 4.756 barre giornaliere Bitstamp (2012-2025, ripiegate da 6,8 milioni di barre da un minuto) e 3.260 barre Binance (2017-2026). Vedi il README.",
"_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.",
"alpaca": {
"paper": true,
"dataFeed": "iex",
"requestsPerMinute": 180,
"httpTimeoutSeconds": 15,
"maxRetries": 4
"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": {
"assetClass": "crypto",
"_timeFrame": "Giornaliero. Le stesse regole su barre orarie perdono il 99% del capitale: con ~50 bps di costo per giro completo la frequenza uccide prima della direzione.",
"timeFrame": "1Day",
"_warmup": "La media è a 100 giorni. 220 barre danno margine.",
"warmupBars": 220,
"tradeOnlyRegularHours": false,
"flattenBeforeCloseMinutes": 0,
"_crypto": "Alpaca sulle crypto vuole quantità frazionarie e non supporta i bracket order: lo stop lo tiene l'engine e lo verifica a ogni quotazione.",
"allowFractionalShares": true,
"useBracketOrders": false,
"entryOrderType": "limit",
"limitOffsetBps": 8,
"dryRun": false,
"_reconcile": "Ogni quanto il bot ricontrolla conto, posizioni e ordini contro il broker, e chiede le barre già chiuse. È anche il momento in cui si accorge che una barra nuova è disponibile da valutare, quindi abbassarlo lo rende più reattivo all'apertura di una barra.",
"reconcileSeconds": 30,
"_status": "Riepilogo periodico nel log: contatori, latenze, stato delle connessioni.",
"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 al prezzo attuale e lo scrive nel log, se è cambiato rispetto a prima. Su barre giornaliere il bot è legittimamente silenzioso per settimane, e da fuori il silenzio è indistinguibile da un blocco: questa riga lo trasforma in una frase.",
"explainSeconds": 5,
"maxQuoteAgeSeconds": 120,
"closeOnShutdown": false
},
"risk": {
"_sizing": "Questa strategia compete con il comprare e tenere, quindi quando è dentro deve esserci per intero: qualunque frazione inferiore perde la gara in partenza. stakePct 1.0 impegna tutto il saldo disponibile; il risk engine si ferma comunque al 98% per lasciare spazio alle commissioni.",
"stakePct": 1.0,
"stakeAmount": 0,
"_risk": "Non usato finché stakePct è impostato, ma deve restare valido: è il criterio di riserva se un giorno azzeri stakePct.",
"maxRiskPerTradePct": 0.05,
"_caps": "A 1.0 perché con un solo asset e stake pieno la posizione È il portafoglio. Abbassarli qui significa restare parzialmente liquidi e perdere rendimento senza guadagnare protezione: la protezione la dà l'uscita sotto la media.",
"maxPositionNotionalPct": 1.0,
"maxGrossExposurePct": 1.0,
"_openPositions": "0 = nessun limite. Nota però che con un solo simbolo il numero di posizioni contemporanee resta 1 comunque: il risk engine rifiuta un secondo ingresso sullo stesso strumento con 'already in position'. E con stakePct 1.0 la prima posizione impegna tutto il saldo, quindi una seconda non avrebbe con cosa aprirsi. Questo limite torna a contare quando aggiungi simboli.",
"maxOpenPositions": 0,
"_frequency": "0 = nessun limite. Il bot può aprire quante posizioni vuole e fare quante operazioni vuole: a fermarlo è la strategia, non un contatore. Attenzione: erano una rete contro un bug (un ciclo che riapre la stessa posizione mille volte costa mille commissioni). Con 0 quella rete non c'è più.",
"maxTradesPerDay": 0,
"maxTradesPerSymbolPerDay": 0,
"minSecondsBetweenEntries": 0,
"_dailyLoss": "Kill switch giornaliero. Al 25% perché su BTC un -20% in un giorno è successo più volte e non è una ragione per smettere: la strategia esce quando cede la media, non quando fa male. Troppo stretto qui significa liquidare sul minimo.",
"maxDailyLossPct": 0.25,
"maxDailyProfitPct": 0,
"maxRelativeSpread": 0.0015,
"minPrice": 0.01,
"maxPrice": 10000000,
"minOrderNotional": 25,
"maxOrderNotional": 0,
"_shorting": "Alpaca non consente lo short sulle crypto. La strategia è long/flat.",
"allowShorting": false,
"_stop": "Rete di sicurezza per un gap, non il controllo del rischio. Quello vero è l'uscita sotto la media: uno stop stretto venderebbe e poi aspetterebbe un nuovo incrocio per rientrare, che è esattamente come il modello precedente trasformava le oscillazioni in perdite realizzate.",
"defaultStopPct": 0.35,
"maxStopDistancePct": 0.60
},
"logging": {
"_level": "trace | debug | info | warn | error | none. 'debug' registra anche ogni segnale scartato e ogni rifiuto del risk engine: utile per capire perché il bot NON ha fatto qualcosa.",
"level": "debug",
"_directory": "Dove salvare tutti gli output. Relativa all'eseguibile, oppure un percorso assoluto tipo D:\\encelado-logs. Si cambia anche da Impostazioni → Log, che verifica di potervi scrivere prima di salvare.",
"directory": "logs",
"console": false,
"file": "encelado.log",
"_rotation": "Ruota encelado.log in encelado.1.log e così via, tenendo gli ultimi 10.",
"maxFileSizeMb": 32,
"maxFiles": 10,
"_analysis": "decisions.csv ha una riga per ogni barra valutata con tutti gli indicatori; executions.csv ha una riga per ogni segnale arrivato agli ordini, con il verdetto del risk engine. Si uniscono su decisionId. Sono il materiale per migliorare il modello.",
"tradeJournal": "trades.jsonl",
"decisionLog": "decisions.csv",
"executionLog": "executions.csv",
"_verbose": "Con logMarketData attivo e level=trace registra ogni singola quotazione e ogni print. File enormi: serve solo per diagnosticare il flusso dati.",
"logMarketData": false,
"_everyBar": "Scrive una riga per ogni barra da un minuto che arriva dallo stream, non solo per quelle che chiudono una barra della strategia. Su barre giornaliere 1439 minuti su 1440 vengono assorbiti in silenzio: senza questo il log non mostra nulla per ventiquattr'ore e il bot sembra fermo.",
"logEveryBar": true,
"_inApp": "Quante righe tiene la striscia ATTIVITÀ nella pagina Stato e quante ne tiene la scheda Log. La seconda è il tetto di memoria del log in-app. Il file su disco resta completo comunque.",
"statusLines": 200,
"bufferedLines": 5000
"_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,
"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
},
"ui": {
"url": "http://localhost:5088",
"autoStartBot": false,
"openBrowser": false
"_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"
},
"_symbols": "Solo BTC/USD. ETH è stato tolto: la strategia è tarata e verificata su BTC, e con stakePct 1.0 un secondo asset dimezzerebbe l'esposizione al primo senza che nessun backtest lo giustifichi.",
"symbols": [
{
"symbol": "BTC/USD",
"strategy": "trend-filter",
"enabled": true,
"parameters": {
"_period": "Media a 100 giorni. È l'unico valore che batte il comprare e tenere su ENTRAMBI i dataset: 120 rende di più su Bitstamp ma perde su Binance, 200 perde su tutti e due. La riga dei 100 giorni vince su entrambi a qualunque banda.",
"period": 100,
"_band": "Isteresi, non un filtro: si entra il 2% sopra la media e si esce il 2% sotto, così un prezzo appoggiato alla media non genera un'operazione ogni due giorni. Dimezza gli scambi lasciando il rendimento dov'era.",
"band": 0.02,
"_stop": "Rete per un gap. La vera uscita è la media.",
"stopPct": 0.35,
"_cvd": "Gate di order flow, disattivato. Misurato su Binance con il volume taker: alzandolo il Calmar scende da 0,71 a 0,66 a 0,64. Serviva al modello precedente, che operava di rado e poteva permettersi di aspettare conferma; qui ogni barra passata ad aspettare è una barra che non compone. Il valore resta calcolato e registrato nei log.",
"cvdThreshold": 0,
"cvdPeriod": 10,
"cvdNormPeriod": 60,
"_diagnostics": "Solo per il pannello e i log, non entrano in nessuna decisione.",
"volPeriod": 30,
"barsPerYear": 365,
"atrPeriod": 14,
"allowShort": 0
}
}
]
"logging": {
"_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",
"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,
"_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
}
}
@@ -1,12 +0,0 @@
{
"_comment": "Copy this file to encelado.local.json (gitignored) next to encelado.json. It is merged on top of the main config, so it only needs the keys you want to override. Environment variables still win over both.",
"alpaca": {
"keyId": "PK...........",
"secretKey": "................................"
},
"engine": {
"dryRun": true
}
}
+80
View File
@@ -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)" }
]
}
+148
View File
@@ -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).
+69
View File
@@ -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 (;)
```
+32
View File
@@ -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(t1). 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`. |
+36
View File
@@ -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.
+83
View File
@@ -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`.
+94
View File
@@ -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).
+43
View File
@@ -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`. |
+36
View File
@@ -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 |
+92
View File
@@ -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).
+34
View 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).
+130
View File
@@ -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.
+14
View File
@@ -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
1 test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
1 test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
+8
View File
@@ -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
1 voce;valore;motivazione
2 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
3 prove;57;ogni configurazione provata conta nel Sharpe deflazionato
4 baseline Moderate;Sharpe -3.38, netto -9608 USD, 2224 basket, win rate 50 %;la configurazione di fabbrica così com'è
5 migliore;BASE-CON: Sharpe 0.00, DSR 0.000;non distinguibile dalla selezione fra le prove
6 PBO;0.000;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
7 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
8 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
1 voce;valore;motivazione
2 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
3 prove;57;ogni configurazione provata conta nel Sharpe deflazionato
4 baseline Moderate;Sharpe -3.88, netto -9802 USD, 2076 basket, win rate 48 %;la configurazione di fabbrica così com'è
5 migliore;T013: Sharpe -0.10, DSR 0.000;non distinguibile dalla selezione fra le prove
6 PBO;0.001;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
7 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
8 verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
+58
View File
@@ -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
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 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
31 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
32 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
33 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
34 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
35 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
36 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
37 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
38 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
39 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
40 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
41 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
42 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
43 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
44 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
45 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
46 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
47 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
48 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
49 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
50 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
51 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
52 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
53 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
54 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
55 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
56 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
57 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
58 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
+58
View File
@@ -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 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 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
31 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
32 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
33 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
34 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
35 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
36 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
37 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
38 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
39 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
40 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
41 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
42 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
43 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
44 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
45 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
46 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
47 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
48 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
49 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
50 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
51 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
52 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
53 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
54 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
55 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
56 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
57 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
58 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,114 +0,0 @@
using Encelado.Core.Market;
namespace Encelado.Alpaca;
/// <summary>Connection settings for every Alpaca endpoint the bot talks to.</summary>
public sealed class AlpacaOptions
{
public const string PaperTradingBase = "https://paper-api.alpaca.markets";
public const string LiveTradingBase = "https://api.alpaca.markets";
public const string MarketDataBase = "https://data.alpaca.markets";
public const string MarketDataStreamBase = "wss://stream.data.alpaca.markets";
public string KeyId { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
/// <summary>Paper trading is the default. Flipping this to <see langword="false"/> risks real money.</summary>
public bool Paper { get; set; } = true;
/// <summary>
/// Equity data feed: <c>iex</c> (free), <c>sip</c> (full tape, paid),
/// <c>delayed_sip</c>, or <c>test</c> (Alpaca's synthetic FAKEPACA stream).
/// </summary>
public string DataFeed { get; set; } = "iex";
/// <summary>Overrides the trading REST base URL. Leave empty to derive it from <see cref="Paper"/>.</summary>
public string TradingBaseUrlOverride { get; set; } = string.Empty;
/// <summary>Overrides the market-data REST base URL.</summary>
public string DataBaseUrlOverride { get; set; } = string.Empty;
/// <summary>Client-side throttle. Alpaca allows 200 requests/minute per account on the basic plan.</summary>
public int RequestsPerMinute { get; set; } = 180;
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>Number of retries for transient failures (429 / 5xx / socket errors).</summary>
public int MaxRetries { get; set; } = 4;
public string TradingBaseUrl =>
string.IsNullOrWhiteSpace(TradingBaseUrlOverride)
? (Paper ? PaperTradingBase : LiveTradingBase)
: TradingBaseUrlOverride.TrimEnd('/');
public string DataBaseUrl =>
string.IsNullOrWhiteSpace(DataBaseUrlOverride)
? MarketDataBase
: DataBaseUrlOverride.TrimEnd('/');
/// <summary>Order/position event stream. Lives on the trading host, not the data host.</summary>
public Uri TradeUpdatesStreamUri =>
new(TradingBaseUrl.Replace("https://", "wss://", StringComparison.Ordinal) + "/stream");
public Uri MarketDataStreamUri(AssetClass assetClass) => assetClass switch
{
AssetClass.Crypto => new Uri($"{MarketDataStreamBase}/v1beta3/crypto/us"),
_ => new Uri($"{MarketDataStreamBase}/v2/{DataFeed}"),
};
public AlpacaOptions Validate()
{
if (string.IsNullOrWhiteSpace(KeyId) || string.IsNullOrWhiteSpace(SecretKey))
{
throw new InvalidOperationException(
"Alpaca credentials are missing. Set APCA_API_KEY_ID and APCA_API_SECRET_KEY " +
"(or alpaca.keyId / alpaca.secretKey in the config file).");
}
// Credentials travel as HTTP headers. A stray non-ASCII character (a smart quote
// from a copy/paste, a BOM, a UTF-16 artefact from a pipe) would otherwise
// surface much later as an opaque "invalid char encoding" transport failure.
RequirePrintableAscii(KeyId, nameof(KeyId));
RequirePrintableAscii(SecretKey, nameof(SecretKey));
if (DataFeed is not ("iex" or "sip" or "delayed_sip" or "otc" or "test"))
{
throw new InvalidOperationException(
$"alpaca.dataFeed '{DataFeed}' is not one of: iex, sip, delayed_sip, otc, test.");
}
if (RequestsPerMinute is < 1 or > 1000)
{
throw new InvalidOperationException("alpaca.requestsPerMinute must be between 1 and 1000.");
}
return this;
}
private static void RequirePrintableAscii(string value, string field)
{
foreach (char c in value)
{
if (c is < ' ' or > '~')
{
throw new InvalidOperationException(
$"alpaca.{char.ToLowerInvariant(field[0])}{field[1..]} contains a character that is not " +
$"printable ASCII (U+{(int)c:X4}). Re-copy the key from the Alpaca dashboard — " +
"invisible characters are usually picked up by copy/paste.");
}
}
}
}
/// <summary>Raised when Alpaca answers with a non-success status or an unusable payload.</summary>
public sealed class AlpacaApiException(string message, int statusCode = 0, string? body = null)
: Exception(message)
{
public int StatusCode { get; } = statusCode;
public string? Body { get; } = body;
/// <summary>Transient conditions worth retrying.</summary>
public bool IsTransient => StatusCode is 429 or >= 500;
}
@@ -1,12 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Encelado.Alpaca</RootNamespace>
<AssemblyName>Encelado.Alpaca</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
</ItemGroup>
</Project>
@@ -1,98 +0,0 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Alpaca.Internal;
/// <summary>
/// Reading helpers for Alpaca's REST payloads. Alpaca encodes most numeric fields as
/// JSON <i>strings</i> (<c>"qty": "10"</c>), and omits or nulls fields liberally, so
/// every accessor tolerates both shapes and a missing property.
/// </summary>
public static class JsonRead
{
public static string? StringOrNull(this JsonElement e, string name) =>
e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
public static string StringOrEmpty(this JsonElement e, string name) =>
e.StringOrNull(name) ?? string.Empty;
public static double Double(this JsonElement e, string name, double fallback = 0)
{
if (!e.TryGetProperty(name, out JsonElement v))
{
return fallback;
}
return v.ValueKind switch
{
JsonValueKind.Number => v.GetDouble(),
JsonValueKind.String => double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d)
? d
: fallback,
_ => fallback,
};
}
public static decimal Decimal(this JsonElement e, string name, decimal fallback = 0)
{
if (!e.TryGetProperty(name, out JsonElement v))
{
return fallback;
}
return v.ValueKind switch
{
JsonValueKind.Number => v.GetDecimal(),
JsonValueKind.String => decimal.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out decimal d)
? d
: fallback,
_ => fallback,
};
}
public static int Int32(this JsonElement e, string name, int fallback = 0)
{
if (!e.TryGetProperty(name, out JsonElement v))
{
return fallback;
}
return v.ValueKind switch
{
JsonValueKind.Number => v.TryGetInt32(out int i) ? i : (int)v.GetDouble(),
JsonValueKind.String => int.TryParse(v.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int i)
? i
: fallback,
_ => fallback,
};
}
public static bool Bool(this JsonElement e, string name, bool fallback = false)
{
if (!e.TryGetProperty(name, out JsonElement v))
{
return fallback;
}
return v.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(v.GetString(), out bool b) ? b : fallback,
_ => fallback,
};
}
public static DateTime Timestamp(this JsonElement e, string name) =>
e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String
? Rfc3339.ParseUtc(v.GetString())
: DateTime.MinValue;
public static DateTime? TimestampOrNull(this JsonElement e, string name)
{
DateTime dt = e.Timestamp(name);
return dt == DateTime.MinValue ? null : dt;
}
}
@@ -1,121 +0,0 @@
using System.Globalization;
namespace Encelado.Alpaca.Internal;
/// <summary>
/// Hand-rolled RFC3339 parser for the shape Alpaca actually emits
/// (<c>2024-05-17T13:04:56.334262119Z</c>). It runs on every tick of the market-data
/// stream, so it avoids the general-purpose date parser and its culture lookups.
/// Falls back to <see cref="DateTime.TryParse(ReadOnlySpan{char}, IFormatProvider, DateTimeStyles, out DateTime)"/>
/// for anything unusual (offsets, missing fractions, non-UTC).
/// </summary>
public static class Rfc3339
{
/// <summary>Parses a UTC timestamp from UTF-8 bytes. Returns <see cref="DateTime.MinValue"/> on failure.</summary>
public static DateTime ParseUtc(ReadOnlySpan<byte> utf8)
{
// Fast path: exactly "YYYY-MM-DDTHH:MM:SS" plus optional ".fraction" and a "Z".
if (utf8.Length >= 20 && utf8[^1] == (byte)'Z' &&
utf8[4] == (byte)'-' && utf8[7] == (byte)'-' &&
(utf8[10] == (byte)'T' || utf8[10] == (byte)' ') &&
utf8[13] == (byte)':' && utf8[16] == (byte)':')
{
if (TryDigits(utf8, 0, 4, out int year) &&
TryDigits(utf8, 5, 2, out int month) &&
TryDigits(utf8, 8, 2, out int day) &&
TryDigits(utf8, 11, 2, out int hour) &&
TryDigits(utf8, 14, 2, out int minute) &&
TryDigits(utf8, 17, 2, out int second))
{
long fractionTicks = 0;
if (utf8.Length > 20 && utf8[19] == (byte)'.')
{
// Consume up to 7 fractional digits (100 ns resolution); ignore the rest.
int i = 20;
int digits = 0;
long value = 0;
while (i < utf8.Length - 1 && utf8[i] >= (byte)'0' && utf8[i] <= (byte)'9')
{
if (digits < 7)
{
value = (value * 10) + (utf8[i] - (byte)'0');
digits++;
}
i++;
}
while (digits < 7)
{
value *= 10;
digits++;
}
fractionTicks = value;
}
try
{
return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc)
.AddTicks(fractionTicks);
}
catch (ArgumentOutOfRangeException)
{
return DateTime.MinValue;
}
}
}
return SlowParse(utf8);
}
public static DateTime ParseUtc(string? text)
{
if (string.IsNullOrEmpty(text))
{
return DateTime.MinValue;
}
return DateTime.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out DateTime dt)
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
: DateTime.MinValue;
}
private static DateTime SlowParse(ReadOnlySpan<byte> utf8)
{
Span<char> chars = utf8.Length <= 64 ? stackalloc char[utf8.Length] : new char[utf8.Length];
for (int i = 0; i < utf8.Length; i++)
{
chars[i] = (char)utf8[i];
}
return DateTime.TryParse(
chars,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out DateTime dt)
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
: DateTime.MinValue;
}
private static bool TryDigits(ReadOnlySpan<byte> utf8, int offset, int count, out int value)
{
value = 0;
for (int i = offset; i < offset + count; i++)
{
byte b = utf8[i];
if (b < (byte)'0' || b > (byte)'9')
{
return false;
}
value = (value * 10) + (b - (byte)'0');
}
return true;
}
}
@@ -1,197 +0,0 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Encelado.Alpaca.Internal;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Rest;
/// <summary>
/// Historical and latest-snapshot market data. Used to warm indicators up before the
/// live stream takes over, and by the replay/backtest mode.
/// </summary>
public sealed class AlpacaDataClient(AlpacaOptions options) : IDisposable
{
private readonly AlpacaHttp _http = new(options.Validate(), options.DataBaseUrl);
private readonly string _feed = options.DataFeed;
/// <summary>Alpaca caps a single bars page at 10 000 rows.</summary>
private const int PageLimit = 10_000;
public Task WarmupAsync(CancellationToken ct) =>
_http.WarmupAsync("v2/stocks/bars?symbols=SPY&timeframe=1Day&limit=1", ct);
/// <summary>
/// Fetches historical bars for one or more symbols in chronological order,
/// following pagination across the whole requested range.
/// <para>
/// <paramref name="maxBarsPerSymbol"/> keeps the <b>most recent</b> N bars, which is
/// what indicator warm-up needs — trimming while paging would keep the oldest ones
/// and leave the strategies primed with stale state.
/// </para>
/// </summary>
public async Task<Dictionary<string, List<Bar>>> GetBarsAsync(
IReadOnlyList<string> symbols,
TimeFrame timeFrame,
DateTime startUtc,
DateTime? endUtc,
AssetClass assetClass,
int maxBarsPerSymbol,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(symbols);
Dictionary<string, List<Bar>> result = new(StringComparer.OrdinalIgnoreCase);
if (symbols.Count == 0 || maxBarsPerSymbol <= 0)
{
return result;
}
string basePath = assetClass == AssetClass.Crypto
? "v1beta3/crypto/us/bars"
: "v2/stocks/bars";
string? pageToken = null;
int guard = 0;
do
{
StringBuilder path = new(256);
path.Append(basePath)
.Append("?symbols=").Append(Uri.EscapeDataString(string.Join(',', symbols)))
.Append("&timeframe=").Append(timeFrame.ToAlpaca())
.Append("&limit=").Append(PageLimit)
.Append("&sort=asc")
.Append("&start=").Append(Uri.EscapeDataString(FormatInstant(startUtc)));
if (endUtc is { } end)
{
path.Append("&end=").Append(Uri.EscapeDataString(FormatInstant(end)));
}
if (assetClass != AssetClass.Crypto)
{
path.Append("&adjustment=raw&feed=").Append(_feed == "test" ? "iex" : _feed);
}
if (pageToken is not null)
{
path.Append("&page_token=").Append(Uri.EscapeDataString(pageToken));
}
using JsonDocument doc = await _http.GetAsync(path.ToString(), ct).ConfigureAwait(false);
JsonElement root = doc.RootElement;
if (root.TryGetProperty("bars", out JsonElement barsBySymbol) &&
barsBySymbol.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty symbolBars in barsBySymbol.EnumerateObject())
{
if (!result.TryGetValue(symbolBars.Name, out List<Bar>? list))
{
list = new List<Bar>(Math.Min(maxBarsPerSymbol, 1024));
result[symbolBars.Name] = list;
}
foreach (JsonElement b in symbolBars.Value.EnumerateArray())
{
list.Add(ParseBar(b));
}
}
}
pageToken = root.TryGetProperty("next_page_token", out JsonElement token) &&
token.ValueKind == JsonValueKind.String
? token.GetString()
: null;
}
while (pageToken is not null && ++guard < 500);
// Keep only the newest slice, preserving chronological order.
foreach (string key in result.Keys)
{
List<Bar> bars = result[key];
if (bars.Count > maxBarsPerSymbol)
{
result[key] = bars.GetRange(bars.Count - maxBarsPerSymbol, maxBarsPerSymbol);
}
}
return result;
}
public async Task<Dictionary<string, Quote>> GetLatestQuotesAsync(
IReadOnlyList<string> symbols,
AssetClass assetClass,
CancellationToken ct)
{
Dictionary<string, Quote> result = new(StringComparer.OrdinalIgnoreCase);
if (symbols.Count == 0)
{
return result;
}
string path = assetClass == AssetClass.Crypto
? $"v1beta3/crypto/us/latest/quotes?symbols={Uri.EscapeDataString(string.Join(',', symbols))}"
: $"v2/stocks/quotes/latest?symbols={Uri.EscapeDataString(string.Join(',', symbols))}&feed={(_feed == "test" ? "iex" : _feed)}";
using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false);
if (doc.RootElement.TryGetProperty("quotes", out JsonElement quotes) &&
quotes.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty p in quotes.EnumerateObject())
{
result[p.Name] = new Quote(
p.Value.Timestamp("t"),
p.Value.Double("bp"),
p.Value.Double("bs"),
p.Value.Double("ap"),
p.Value.Double("as"));
}
}
return result;
}
public async Task<Dictionary<string, Tick>> GetLatestTradesAsync(
IReadOnlyList<string> symbols,
AssetClass assetClass,
CancellationToken ct)
{
Dictionary<string, Tick> result = new(StringComparer.OrdinalIgnoreCase);
if (symbols.Count == 0)
{
return result;
}
string path = assetClass == AssetClass.Crypto
? $"v1beta3/crypto/us/latest/trades?symbols={Uri.EscapeDataString(string.Join(',', symbols))}"
: $"v2/stocks/trades/latest?symbols={Uri.EscapeDataString(string.Join(',', symbols))}&feed={(_feed == "test" ? "iex" : _feed)}";
using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false);
if (doc.RootElement.TryGetProperty("trades", out JsonElement trades) &&
trades.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty p in trades.EnumerateObject())
{
result[p.Name] = new Tick(p.Value.Timestamp("t"), p.Value.Double("p"), p.Value.Double("s"));
}
}
return result;
}
internal static Bar ParseBar(JsonElement e) => new(
e.Timestamp("t"),
e.Double("o"),
e.Double("h"),
e.Double("l"),
e.Double("c"),
e.Double("v"),
e.Double("vw"),
e.Int32("n"));
private static string FormatInstant(DateTime utc) =>
DateTime.SpecifyKind(utc, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
public void Dispose() => _http.Dispose();
}
@@ -1,225 +0,0 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Encelado.Alpaca.Rest;
/// <summary>
/// Shared HTTP transport for the Alpaca REST APIs: one pooled, pre-warmed HTTP/2
/// connection per host, a client-side rate limiter that keeps us under Alpaca's
/// 200 req/min, and bounded retries for transient failures.
/// </summary>
public sealed class AlpacaHttp : IDisposable
{
private readonly HttpClient _http;
private readonly MinuteRateLimiter _limiter;
private readonly int _maxRetries;
public AlpacaHttp(AlpacaOptions options, string baseUrl)
{
ArgumentNullException.ThrowIfNull(options);
SocketsHttpHandler handler = new()
{
// Long-lived pooled connections: TLS handshakes are the single biggest
// source of order latency, so we never want to pay one on the hot path.
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),
MaxConnectionsPerServer = 16,
EnableMultipleHttp2Connections = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
ConnectTimeout = TimeSpan.FromSeconds(10),
KeepAlivePingDelay = TimeSpan.FromSeconds(30),
KeepAlivePingTimeout = TimeSpan.FromSeconds(10),
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests,
};
_http = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
Timeout = options.HttpTimeout,
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};
_http.DefaultRequestHeaders.Add("APCA-API-KEY-ID", options.KeyId);
_http.DefaultRequestHeaders.Add("APCA-API-SECRET-KEY", options.SecretKey);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Encelado/2.0");
_limiter = new MinuteRateLimiter(options.RequestsPerMinute);
_maxRetries = Math.Max(0, options.MaxRetries);
}
/// <summary>
/// Opens the TLS connection ahead of the first real request so the first order
/// does not pay for the handshake.
/// </summary>
public async Task WarmupAsync(string probePath, CancellationToken ct)
{
try
{
using JsonDocument _ = await GetAsync(probePath, ct).ConfigureAwait(false);
}
catch (AlpacaApiException)
{
// A 4xx still means the socket is up, which is all warm-up needs.
}
}
public Task<JsonDocument> GetAsync(string path, CancellationToken ct) =>
SendAsync(HttpMethod.Get, path, null, ct);
public Task<JsonDocument> PostAsync(string path, ReadOnlyMemory<byte> json, CancellationToken ct) =>
SendAsync(HttpMethod.Post, path, json, ct);
public Task<JsonDocument> PatchAsync(string path, ReadOnlyMemory<byte> json, CancellationToken ct) =>
SendAsync(HttpMethod.Patch, path, json, ct);
public Task<JsonDocument> DeleteAsync(string path, CancellationToken ct) =>
SendAsync(HttpMethod.Delete, path, null, ct);
/// <summary>Like <see cref="GetAsync"/> but maps HTTP 404 to <see langword="null"/>.</summary>
public async Task<JsonDocument?> GetOrNullAsync(string path, CancellationToken ct)
{
try
{
return await GetAsync(path, ct).ConfigureAwait(false);
}
catch (AlpacaApiException ex) when (ex.StatusCode == 404)
{
return null;
}
}
private async Task<JsonDocument> SendAsync(
HttpMethod method,
string path,
ReadOnlyMemory<byte>? body,
CancellationToken ct)
{
AlpacaApiException? last = null;
for (int attempt = 0; attempt <= _maxRetries; attempt++)
{
await _limiter.WaitAsync(ct).ConfigureAwait(false);
using HttpRequestMessage request = new(method, path);
if (body is { } payload)
{
request.Content = new ReadOnlyMemoryContent(payload);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
HttpResponseMessage? response = null;
try
{
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct)
.ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
await using Stream stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NoContent || response.Content.Headers.ContentLength == 0)
{
return JsonDocument.Parse("{}"u8.ToArray());
}
return await JsonDocument.ParseAsync(stream, default, ct).ConfigureAwait(false);
}
string errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
last = new AlpacaApiException(
$"{method} {path} -> {(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(errorBody)}",
(int)response.StatusCode,
errorBody);
if (!last.IsTransient || attempt == _maxRetries)
{
throw last;
}
await BackoffAsync(attempt, response.Headers.RetryAfter, ct).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (attempt < _maxRetries)
{
last = new AlpacaApiException($"{method} {path} -> transport failure: {ex.Message}");
await BackoffAsync(attempt, null, ct).ConfigureAwait(false);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < _maxRetries)
{
last = new AlpacaApiException($"{method} {path} -> timed out after {_http.Timeout.TotalSeconds:F0}s");
await BackoffAsync(attempt, null, ct).ConfigureAwait(false);
}
finally
{
response?.Dispose();
}
}
throw last ?? new AlpacaApiException($"{method} {path} failed without a response.");
}
private static async Task BackoffAsync(int attempt, RetryConditionHeaderValue? retryAfter, CancellationToken ct)
{
TimeSpan delay;
if (retryAfter?.Delta is { } delta && delta > TimeSpan.Zero)
{
delay = delta;
}
else
{
double baseMs = 200 * Math.Pow(2, attempt);
delay = TimeSpan.FromMilliseconds(baseMs + Random.Shared.Next(0, 150));
}
await Task.Delay(delay > TimeSpan.FromSeconds(30) ? TimeSpan.FromSeconds(30) : delay, ct)
.ConfigureAwait(false);
}
private static string Truncate(string s) => s.Length <= 400 ? s : s[..400] + "…";
public void Dispose() => _http.Dispose();
}
/// <summary>
/// Sliding-window limiter: remembers when each of the last N requests went out and
/// blocks until the oldest falls out of the 60 second window.
/// </summary>
internal sealed class MinuteRateLimiter(int permitsPerMinute)
{
private static readonly long WindowTicks = Stopwatch.Frequency * 60;
private readonly long[] _sentAt = new long[Math.Max(1, permitsPerMinute)];
private readonly SemaphoreSlim _gate = new(1, 1);
private int _index;
public async ValueTask WaitAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
long now = Stopwatch.GetTimestamp();
long oldest = _sentAt[_index];
if (oldest != 0)
{
long elapsed = now - oldest;
if (elapsed < WindowTicks)
{
double waitSeconds = (WindowTicks - elapsed) / (double)Stopwatch.Frequency;
await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct).ConfigureAwait(false);
now = Stopwatch.GetTimestamp();
}
}
_sentAt[_index] = now;
_index = _index + 1 == _sentAt.Length ? 0 : _index + 1;
}
finally
{
_gate.Release();
}
}
}
@@ -1,350 +0,0 @@
using System.Text.Json;
using Encelado.Alpaca.Internal;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Rest;
public enum OrderStatus : byte
{
Unknown = 0,
New,
PendingNew,
Accepted,
AcceptedForBidding,
PartiallyFilled,
Filled,
DoneForDay,
Canceled,
PendingCancel,
Expired,
Replaced,
PendingReplace,
Rejected,
Suspended,
Stopped,
Calculated,
Held,
}
public static class OrderStatusParser
{
public static OrderStatus Parse(string? s) => s switch
{
"new" => OrderStatus.New,
"pending_new" => OrderStatus.PendingNew,
"accepted" => OrderStatus.Accepted,
"accepted_for_bidding" => OrderStatus.AcceptedForBidding,
"partially_filled" => OrderStatus.PartiallyFilled,
"filled" => OrderStatus.Filled,
"done_for_day" => OrderStatus.DoneForDay,
"canceled" => OrderStatus.Canceled,
"pending_cancel" => OrderStatus.PendingCancel,
"expired" => OrderStatus.Expired,
"replaced" => OrderStatus.Replaced,
"pending_replace" => OrderStatus.PendingReplace,
"rejected" => OrderStatus.Rejected,
"suspended" => OrderStatus.Suspended,
"stopped" => OrderStatus.Stopped,
"calculated" => OrderStatus.Calculated,
"held" => OrderStatus.Held,
_ => OrderStatus.Unknown,
};
/// <summary>True once the order can no longer change state.</summary>
public static bool IsTerminal(this OrderStatus s) =>
s is OrderStatus.Filled or OrderStatus.Canceled or OrderStatus.Expired
or OrderStatus.Rejected or OrderStatus.Replaced or OrderStatus.DoneForDay;
public static bool IsWorking(this OrderStatus s) =>
s is OrderStatus.New or OrderStatus.PendingNew or OrderStatus.Accepted
or OrderStatus.AcceptedForBidding or OrderStatus.PartiallyFilled
or OrderStatus.PendingCancel or OrderStatus.PendingReplace or OrderStatus.Held;
}
public sealed record AlpacaAccount(
string Id,
string AccountNumber,
string Status,
string Currency,
decimal Cash,
decimal Equity,
decimal LastEquity,
decimal BuyingPower,
decimal DaytradingBuyingPower,
decimal PortfolioValue,
decimal Multiplier,
int DaytradeCount,
bool PatternDayTrader,
bool TradingBlocked,
bool AccountBlocked,
bool TransfersBlocked,
bool TradeSuspendedByUser,
bool ShortingEnabled)
{
/// <summary>True when the broker will refuse new orders for any reason.</summary>
public bool CanTrade => !TradingBlocked && !AccountBlocked && !TradeSuspendedByUser &&
string.Equals(Status, "ACTIVE", StringComparison.OrdinalIgnoreCase);
public static AlpacaAccount FromJson(JsonElement e) => new(
e.StringOrEmpty("id"),
e.StringOrEmpty("account_number"),
e.StringOrEmpty("status"),
e.StringOrEmpty("currency"),
e.Decimal("cash"),
e.Decimal("equity"),
e.Decimal("last_equity"),
e.Decimal("buying_power"),
e.Decimal("daytrading_buying_power"),
e.Decimal("portfolio_value"),
e.Decimal("multiplier", 1),
e.Int32("daytrade_count"),
e.Bool("pattern_day_trader"),
e.Bool("trading_blocked"),
e.Bool("account_blocked"),
e.Bool("transfers_blocked"),
e.Bool("trade_suspended_by_user"),
e.Bool("shorting_enabled"));
}
public sealed record AlpacaPosition(
string Symbol,
string AssetClass,
double Quantity,
double AverageEntryPrice,
double CurrentPrice,
double MarketValue,
double UnrealizedPnl,
double UnrealizedPnlPct)
{
public static AlpacaPosition FromJson(JsonElement e)
{
double qty = e.Double("qty");
// Alpaca reports short positions with a negative qty already, but be explicit.
if (string.Equals(e.StringOrNull("side"), "short", StringComparison.OrdinalIgnoreCase) && qty > 0)
{
qty = -qty;
}
return new AlpacaPosition(
e.StringOrEmpty("symbol"),
e.StringOrEmpty("asset_class"),
qty,
e.Double("avg_entry_price"),
e.Double("current_price"),
e.Double("market_value"),
e.Double("unrealized_pl"),
e.Double("unrealized_plpc"));
}
}
public sealed record AlpacaOrder(
string Id,
string ClientOrderId,
string Symbol,
Side Side,
string Type,
string OrderClass,
OrderStatus Status,
double Quantity,
double FilledQuantity,
double FilledAveragePrice,
double LimitPrice,
double StopPrice,
DateTime SubmittedAtUtc,
DateTime? FilledAtUtc,
IReadOnlyList<AlpacaOrder> Legs)
{
private static readonly AlpacaOrder[] NoLegs = [];
public bool IsWorking => Status.IsWorking();
public static AlpacaOrder FromJson(JsonElement e)
{
AlpacaOrder[] legs = NoLegs;
if (e.TryGetProperty("legs", out JsonElement legsElement) && legsElement.ValueKind == JsonValueKind.Array)
{
int n = legsElement.GetArrayLength();
if (n > 0)
{
legs = new AlpacaOrder[n];
int i = 0;
foreach (JsonElement leg in legsElement.EnumerateArray())
{
legs[i++] = FromJson(leg);
}
}
}
return new AlpacaOrder(
e.StringOrEmpty("id"),
e.StringOrEmpty("client_order_id"),
e.StringOrEmpty("symbol"),
string.Equals(e.StringOrNull("side"), "sell", StringComparison.OrdinalIgnoreCase) ? Side.Sell : Side.Buy,
e.StringOrEmpty("type"),
e.StringOrEmpty("order_class"),
OrderStatusParser.Parse(e.StringOrNull("status")),
e.Double("qty"),
e.Double("filled_qty"),
e.Double("filled_avg_price"),
e.Double("limit_price", double.NaN),
e.Double("stop_price", double.NaN),
e.Timestamp("submitted_at"),
e.TimestampOrNull("filled_at"),
legs);
}
}
public sealed record AlpacaClock(
DateTime TimestampUtc,
bool IsOpen,
DateTime NextOpenUtc,
DateTime NextCloseUtc)
{
public static AlpacaClock FromJson(JsonElement e) => new(
e.Timestamp("timestamp"),
e.Bool("is_open"),
e.Timestamp("next_open"),
e.Timestamp("next_close"));
}
public sealed record AlpacaAsset(
string Symbol,
string Name,
string Exchange,
string Class,
string Status,
bool Tradable,
bool Marginable,
bool Shortable,
bool EasyToBorrow,
bool Fractionable,
double MinOrderSize,
double MinTradeIncrement,
double PriceIncrement)
{
public bool IsActive => Tradable && string.Equals(Status, "active", StringComparison.OrdinalIgnoreCase);
public static AlpacaAsset FromJson(JsonElement e) => new(
e.StringOrEmpty("symbol"),
e.StringOrEmpty("name"),
e.StringOrEmpty("exchange"),
e.StringOrEmpty("class"),
e.StringOrEmpty("status"),
e.Bool("tradable"),
e.Bool("marginable"),
e.Bool("shortable"),
e.Bool("easy_to_borrow"),
e.Bool("fractionable"),
e.Double("min_order_size"),
e.Double("min_trade_increment"),
e.Double("price_increment"));
}
/// <summary>
/// Account equity over time, straight from Alpaca. <see cref="BaseValue"/> is the
/// equity at the start of the requested window, so lifetime P&amp;L is
/// <c>Equity[^1] - BaseValue</c> — with the usual caveat that deposits and withdrawals
/// move equity without being profit.
/// </summary>
public sealed record AlpacaPortfolioHistory(
IReadOnlyList<long> TimestampsUnix,
IReadOnlyList<double> Equity,
IReadOnlyList<double> ProfitLoss,
double BaseValue,
string Timeframe)
{
public static readonly AlpacaPortfolioHistory Empty =
new([], [], [], 0, string.Empty);
public bool HasData => Equity.Count > 0;
public double LastEquity => Equity.Count > 0 ? Equity[^1] : 0;
/// <summary>Change over the whole window in absolute terms.</summary>
public double TotalProfitLoss => HasData && BaseValue > 0 ? LastEquity - BaseValue : 0;
public double TotalProfitLossPct => BaseValue > 0 ? TotalProfitLoss / BaseValue : 0;
public static AlpacaPortfolioHistory FromJson(JsonElement e)
{
return new AlpacaPortfolioHistory(
ReadLongs(e, "timestamp"),
ReadDoubles(e, "equity"),
ReadDoubles(e, "profit_loss"),
e.Double("base_value"),
e.StringOrEmpty("timeframe"));
static double[] ReadDoubles(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out JsonElement array) || array.ValueKind != JsonValueKind.Array)
{
return [];
}
double[] values = new double[array.GetArrayLength()];
int i = 0;
foreach (JsonElement item in array.EnumerateArray())
{
values[i++] = item.ValueKind == JsonValueKind.Number ? item.GetDouble() : 0;
}
return values;
}
static long[] ReadLongs(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out JsonElement array) || array.ValueKind != JsonValueKind.Array)
{
return [];
}
long[] values = new long[array.GetArrayLength()];
int i = 0;
foreach (JsonElement item in array.EnumerateArray())
{
values[i++] = item.ValueKind == JsonValueKind.Number ? item.GetInt64() : 0;
}
return values;
}
}
}
/// <summary>An order about to be submitted. Built by the execution router, never by strategies.</summary>
public sealed record NewOrder
{
public required string Symbol { get; init; }
public required Side Side { get; init; }
public required double Quantity { get; init; }
public OrderType Type { get; init; } = OrderType.Market;
public TimeInForce TimeInForce { get; init; } = TimeInForce.Day;
public double LimitPrice { get; init; } = double.NaN;
public double StopPrice { get; init; } = double.NaN;
/// <summary>Idempotency key. Alpaca rejects duplicates, which is exactly what we want on a retry.</summary>
public string? ClientOrderId { get; init; }
public bool ExtendedHours { get; init; }
/// <summary>Attached protective stop. Turns the order into a bracket/OTO order.</summary>
public double TakeProfitLimitPrice { get; init; } = double.NaN;
public double StopLossStopPrice { get; init; } = double.NaN;
public double StopLossLimitPrice { get; init; } = double.NaN;
public bool HasBracket => !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice);
/// <summary>Alpaca's <c>order_class</c> implied by the attached legs.</summary>
public string OrderClass =>
!double.IsNaN(TakeProfitLimitPrice) && !double.IsNaN(StopLossStopPrice) ? "bracket"
: !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice) ? "oto"
: "simple";
}
@@ -1,307 +0,0 @@
using System.Buffers;
using System.Globalization;
using System.Text.Json;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Rest;
/// <summary>
/// Typed wrapper over Alpaca's trading REST API (<c>/v2/account</c>, <c>/v2/orders</c>,
/// <c>/v2/positions</c>, …). Request bodies are written straight to UTF-8 with
/// <see cref="Utf8JsonWriter"/> — no serializer, no reflection, no per-order allocation
/// beyond a pooled buffer.
/// </summary>
public sealed class AlpacaTradingClient(AlpacaOptions options) : IDisposable
{
private readonly AlpacaHttp _http = new(options.Validate(), options.TradingBaseUrl);
public string BaseUrl { get; } = options.TradingBaseUrl;
public bool IsPaper { get; } = options.Paper;
/// <summary>Opens the TLS/HTTP2 connection before the session starts.</summary>
public Task WarmupAsync(CancellationToken ct) => _http.WarmupAsync("v2/clock", ct);
public async Task<AlpacaAccount> GetAccountAsync(CancellationToken ct)
{
using JsonDocument doc = await _http.GetAsync("v2/account", ct).ConfigureAwait(false);
return AlpacaAccount.FromJson(doc.RootElement);
}
public async Task<AlpacaClock> GetClockAsync(CancellationToken ct)
{
using JsonDocument doc = await _http.GetAsync("v2/clock", ct).ConfigureAwait(false);
return AlpacaClock.FromJson(doc.RootElement);
}
/// <summary>
/// Equity curve for the account. <paramref name="period"/> uses Alpaca's notation
/// (<c>1D</c>, <c>1M</c>, <c>1A</c>, <c>all</c>) and <paramref name="timeframe"/> the
/// bucket size (<c>1Min</c>, <c>15Min</c>, <c>1H</c>, <c>1D</c>).
/// </summary>
public async Task<AlpacaPortfolioHistory> GetPortfolioHistoryAsync(
string period,
string timeframe,
CancellationToken ct)
{
string path = $"v2/account/portfolio/history?period={Uri.EscapeDataString(period)}" +
$"&timeframe={Uri.EscapeDataString(timeframe)}&intraday_reporting=continuous";
try
{
using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false);
return AlpacaPortfolioHistory.FromJson(doc.RootElement);
}
catch (AlpacaApiException)
{
// History is a nice-to-have for the dashboard, never a reason to stop trading.
return AlpacaPortfolioHistory.Empty;
}
}
public async Task<AlpacaAsset?> GetAssetAsync(string symbol, CancellationToken ct)
{
using JsonDocument? doc = await _http.GetOrNullAsync($"v2/assets/{Uri.EscapeDataString(symbol)}", ct)
.ConfigureAwait(false);
return doc is null ? null : AlpacaAsset.FromJson(doc.RootElement);
}
public async Task<List<AlpacaPosition>> ListPositionsAsync(CancellationToken ct)
{
using JsonDocument doc = await _http.GetAsync("v2/positions", ct).ConfigureAwait(false);
List<AlpacaPosition> positions = [];
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement e in doc.RootElement.EnumerateArray())
{
positions.Add(AlpacaPosition.FromJson(e));
}
}
return positions;
}
public async Task<AlpacaPosition?> GetPositionAsync(string symbol, CancellationToken ct)
{
using JsonDocument? doc = await _http.GetOrNullAsync($"v2/positions/{Uri.EscapeDataString(symbol)}", ct)
.ConfigureAwait(false);
return doc is null ? null : AlpacaPosition.FromJson(doc.RootElement);
}
/// <summary>Liquidates a position at market. Alpaca cancels the open legs for us.</summary>
public async Task<AlpacaOrder?> ClosePositionAsync(string symbol, double? quantity, CancellationToken ct)
{
string path = $"v2/positions/{Uri.EscapeDataString(symbol)}";
if (quantity is > 0)
{
path += $"?qty={FormatQuantity(quantity.Value)}";
}
try
{
using JsonDocument doc = await _http.DeleteAsync(path, ct).ConfigureAwait(false);
return doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("id", out _)
? AlpacaOrder.FromJson(doc.RootElement)
: null;
}
catch (AlpacaApiException ex) when (ex.StatusCode == 404)
{
// Already flat: treat as success so the caller's exit path is idempotent.
return null;
}
}
public async Task CloseAllPositionsAsync(bool cancelOrders, CancellationToken ct)
{
using JsonDocument _ = await _http
.DeleteAsync($"v2/positions?cancel_orders={(cancelOrders ? "true" : "false")}", ct)
.ConfigureAwait(false);
}
public async Task<List<AlpacaOrder>> ListOrdersAsync(
string status,
int limit,
string? symbols,
CancellationToken ct)
{
string path = $"v2/orders?status={status}&limit={limit}&nested=true";
if (!string.IsNullOrWhiteSpace(symbols))
{
path += $"&symbols={Uri.EscapeDataString(symbols)}";
}
using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false);
List<AlpacaOrder> orders = [];
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement e in doc.RootElement.EnumerateArray())
{
orders.Add(AlpacaOrder.FromJson(e));
}
}
return orders;
}
public Task<List<AlpacaOrder>> ListOpenOrdersAsync(CancellationToken ct) =>
ListOrdersAsync("open", 500, null, ct);
public async Task<AlpacaOrder> SubmitOrderAsync(NewOrder order, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(order);
byte[] body = WriteOrderJson(order);
using JsonDocument doc = await _http.PostAsync("v2/orders", body, ct).ConfigureAwait(false);
return AlpacaOrder.FromJson(doc.RootElement);
}
/// <summary>Moves an open order's stop/limit — used to trail protective stops.</summary>
public async Task<AlpacaOrder> ReplaceOrderAsync(
string orderId,
double? quantity,
double? limitPrice,
double? stopPrice,
string? clientOrderId,
CancellationToken ct)
{
ArrayBufferWriter<byte> buffer = new(192);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
if (quantity is > 0)
{
w.WriteString("qty", FormatQuantity(quantity.Value));
}
if (limitPrice is > 0)
{
w.WriteString("limit_price", FormatPrice(limitPrice.Value));
}
if (stopPrice is > 0)
{
w.WriteString("stop_price", FormatPrice(stopPrice.Value));
}
if (!string.IsNullOrEmpty(clientOrderId))
{
w.WriteString("client_order_id", clientOrderId);
}
w.WriteEndObject();
}
using JsonDocument doc = await _http
.PatchAsync($"v2/orders/{Uri.EscapeDataString(orderId)}", buffer.WrittenMemory, ct)
.ConfigureAwait(false);
return AlpacaOrder.FromJson(doc.RootElement);
}
public async Task<bool> CancelOrderAsync(string orderId, CancellationToken ct)
{
try
{
using JsonDocument _ = await _http
.DeleteAsync($"v2/orders/{Uri.EscapeDataString(orderId)}", ct)
.ConfigureAwait(false);
return true;
}
catch (AlpacaApiException ex) when (ex.StatusCode is 404 or 422)
{
// 404 = gone, 422 = already in a terminal state. Both mean "not working any more".
return false;
}
}
public async Task CancelAllOrdersAsync(CancellationToken ct)
{
using JsonDocument _ = await _http.DeleteAsync("v2/orders", ct).ConfigureAwait(false);
}
/// <summary>Serialises an order to Alpaca's wire format. Public so it can be asserted on in tests.</summary>
public static byte[] WriteOrderJson(NewOrder order)
{
ArrayBufferWriter<byte> buffer = new(384);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("symbol", order.Symbol);
w.WriteString("qty", FormatQuantity(order.Quantity));
w.WriteString("side", order.Side.ToAlpaca());
w.WriteString("type", order.Type.ToAlpaca());
w.WriteString("time_in_force", order.TimeInForce.ToAlpaca());
if (order.Type is OrderType.Limit or OrderType.StopLimit && !double.IsNaN(order.LimitPrice))
{
w.WriteString("limit_price", FormatPrice(order.LimitPrice));
}
if (order.Type is OrderType.Stop or OrderType.StopLimit && !double.IsNaN(order.StopPrice))
{
w.WriteString("stop_price", FormatPrice(order.StopPrice));
}
if (!string.IsNullOrEmpty(order.ClientOrderId))
{
w.WriteString("client_order_id", order.ClientOrderId);
}
if (order.ExtendedHours)
{
w.WriteBoolean("extended_hours", true);
}
if (order.HasBracket)
{
w.WriteString("order_class", order.OrderClass);
if (!double.IsNaN(order.TakeProfitLimitPrice))
{
w.WriteStartObject("take_profit");
w.WriteString("limit_price", FormatPrice(order.TakeProfitLimitPrice));
w.WriteEndObject();
}
if (!double.IsNaN(order.StopLossStopPrice))
{
w.WriteStartObject("stop_loss");
w.WriteString("stop_price", FormatPrice(order.StopLossStopPrice));
if (!double.IsNaN(order.StopLossLimitPrice))
{
w.WriteString("limit_price", FormatPrice(order.StopLossLimitPrice));
}
w.WriteEndObject();
}
}
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
/// <summary>
/// Alpaca rejects prices that are not a valid sub-penny increment: two decimals at
/// or above $1, four decimals below it.
/// </summary>
public static string FormatPrice(double price)
{
double rounded = price >= 1.0
? Math.Round(price, 2, MidpointRounding.AwayFromZero)
: Math.Round(price, 4, MidpointRounding.AwayFromZero);
return rounded.ToString(price >= 1.0 ? "0.##" : "0.####", CultureInfo.InvariantCulture);
}
/// <summary>Whole shares stay integral; fractional sizes get at most 9 decimals.</summary>
public static string FormatQuantity(double quantity)
{
double abs = Math.Abs(quantity);
return abs == Math.Floor(abs)
? abs.ToString("0", CultureInfo.InvariantCulture)
: Math.Round(abs, 9, MidpointRounding.ToZero).ToString("0.#########", CultureInfo.InvariantCulture);
}
public void Dispose() => _http.Dispose();
}
@@ -1,486 +0,0 @@
using System.Buffers;
using System.Globalization;
using System.Text.Json;
using Encelado.Alpaca.Internal;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Streaming;
/// <summary>Which side crossed the spread on a print. Unknown when the feed omits it.</summary>
public enum Aggressor : byte
{
Unknown = 0,
Buy = 1,
Sell = 2,
}
public delegate void TickHandler(int symbolId, string symbol, in Tick tick, Aggressor aggressor);
public delegate void QuoteHandler(int symbolId, string symbol, in Quote quote);
public delegate void BarHandler(int symbolId, string symbol, in Bar bar);
/// <summary>
/// Alpaca's real-time market data socket. Frames are decoded straight out of the
/// receive buffer with <see cref="Utf8JsonReader"/> and symbols are resolved through
/// a <see cref="SymbolTable"/>, so a live tape produces no garbage per tick.
/// </summary>
public sealed class MarketDataStream : WebSocketChannel
{
private enum MsgKind : byte
{
Unknown = 0,
Trade,
Quote,
Bar,
UpdatedBar,
DailyBar,
Status,
Success,
Error,
Subscription,
}
private readonly byte[] _authPayload;
private readonly byte[] _subscribePayload;
private readonly SymbolTable _symbols;
private CancellationToken _channelToken;
public MarketDataStream(
AlpacaOptions options,
IReadOnlyList<string> symbols,
AssetClass assetClass,
bool subscribeTrades = true,
bool subscribeQuotes = true,
bool subscribeBars = true)
: base(options.MarketDataStreamUri(assetClass), $"data:{(assetClass == AssetClass.Crypto ? "crypto" : options.DataFeed)}")
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(symbols);
_symbols = new SymbolTable(symbols);
AssetClass = assetClass;
_authPayload = BuildAuth(options.KeyId, options.SecretKey);
_subscribePayload = BuildSubscribe(symbols, subscribeTrades, subscribeQuotes, subscribeBars);
}
public AssetClass AssetClass { get; }
public SymbolTable Symbols => _symbols;
/// <summary>Fired for every print on the tape.</summary>
public TickHandler? OnTrade { get; set; }
/// <summary>Fired on every top-of-book change.</summary>
public QuoteHandler? OnQuote { get; set; }
/// <summary>Fired when a minute bar closes — the engine's main decision trigger.</summary>
public BarHandler? OnBar { get; set; }
/// <summary>Fired for Alpaca's rolling daily bar.</summary>
public BarHandler? OnDailyBar { get; set; }
public long TradesReceived { get; private set; }
public long QuotesReceived { get; private set; }
public long BarsReceived { get; private set; }
protected override async ValueTask OnOpenAsync(CancellationToken ct)
{
_channelToken = ct;
// Alpaca accepts the auth frame immediately; the "connected" greeting and the
// "authenticated" acknowledgement both arrive on the receive loop.
await SendAsync(_authPayload, ct).ConfigureAwait(false);
}
protected override void OnMessage(ReadOnlySpan<byte> payload, bool isText)
{
if (!isText)
{
Log($"[{Name}] ignoring a binary frame ({payload.Length} bytes); expected JSON.");
return;
}
Utf8JsonReader reader = new(payload, isFinalBlock: true, state: default);
if (!reader.Read())
{
return;
}
if (reader.TokenType == JsonTokenType.StartArray)
{
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.StartObject)
{
DecodeObject(ref reader);
}
else
{
reader.Skip();
}
}
}
else if (reader.TokenType == JsonTokenType.StartObject)
{
DecodeObject(ref reader);
}
}
private void DecodeObject(ref Utf8JsonReader r)
{
MsgKind kind = MsgKind.Unknown;
int symbolId = -1;
double open = 0, high = 0, low = 0, close = 0, volume = 0, vwap = 0;
double price = 0, size = 0, bidPrice = 0, bidSize = 0, askPrice = 0, askSize = 0;
int tradeCount = 0;
DateTime timestamp = default;
string? message = null;
int code = 0;
Aggressor aggressor = Aggressor.Unknown;
while (r.Read() && r.TokenType != JsonTokenType.EndObject)
{
if (r.TokenType != JsonTokenType.PropertyName)
{
r.Skip();
continue;
}
if (r.ValueTextEquals("T"u8))
{
r.Read();
kind = ParseKind(r.ValueSpan);
}
else if (r.ValueTextEquals("S"u8))
{
r.Read();
symbolId = _symbols.Resolve(r.ValueSpan);
}
else if (r.ValueTextEquals("t"u8))
{
r.Read();
timestamp = Rfc3339.ParseUtc(r.ValueSpan);
}
else if (r.ValueTextEquals("p"u8))
{
r.Read();
price = ReadNumber(ref r);
}
else if (r.ValueTextEquals("s"u8))
{
r.Read();
size = ReadNumber(ref r);
}
else if (r.ValueTextEquals("bp"u8))
{
r.Read();
bidPrice = ReadNumber(ref r);
}
else if (r.ValueTextEquals("bs"u8))
{
r.Read();
bidSize = ReadNumber(ref r);
}
else if (r.ValueTextEquals("ap"u8))
{
r.Read();
askPrice = ReadNumber(ref r);
}
else if (r.ValueTextEquals("as"u8))
{
r.Read();
askSize = ReadNumber(ref r);
}
else if (r.ValueTextEquals("o"u8))
{
r.Read();
open = ReadNumber(ref r);
}
else if (r.ValueTextEquals("h"u8))
{
r.Read();
high = ReadNumber(ref r);
}
else if (r.ValueTextEquals("l"u8))
{
r.Read();
low = ReadNumber(ref r);
}
else if (r.ValueTextEquals("c"u8))
{
r.Read();
// On a bar "c" is the close; on a trade/quote it is the condition array.
if (r.TokenType == JsonTokenType.Number)
{
close = r.GetDouble();
}
else
{
r.Skip();
}
}
else if (r.ValueTextEquals("v"u8))
{
r.Read();
volume = ReadNumber(ref r);
}
else if (r.ValueTextEquals("vw"u8))
{
r.Read();
vwap = ReadNumber(ref r);
}
else if (r.ValueTextEquals("n"u8))
{
r.Read();
tradeCount = (int)ReadNumber(ref r);
}
else if (r.ValueTextEquals("tks"u8))
{
r.Read();
// Alpaca's crypto feed reports the taker side as "B" or "S". It is the
// only way to know whether a print lifted an offer or hit a bid, which
// is what the volume delta is built from.
if (r.TokenType == JsonTokenType.String && r.ValueSpan.Length > 0)
{
aggressor = r.ValueSpan[0] switch
{
(byte)'B' or (byte)'b' => Aggressor.Buy,
(byte)'S' or (byte)'s' => Aggressor.Sell,
_ => Aggressor.Unknown,
};
}
}
else if (r.ValueTextEquals("msg"u8))
{
r.Read();
message = r.TokenType == JsonTokenType.String ? r.GetString() : null;
}
else if (r.ValueTextEquals("code"u8))
{
r.Read();
code = (int)ReadNumber(ref r);
}
else
{
r.Read();
r.Skip();
}
}
Dispatch(kind, symbolId, timestamp, message, code,
open, high, low, close, volume, vwap, tradeCount,
price, size, bidPrice, bidSize, askPrice, askSize, aggressor);
}
private void Dispatch(
MsgKind kind, int symbolId, DateTime timestamp, string? message, int code,
double open, double high, double low, double close, double volume, double vwap, int tradeCount,
double price, double size, double bidPrice, double bidSize, double askPrice, double askSize,
Aggressor aggressor)
{
switch (kind)
{
case MsgKind.Trade when symbolId >= 0:
{
TradesReceived++;
Tick tick = new(timestamp, price, size);
OnTrade?.Invoke(symbolId, _symbols.Name(symbolId), in tick, aggressor);
break;
}
case MsgKind.Quote when symbolId >= 0:
{
QuotesReceived++;
Quote quote = new(timestamp, bidPrice, bidSize, askPrice, askSize);
OnQuote?.Invoke(symbolId, _symbols.Name(symbolId), in quote);
break;
}
case MsgKind.Bar when symbolId >= 0:
{
BarsReceived++;
Bar bar = new(timestamp, open, high, low, close, volume, vwap, tradeCount);
OnBar?.Invoke(symbolId, _symbols.Name(symbolId), in bar);
break;
}
case MsgKind.DailyBar when symbolId >= 0:
{
Bar bar = new(timestamp, open, high, low, close, volume, vwap, tradeCount);
OnDailyBar?.Invoke(symbolId, _symbols.Name(symbolId), in bar);
break;
}
case MsgKind.Success:
if (string.Equals(message, "authenticated", StringComparison.Ordinal))
{
Log($"[{Name}] authenticated; subscribing to {_symbols.Count} symbol(s)");
_ = SendSubscribeAsync();
}
else
{
Log($"[{Name}] {message}");
}
break;
case MsgKind.Subscription:
SetState(ChannelState.Live);
Log($"[{Name}] subscription confirmed");
break;
case MsgKind.Error:
OnServerError(code, message);
break;
case MsgKind.Status:
case MsgKind.UpdatedBar:
case MsgKind.Unknown:
default:
break;
}
}
/// <summary>
/// Turns an Alpaca stream error into either a refusal or a note.
/// <para>
/// The distinction is the whole point. Codes in the 400s here mean the server has
/// decided about this session: reconnecting straight away cannot change its mind,
/// and — because an unauthenticated socket keeps the account's single market-data
/// slot busy for ten seconds — trying again quickly is what keeps the refusal true.
/// Treating these as informational is what produced an endless connect / 406 /
/// auth-timeout loop that never recovered on its own.
/// </para>
/// </summary>
private void OnServerError(int code, string? message)
{
string? refusal = code switch
{
406 => "un'altra connessione sta già usando i dati di mercato di questo conto " +
"(Alpaca ne consente una sola). Chiudi l'altra istanza di Encelado, oppure " +
"attendi: una sessione interrotta male viene liberata dal server dopo poco.",
401 or 403 => "credenziali rifiutate dallo stream dati. Controlla le chiavi in " +
"Impostazioni e che siano quelle dell'ambiente giusto (paper o live).",
409 => "abbonamento dati insufficiente per i simboli richiesti.",
_ => null,
};
if (refusal is null)
{
Log($"[{Name}] server error {code}: {message}");
return;
}
Log($"[{Name}] {code}: {refusal}");
Reject(refusal);
}
private async Task SendSubscribeAsync()
{
try
{
await SendAsync(_subscribePayload, _channelToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log($"[{Name}] subscribe failed: {ex.Message}", ex);
}
}
private static double ReadNumber(ref Utf8JsonReader r) => r.TokenType switch
{
JsonTokenType.Number => r.GetDouble(),
JsonTokenType.String => double.TryParse(
r.ValueSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out double d) ? d : 0,
_ => 0,
};
private static MsgKind ParseKind(ReadOnlySpan<byte> value)
{
if (value.Length == 1)
{
return value[0] switch
{
(byte)'t' => MsgKind.Trade,
(byte)'q' => MsgKind.Quote,
(byte)'b' => MsgKind.Bar,
(byte)'u' => MsgKind.UpdatedBar,
(byte)'d' => MsgKind.DailyBar,
(byte)'s' => MsgKind.Status,
_ => MsgKind.Unknown,
};
}
if (value.SequenceEqual("success"u8))
{
return MsgKind.Success;
}
if (value.SequenceEqual("error"u8))
{
return MsgKind.Error;
}
return value.SequenceEqual("subscription"u8) ? MsgKind.Subscription : MsgKind.Unknown;
}
private static byte[] BuildAuth(string key, string secret)
{
ArrayBufferWriter<byte> buffer = new(160);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "auth");
w.WriteString("key", key);
w.WriteString("secret", secret);
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
private static byte[] BuildSubscribe(IReadOnlyList<string> symbols, bool trades, bool quotes, bool bars)
{
ArrayBufferWriter<byte> buffer = new(256);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "subscribe");
if (trades)
{
WriteArray(w, "trades", symbols);
}
if (quotes)
{
WriteArray(w, "quotes", symbols);
}
if (bars)
{
WriteArray(w, "bars", symbols);
}
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
static void WriteArray(Utf8JsonWriter w, string name, IReadOnlyList<string> values)
{
w.WriteStartArray(name);
foreach (string v in values)
{
w.WriteStringValue(v);
}
w.WriteEndArray();
}
}
}
@@ -1,62 +0,0 @@
namespace Encelado.Alpaca.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 (crypto pairs like BTC/USD included), 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,258 +0,0 @@
using System.Buffers;
using System.Text.Json;
using Encelado.Alpaca.Internal;
using Encelado.Alpaca.Rest;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Streaming;
/// <summary>One order lifecycle event pushed by Alpaca.</summary>
public sealed record TradeUpdate(
string Event,
DateTime TimestampUtc,
string Symbol,
Side Side,
double Price,
double Quantity,
double PositionQuantity,
AlpacaOrder Order)
{
/// <summary>True when shares actually changed hands.</summary>
public bool IsExecution => Event is "fill" or "partial_fill";
public bool IsTerminal => Event is "fill" or "canceled" or "expired" or "rejected" or "done_for_day";
}
/// <summary>
/// Order and position events straight from the broker, so the bot learns about fills
/// in milliseconds instead of polling. The reconciler still sweeps REST periodically:
/// this stream is the fast path, not the source of truth.
/// </summary>
public sealed class TradeUpdateStream : WebSocketChannel
{
private readonly byte[] _authPrimary;
private readonly byte[] _authAlternate;
private readonly byte[] _listenPayload;
private CancellationToken _channelToken;
private volatile bool _authorized;
private bool _warnedBinary;
public TradeUpdateStream(AlpacaOptions options)
: base(options.TradeUpdatesStreamUri, "trade-updates")
{
ArgumentNullException.ThrowIfNull(options);
_authPrimary = BuildEnvelopeAuth(options.KeyId, options.SecretKey);
_authAlternate = BuildFlatAuth(options.KeyId, options.SecretKey);
_listenPayload = BuildListen();
}
/// <summary>Raised for every order lifecycle event. Runs on the receive thread.</summary>
public Action<TradeUpdate>? OnTradeUpdate { get; set; }
public long UpdatesReceived { get; private set; }
protected override async ValueTask OnOpenAsync(CancellationToken ct)
{
_channelToken = ct;
_authorized = false;
// The documented handshake for the trading /stream endpoint.
await SendAsync(_authPrimary, ct).ConfigureAwait(false);
// Alpaca has shipped two auth shapes for this endpoint over the years. If the
// first one is not acknowledged shortly, try the other before giving up.
_ = FallbackAuthAsync();
}
private async Task FallbackAuthAsync()
{
try
{
await Task.Delay(TimeSpan.FromSeconds(3), _channelToken).ConfigureAwait(false);
if (!_authorized)
{
Log($"[{Name}] no auth acknowledgement yet; retrying with the alternate handshake");
await SendAsync(_authAlternate, _channelToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException)
{
// Socket closed while we were waiting; the reconnect loop takes over.
}
catch (Exception ex)
{
Log($"[{Name}] fallback auth failed: {ex.Message}", ex);
}
}
protected override void OnMessage(ReadOnlySpan<byte> payload, bool isText)
{
if (!isText)
{
if (!_warnedBinary)
{
_warnedBinary = true;
Log($"[{Name}] received a binary (msgpack) frame; falling back to REST reconciliation for fills.");
}
return;
}
JsonDocument doc;
try
{
Utf8JsonReader reader = new(payload, isFinalBlock: true, state: default);
doc = JsonDocument.ParseValue(ref reader);
}
catch (JsonException ex)
{
Log($"[{Name}] undecodable frame: {ex.Message}");
return;
}
using (doc)
{
JsonElement root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return;
}
string stream = root.StringOrEmpty("stream");
if (!root.TryGetProperty("data", out JsonElement data))
{
return;
}
switch (stream)
{
case "authorization":
HandleAuthorization(data);
break;
case "listening":
SetState(ChannelState.Live);
Log($"[{Name}] listening for trade updates");
break;
case "trade_updates":
HandleTradeUpdate(data);
break;
default:
break;
}
}
}
private void HandleAuthorization(JsonElement data)
{
string status = data.StringOrEmpty("status");
if (string.Equals(status, "authorized", StringComparison.OrdinalIgnoreCase))
{
_authorized = true;
Log($"[{Name}] authorized");
_ = SendListenAsync();
}
else
{
Log($"[{Name}] authorization refused: {status}");
}
}
private void HandleTradeUpdate(JsonElement data)
{
UpdatesReceived++;
AlpacaOrder order = data.TryGetProperty("order", out JsonElement orderElement) &&
orderElement.ValueKind == JsonValueKind.Object
? AlpacaOrder.FromJson(orderElement)
: new AlpacaOrder(string.Empty, string.Empty, string.Empty, Side.Buy, string.Empty, string.Empty,
OrderStatus.Unknown, 0, 0, 0, double.NaN, double.NaN, DateTime.MinValue, null, []);
DateTime timestamp = data.Timestamp("timestamp");
TradeUpdate update = new(
data.StringOrEmpty("event"),
timestamp == DateTime.MinValue ? DateTime.UtcNow : timestamp,
order.Symbol,
order.Side,
data.Double("price", order.FilledAveragePrice),
data.Double("qty"),
data.Double("position_qty"),
order);
try
{
OnTradeUpdate?.Invoke(update);
}
catch (Exception ex)
{
Log($"[{Name}] trade-update handler threw: {ex.Message}", ex);
}
}
private async Task SendListenAsync()
{
try
{
await SendAsync(_listenPayload, _channelToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log($"[{Name}] listen failed: {ex.Message}", ex);
}
}
/// <summary><c>{"action":"authenticate","data":{"key_id":…,"secret_key":…}}</c></summary>
private static byte[] BuildEnvelopeAuth(string key, string secret)
{
ArrayBufferWriter<byte> buffer = new(192);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "authenticate");
w.WriteStartObject("data");
w.WriteString("key_id", key);
w.WriteString("secret_key", secret);
w.WriteEndObject();
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
/// <summary><c>{"action":"auth","key":…,"secret":…}</c></summary>
private static byte[] BuildFlatAuth(string key, string secret)
{
ArrayBufferWriter<byte> buffer = new(160);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "auth");
w.WriteString("key", key);
w.WriteString("secret", secret);
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
private static byte[] BuildListen()
{
ArrayBufferWriter<byte> buffer = new(96);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "listen");
w.WriteStartObject("data");
w.WriteStartArray("streams");
w.WriteStringValue("trade_updates");
w.WriteEndArray();
w.WriteEndObject();
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
}
@@ -1,378 +0,0 @@
using System.Buffers;
using System.Net.WebSockets;
namespace Encelado.Alpaca.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;
public Uri Uri { get; } = 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. Alpaca permits one market-data connection
/// per account and closes an unauthenticated socket after ten seconds; a client that
/// reconnects on a three-second backoff therefore opens the next socket while the
/// refused one is still occupying the only slot, and refuses itself forever. Closing
/// 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);
_socket = new ClientWebSocket();
_socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(20);
ConfigureSocket(_socket.Options);
await _socket.ConnectAsync(Uri, ct).ConfigureAwait(false);
OnLog?.Invoke($"[{Name}] socket open -> {Uri}", null);
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>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>
/// Server-side timeout for an unauthenticated socket. Any backoff shorter than this
/// risks opening the next connection while the previous one still holds the
/// account's single market-data slot.
/// </summary>
private static readonly TimeSpan ServerAuthTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// 1s, 2s, 4s … capped at 60s, with jitter. Alpaca allows a single market-data
/// connection per account, so hammering reconnects just earns a 406.
/// <para>
/// Once the server has actually refused us, the floor rises above its own ten-second
/// timeout. Otherwise the client competes with its own dying socket for the one slot
/// available and can never win.
/// </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 async ValueTask DisposeAsync()
{
await StopAsync().ConfigureAwait(false);
_cts?.Dispose();
_sendGate.Dispose();
DisposeSocket();
GC.SuppressFinalize(this);
}
}
+98 -5
View File
@@ -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
@@ -54,16 +87,76 @@ public partial class App : Application
}
/// <summary>
/// The config lives next to the executable. Working directories vary (debugger,
/// shortcut, taskbar), so resolving it relative to the assembly is the only choice
/// that always finds the file.
/// 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>
/// <c>Documenti\Encelado\encelado.json</c>. It is the operator's file — their
/// thresholds, their pairs, their notes — so it belongs with their documents, where a
/// backup catches it and a reinstall cannot overwrite it. The credentials do
/// <b>not</b> live here: they stay encrypted in the per-user application data folder,
/// because a file in Documents is precisely the kind of file that gets copied to a
/// USB stick or synced to a cloud drive.
/// </para>
/// <para>
/// On first run the file is seeded from the copy shipped beside the executable when
/// there is one (the previous location, so an existing tuning is carried over rather
/// than lost) and from the built-in default otherwise.
/// </para>
/// </summary>
public static string ConfigDirectory =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Encelado");
private static string ResolveConfigPath()
{
string beside = Path.Combine(AppContext.BaseDirectory, "encelado.json");
return File.Exists(beside) ? beside : Path.GetFullPath("encelado.json");
string target = Path.Combine(ConfigDirectory, "encelado.json");
if (File.Exists(target))
{
return target;
}
Directory.CreateDirectory(ConfigDirectory);
string legacy = Path.Combine(AppContext.BaseDirectory, "encelado.json");
if (File.Exists(legacy))
{
File.Copy(legacy, target, overwrite: false);
SeedNote = $"configurazione copiata da {legacy} a {target}: da ora si modifica quella in Documenti";
}
else
{
File.WriteAllText(target, ConfigDefaults.Json);
SeedNote = $"nessuna configurazione trovata: creata quella di fabbrica in {target}";
}
return target;
}
/// <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)
{
Log.Error("UI exception", e.Exception);
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;
}
+539
View File
@@ -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 &lt;basket&gt;</c>, <c>kill</c>, <c>preset &lt;nome&gt;</c>,
/// <c>reset &lt;motivazione&gt;</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();
}
+388
View File
@@ -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,203 +1,168 @@
using Encelado.Alpaca;
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 Alpaca 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 AlpacaOptions Alpaca { 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="AlpacaOptions.KeyId"/>. 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 UiOptions Ui { get; set; } = new();
public List<SymbolConfig> Symbols { get; set; } = [];
public IEnumerable<SymbolConfig> EnabledSymbols => Symbols.Where(s => s.Enabled);
public BotConfig Validate()
{
Alpaca.Validate();
Risk.Validate();
Engine.Validate();
Etoro.Validate();
Run.Validate();
Ui.Validate();
Logging.Validate();
List<SymbolConfig> enabled = [.. EnabledSymbols];
if (enabled.Count == 0)
{
throw new InvalidOperationException("No enabled symbols in the configuration.");
}
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (SymbolConfig s in enabled)
{
if (string.IsNullOrWhiteSpace(s.Symbol))
{
throw new InvalidOperationException("A symbol entry has an empty 'symbol'.");
}
if (!seen.Add(s.Symbol))
{
throw new InvalidOperationException($"Symbol '{s.Symbol}' is configured more than once.");
}
// Il messaggio dice anche come uscirne. Questo caso capita quando un
// aggiornamento toglie una strategia e l'installazione conserva — a
// ragione — l'encelado.json dell'utente: senza l'indicazione, l'unica via
// d'uscita apparente è modificare il file a mano.
if (!StrategyFactory.IsKnown(s.Strategy))
{
throw new InvalidOperationException(
$"La strategia '{s.Strategy}' configurata su {s.Symbol} non esiste più.\n\n" +
$"Disponibili: {string.Join(", ", StrategyFactory.Available)}.\n\n" +
"Aprila da Impostazioni → Strategia e scegline una dall'elenco: " +
"capita dopo un aggiornamento, perché l'installazione non sovrascrive " +
"la tua configurazione.");
}
}
return this;
}
}
public sealed class EngineOptions
/// <summary>
/// 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 RunOptions
{
/// <summary><c>us_equity</c> or <c>crypto</c>. Crypto trades 24/7 and requires fractional sizes.</summary>
public string AssetClass { get; set; } = "us_equity";
/// <summary><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>Decision timeframe. Bars are consumed straight from the stream at 1Min.</summary>
public string TimeFrame { get; set; } = "1Min";
/// <summary>Required, together with the typed phrase <c>CONFERMO LIVE</c> at start, for <c>Live</c>.</summary>
public bool AllowLive { get; set; }
/// <summary>Historical bars pulled at startup to warm the indicators.</summary>
public int WarmupBars { get; set; } = 300;
/// <summary>Refuse new entries outside 09:3016:00 ET.</summary>
public bool TradeOnlyRegularHours { get; set; } = true;
/// <summary>Flatten everything this many minutes before the close. 0 disables.</summary>
public int FlattenBeforeCloseMinutes { get; set; } = 10;
public bool AllowFractionalShares { get; set; }
/// <summary>Attach take-profit/stop-loss legs server-side so exits survive a bot crash.</summary>
public bool UseBracketOrders { get; set; } = true;
/// <summary><c>market</c> or <c>limit</c>. A marketable limit caps slippage.</summary>
public string EntryOrderType { get; set; } = "limit";
/// <summary>How far through the touch a marketable limit is priced, in basis points.</summary>
public double LimitOffsetBps { get; set; } = 5;
/// <summary>Log decisions but never send an order. The safest way to observe a new config.</summary>
public bool DryRun { get; set; }
public int ReconcileSeconds { get; set; } = 30;
/// <summary>Seconds between two quote polls (2-30; each poll is one request for all instruments).</summary>
public int PollSeconds { get; set; } = 3;
/// <summary>Seconds between two status lines in the log and on the console.</summary>
public int StatusSeconds { get; set; } = 60;
/// <summary>
/// How often the engine re-reads what each strategy would do at the current price and
/// writes it to the log when it has changed. This is the heartbeat that makes a
/// patient bot distinguishable from a stuck one.
/// </summary>
public int ExplainSeconds { get; set; } = 5;
/// <summary>Reject entries when top-of-book is older than this. 0 disables the check.</summary>
public int MaxQuoteAgeSeconds { get; set; } = 30;
/// <summary>Liquidate everything when the bot shuts down.</summary>
/// <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 AssetClass ResolvedAssetClass =>
AssetClass.Trim().ToLowerInvariant() is "crypto" or "us_crypto"
? Core.Market.AssetClass.Crypto
: Core.Market.AssetClass.UsEquity;
public string StrategyFile { get; set; } = "strategy.json";
public TimeFrame ResolvedTimeFrame =>
TimeFrameExtensions.TryParse(TimeFrame, out TimeFrame tf) ? tf : Core.Market.TimeFrame.OneMinute;
public string DataDirectory { get; set; } = "data";
public bool UseLimitEntries =>
EntryOrderType.Trim().Equals("limit", StringComparison.OrdinalIgnoreCase);
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()
{
if (!TimeFrameExtensions.TryParse(TimeFrame, out _))
if (!ExecutionModeExtensions.TryParse(ExecutionMode, out ExecutionMode mode))
{
throw new InvalidOperationException($"engine.timeFrame '{TimeFrame}' is not supported.");
throw new InvalidOperationException("run.executionMode deve essere Paper, Demo oppure Live.");
}
if (EntryOrderType.Trim() is not ("limit" or "market"))
if (mode == Core.Baskets.ExecutionMode.Backtest)
{
throw new InvalidOperationException("engine.entryOrderType must be 'limit' or 'market'.");
throw new InvalidOperationException("run.executionMode 'Backtest' non è una modalità del bot: il backtest si lancia dallo strumento di ricerca.");
}
if (WarmupBars is < 0 or > 10_000)
if (mode.IsLive() && !AllowLive)
{
throw new InvalidOperationException("engine.warmupBars must be between 0 and 10000.");
throw new InvalidOperationException("la modalità Live richiede run.allowLive = true (e la frase CONFERMO LIVE all'avvio).");
}
if (LimitOffsetBps is < 0 or > 500)
if (PollSeconds is < 2 or > 30)
{
throw new InvalidOperationException("engine.limitOffsetBps must be between 0 and 500.");
throw new InvalidOperationException("run.pollSeconds deve essere fra 2 e 30.");
}
if (ReconcileSeconds < 5)
if (StatusSeconds is < 10 or > 3600)
{
throw new InvalidOperationException("engine.reconcileSeconds must be at least 5.");
throw new InvalidOperationException("run.statusSeconds deve essere fra 10 e 3600.");
}
if (ResolvedAssetClass == Core.Market.AssetClass.Crypto && !AllowFractionalShares)
if (PaperStartingBalance is <= 0 or > 1e9)
{
throw new InvalidOperationException(
"engine.allowFractionalShares must be true when engine.assetClass is 'crypto'.");
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.");
}
}
}
/// <summary>Settings for the web dashboard served by the <c>ui</c> command.</summary>
/// <summary>What the window shows and how. Nothing here changes what the bot does.</summary>
public sealed class UiOptions
{
/// <summary>The name that means "the computer's own time zone".</summary>
public const string ComputerZone = "computer";
/// <summary>
/// Where the dashboard listens. Use <c>http://0.0.0.0:5088</c> to reach it from
/// another machine — there is no authentication, so only do that on a trusted LAN.
/// <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 string Url { get; set; } = "http://localhost:5088";
public string TimeZone { get; set; } = ComputerZone;
/// <summary>Begin trading as soon as the dashboard starts, without pressing START.</summary>
public bool AutoStartBot { get; set; }
/// <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;
}
public bool OpenBrowser { get; set; } = true;
if (id.Equals("utc", StringComparison.OrdinalIgnoreCase))
{
return TimeZoneInfo.Utc;
}
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 (!Uri.TryCreate(Url, UriKind.Absolute, out Uri? parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException($"ui.url '{Url}' is not a valid http(s) URL.");
}
TimeZone ??= ComputerZone;
}
}
@@ -205,131 +170,77 @@ public sealed class LoggingOptions
{
/// <summary>
/// Verbosity: <c>trace</c>, <c>debug</c>, <c>info</c>, <c>warn</c>, <c>error</c> or
/// <c>none</c>. <c>debug</c> adds every rejected signal and risk refusal;
/// <c>trace</c> adds per-quote detail and is very noisy.
/// <c>none</c>. Every refusal that stops an order is written at <c>info</c> or above,
/// so <c>debug</c> is for the market-data path rather than for finding out why the
/// bot did not trade.
/// </summary>
public string Level { get; set; } = "info";
public bool Console { get; set; }
/// <summary>
/// Folder that holds every output file. Relative paths resolve against the
/// executable's directory, so the app writes to the same place regardless of where
/// it was launched from. 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>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 JSON line per order event. Empty disables it.</summary>
public string TradeJournal { get; set; } = "trades.jsonl";
/// <summary>
/// One CSV row per evaluated bar, per symbol, with the full market state, every
/// indicator the strategy exposes, the position, and the resulting signal. This is
/// the dataset to analyse when tuning the model. Empty disables it.
/// </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 and trade tick. Produces enormous files and is only useful when
/// diagnosing the market-data path itself.
/// </summary>
public bool LogMarketData { get; set; }
/// <summary>
/// Lines kept in the activity strip on the status page. Small on purpose: that panel
/// is glanced at, not read, and every line held there is a live WPF visual.
/// </summary>
/// <summary>
/// Log every incoming bar from the stream, not only the ones that close a strategy
/// bucket. On a daily timeframe this is the difference between a log that shows the
/// market moving and one that shows nothing for twenty-four hours.
/// </summary>
public bool LogEveryBar { get; set; } = true;
/// <summary>Lines kept in the activity strip 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: at the
/// default it is a few megabytes. It is deliberately not unbounded — a bot left
/// running for a week at <c>debug</c> would otherwise grow without limit. The file
/// on disk stays complete regardless, and the page can open it.
/// </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(AppContext.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()
{
if (MaxFileSizeMb is < 0 or > 4096)
{
throw new InvalidOperationException("logging.maxFileSizeMb must be between 0 and 4096.");
throw new InvalidOperationException("logging.maxFileSizeMb deve essere fra 0 e 4096.");
}
if (MaxFiles is < 1 or > 500)
{
throw new InvalidOperationException("logging.maxFiles must be between 1 and 500.");
throw new InvalidOperationException("logging.maxFiles deve essere fra 1 e 500.");
}
if (StatusLines is < 20 or > 5_000)
{
throw new InvalidOperationException("logging.statusLines must be between 20 and 5000.");
throw new InvalidOperationException("logging.statusLines deve essere fra 20 e 5000.");
}
// The ceiling is a memory guard, not a preference: each buffered line is a live
// object plus, once scrolled into view, a WPF visual.
if (BufferedLines is < 100 or > 200_000)
{
throw new InvalidOperationException("logging.bufferedLines must be between 100 and 200000.");
throw new InvalidOperationException("logging.bufferedLines deve essere fra 100 e 200000.");
}
if (BufferedLines < StatusLines)
{
throw new InvalidOperationException(
"logging.bufferedLines must be >= logging.statusLines: the log page cannot hold " +
"less history than the status strip.");
throw new InvalidOperationException("logging.bufferedLines deve essere >= logging.statusLines.");
}
}
}
public sealed class SymbolConfig
{
public string Symbol { get; set; } = string.Empty;
public string Strategy { get; set; } = "ema-cross";
public bool Enabled { get; set; } = true;
public Dictionary<string, double> Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public StrategyParameters ToStrategyParameters() => new(Parameters);
}
@@ -0,0 +1,155 @@
using System.Globalization;
namespace Encelado.Bot.Configuration;
/// <summary>
/// The factory configuration, and the ability to go back to it.
/// <para>
/// The default lives here as text rather than as a set of property assignments, and the
/// shipped <c>config/encelado.json</c> is a copy of this string. That is deliberate: the
/// file is more than its values — the <c>_</c>-prefixed lines explain what every number
/// is for and why it has the value it has, and a "restore defaults" that rebuilt the file
/// from object defaults would silently throw all of that away. 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
{
/// <summary>Extension given to the copy taken before a restore.</summary>
public const string BackupSuffix = ".bak";
/// <summary>
/// Rewrites <paramref name="configPath"/> with the factory configuration, after
/// moving whatever was there to a timestamped backup beside it. 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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configPath);
string? backup = null;
if (File.Exists(configPath))
{
backup = string.Create(CultureInfo.InvariantCulture,
$"{configPath}.{DateTime.Now:yyyyMMdd-HHmmss}{BackupSuffix}");
File.Copy(configPath, backup, overwrite: true);
}
string? directory = Path.GetDirectoryName(Path.GetFullPath(configPath));
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// Written to a temporary file and moved into place, so an interrupted write
// cannot leave a half-file the application then refuses to start from.
string temporary = configPath + ".tmp";
File.WriteAllText(temporary, Json);
File.Move(temporary, configPath, overwrite: true);
return backup;
}
/// <summary>Loads the factory values into <paramref name="config"/> in memory, without touching the disk.</summary>
public static void ApplyTo(BotConfig config)
{
ArgumentNullException.ThrowIfNull(config);
BotConfig factory = Parse();
config.Run = factory.Run;
config.Ui = factory.Ui;
config.Logging = factory.Logging;
// 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.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>
public static BotConfig Parse()
{
string temporary = Path.Combine(Path.GetTempPath(), $"encelado-default-{Guid.NewGuid():N}.json");
try
{
File.WriteAllText(temporary, Json);
return ConfigLoader.Load(temporary, out _);
}
finally
{
try
{
File.Delete(temporary);
}
catch (IOException)
{
// A leftover in the temp folder is harmless.
}
}
}
/// <summary>The factory <c>encelado.json</c>, byte for byte what ships in <c>config/</c>.</summary>
public const string Json = """
{
"_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.",
"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
},
"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,
"_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,
"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
},
"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. 'info' basta: ogni rifiuto che impedisce un ordine viene scritto a questo livello o sopra, con il basket e il motivo esatto.",
"level": "info",
"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,
"_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
}
}
""";
}
@@ -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 &lt; environment variables. Credentials should live in the
/// environment (or a gitignored local file), never in the committed config.
/// Precedence: file &lt; local overlay &lt; environment variables. Credentials live in
/// the encrypted store or in the environment, never in the committed config.
/// </para>
/// </summary>
public static class ConfigLoader
@@ -20,11 +21,26 @@ 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 = [];
BotConfig config = new();
// Relative output paths anchor to the configuration's own folder, so a config in
// Documents keeps its logs beside it instead of beside the executable.
string? folder = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(folder))
{
config.Logging.BaseDirectory = folder;
config.Run.BaseDirectory = folder;
}
if (File.Exists(path))
{
using FileStream stream = File.OpenRead(path);
@@ -33,10 +49,11 @@ public static class ConfigLoader
}
else
{
warnings.Add($"config file '{path}' not found; using defaults plus environment variables");
warnings.Add($"configurazione '{path}' non trovata; uso i valori di fabbrica e le variabili d'ambiente");
ConfigDefaults.ApplyTo(config);
}
// A sibling *.local.json overlays secrets and machine-specific overrides.
// A sibling *.local.json overlays machine-specific overrides.
string localPath = Path.ChangeExtension(path, null) + ".local.json";
if (File.Exists(localPath))
{
@@ -46,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;
}
@@ -53,7 +77,7 @@ public static class ConfigLoader
{
if (root.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException("The configuration root must be a JSON object.");
throw new InvalidOperationException("La radice della configurazione deve essere un oggetto JSON.");
}
foreach (JsonProperty section in root.EnumerateObject())
@@ -65,109 +89,102 @@ public static class ConfigLoader
switch (section.Name.ToLowerInvariant())
{
case "alpaca":
ReadAlpaca(config, section.Value, warnings);
case "etoro":
ReadEtoro(config, section.Value, warnings);
break;
case "engine":
ReadEngine(config, section.Value, warnings);
break;
case "risk":
ReadRisk(config, section.Value, warnings);
break;
case "logging":
ReadLogging(config, section.Value, warnings);
case "run":
ReadRun(config, section.Value, warnings);
break;
case "ui":
ReadUi(config, section.Value, warnings);
break;
case "symbols":
ReadSymbols(config, section.Value, warnings);
case "logging":
ReadLogging(config, section.Value, warnings);
break;
case "$schema":
case "_comment":
break;
default:
warnings.Add($"unknown config section '{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 ReadAlpaca(BotConfig config, JsonElement e, List<string> warnings)
private static void ReadEtoro(BotConfig config, JsonElement e, List<string> warnings)
{
foreach (JsonProperty p in Properties(e, "alpaca", warnings))
Etoro.EtoroOptions o = config.Etoro;
foreach (JsonProperty p in Properties(e, "etoro", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "keyid": config.Alpaca.KeyId = Str(p); break;
case "secretkey": config.Alpaca.SecretKey = Str(p); break;
case "paper": config.Alpaca.Paper = Bool(p); break;
case "datafeed": config.Alpaca.DataFeed = Str(p); break;
case "tradingbaseurl": config.Alpaca.TradingBaseUrlOverride = Str(p); break;
case "databaseurl": config.Alpaca.DataBaseUrlOverride = Str(p); break;
case "requestsperminute": config.Alpaca.RequestsPerMinute = Int(p); break;
case "httptimeoutseconds": config.Alpaca.HttpTimeout = TimeSpan.FromSeconds(Num(p)); break;
case "maxretries": config.Alpaca.MaxRetries = Int(p); break;
default: warnings.Add($"unknown key 'alpaca.{p.Name}'"); break;
case "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 "assetclass": o.AssetClass = Str(p); break;
case "timeframe": o.TimeFrame = Str(p); break;
case "warmupbars": o.WarmupBars = Int(p); break;
case "tradeonlyregularhours": o.TradeOnlyRegularHours = Bool(p); break;
case "flattenbeforeclosminutes":
case "flattenbeforecloseminutes": o.FlattenBeforeCloseMinutes = Int(p); break;
case "allowfractionalshares": o.AllowFractionalShares = Bool(p); break;
case "usebracketorders": o.UseBracketOrders = Bool(p); break;
case "entryordertype": o.EntryOrderType = Str(p); break;
case "limitoffsetbps": o.LimitOffsetBps = Num(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;
default: warnings.Add($"unknown key '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 "maxriskpertradepct": r.MaxRiskPerTradePct = Num(p); break;
case "stakepct": r.StakePct = Num(p); break;
case "stakeamount": r.StakeAmount = Num(p); break;
case "maxpositionnotionalpct": r.MaxPositionNotionalPct = Num(p); break;
case "maxgrossexposurepct": r.MaxGrossExposurePct = Num(p); break;
case "maxopenpositions": r.MaxOpenPositions = Int(p); break;
case "maxtradesperday": r.MaxTradesPerDay = Int(p); break;
case "maxtradespersymbolperday": r.MaxTradesPerSymbolPerDay = Int(p); break;
case "maxdailylosspct": r.MaxDailyLossPct = Num(p); break;
case "maxdailyprofitpct": r.MaxDailyProfitPct = Num(p); break;
case "minsecondsbetweenentries": r.MinSecondsBetweenEntries = Int(p); break;
case "maxrelativespread": r.MaxRelativeSpread = Num(p); break;
case "minprice": r.MinPrice = Num(p); break;
case "maxprice": r.MaxPrice = Num(p); break;
case "minordernotional": r.MinOrderNotional = Num(p); break;
case "maxordernotional": r.MaxOrderNotional = Num(p); break;
case "allowshorting": r.AllowShorting = Bool(p); break;
case "defaultstoppct": r.DefaultStopPct = Num(p); break;
case "maxstopdistancepct": r.MaxStopDistancePct = Num(p); break;
default: warnings.Add($"unknown key 'risk.{p.Name}'"); break;
case "timezone": o.TimeZone = Str(p); break;
default: warnings.Add($"chiave sconosciuta 'ui.{p.Name}'"); break;
}
}
}
@@ -183,143 +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 "logeverybar": o.LogEveryBar = Bool(p); break;
case "statuslines": o.StatusLines = Int(p); break;
case "bufferedlines": o.BufferedLines = Int(p); break;
default: warnings.Add($"unknown key 'logging.{p.Name}'"); break;
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 ReadUi(BotConfig config, JsonElement e, List<string> warnings)
{
UiOptions o = config.Ui;
foreach (JsonProperty p in Properties(e, "ui", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "url": o.Url = Str(p); break;
case "autostartbot": o.AutoStartBot = Bool(p); break;
case "openbrowser": o.OpenBrowser = Bool(p); break;
default: warnings.Add($"unknown key 'ui.{p.Name}'"); break;
}
}
}
private static void ReadSymbols(BotConfig config, JsonElement e, List<string> warnings)
{
if (e.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("'symbols' must be an array.");
}
config.Symbols.Clear();
foreach (JsonElement item in e.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
config.Symbols.Add(new SymbolConfig { Symbol = item.GetString() ?? string.Empty });
continue;
}
if (item.ValueKind != JsonValueKind.Object)
{
warnings.Add("ignoring a non-object entry in 'symbols'");
continue;
}
SymbolConfig sc = new();
foreach (JsonProperty p in item.EnumerateObject())
{
if (p.Name.StartsWith('_'))
{
continue;
}
switch (p.Name.ToLowerInvariant())
{
case "symbol": sc.Symbol = Str(p); break;
case "strategy": sc.Strategy = Str(p); break;
case "enabled": sc.Enabled = Bool(p); break;
case "parameters" or "params":
if (p.Value.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty kv in p.Value.EnumerateObject())
{
if (kv.Name.StartsWith('_'))
{
continue;
}
sc.Parameters[kv.Name] = kv.Value.ValueKind switch
{
JsonValueKind.Number => kv.Value.GetDouble(),
JsonValueKind.True => 1,
JsonValueKind.False => 0,
JsonValueKind.String when double.TryParse(
kv.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture,
out double parsed) => parsed,
_ => 0,
};
}
}
break;
default: warnings.Add($"unknown key 'symbols[].{p.Name}'"); break;
}
}
config.Symbols.Add(sc);
}
}
private static void ApplyEnvironment(BotConfig config)
{
// Alpaca's own variable names, so existing tooling keeps working.
bool fromEnvironment = false;
string? key = Environment.GetEnvironmentVariable("APCA_API_KEY_ID");
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.Alpaca.KeyId = key.Trim();
fromEnvironment = true;
config.Etoro.ApiKey = apiKey.Trim();
config.Etoro.UserKey = userKey.Trim();
}
string? secret = Environment.GetEnvironmentVariable("APCA_API_SECRET_KEY");
if (!string.IsNullOrWhiteSpace(secret))
string? etoroEnvironment = Environment.GetEnvironmentVariable("ETORO_ENVIRONMENT");
if (!string.IsNullOrWhiteSpace(etoroEnvironment))
{
config.Alpaca.SecretKey = secret.Trim();
fromEnvironment = true;
config.Etoro.Environment = etoroEnvironment.Trim().ToLowerInvariant();
}
bool haveBoth =
!string.IsNullOrWhiteSpace(config.Alpaca.KeyId) &&
!string.IsNullOrWhiteSpace(config.Alpaca.SecretKey);
config.CredentialOrigin = haveBoth
? fromEnvironment ? CredentialSource.Environment : CredentialSource.ConfigFile
: CredentialSource.None;
if (TryEnvBool("ENCELADO_PAPER", out bool paper))
string? executionMode = Environment.GetEnvironmentVariable("ENCELADO_EXECUTION_MODE");
if (!string.IsNullOrWhiteSpace(executionMode))
{
config.Alpaca.Paper = paper;
}
if (TryEnvBool("ENCELADO_DRY_RUN", out bool dryRun))
{
config.Engine.DryRun = dryRun;
}
string? feed = Environment.GetEnvironmentVariable("ENCELADO_DATA_FEED");
if (!string.IsNullOrWhiteSpace(feed))
{
config.Alpaca.DataFeed = feed.Trim();
config.Run.ExecutionMode = ExecutionModeExtensions.TryParse(executionMode, out ExecutionMode m) ? m.ToString() : executionMode.Trim();
}
string? level = Environment.GetEnvironmentVariable("ENCELADO_LOG_LEVEL");
@@ -327,27 +240,19 @@ 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)
{
if (e.ValueKind != JsonValueKind.Object)
{
warnings.Add($"'{section}' must be an object; ignored");
warnings.Add($"'{section}' deve essere un oggetto; ignorata");
yield break;
}
@@ -376,7 +281,7 @@ public static class ConfigLoader
p.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) => d,
JsonValueKind.True => 1,
JsonValueKind.False => 0,
_ => throw new InvalidOperationException($"'{p.Name}' must be a number."),
_ => throw new InvalidOperationException($"'{p.Name}' deve essere un numero."),
};
private static int Int(JsonProperty p) => (int)Math.Round(Num(p));
@@ -386,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}' must be a boolean."),
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,123 +0,0 @@
using Encelado.Alpaca;
using Encelado.Alpaca.Rest;
namespace Encelado.Bot.Configuration;
/// <summary>Outcome of trying to find usable Alpaca 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 the
/// broker 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.Alpaca.KeyId));
}
if (CredentialStore.Load(config.Alpaca.Paper) is { } saved)
{
config.Alpaca.KeyId = saved.KeyId;
config.Alpaca.SecretKey = saved.SecretKey;
config.CredentialOrigin = CredentialSource.SavedStore;
return new CredentialLookup(true, CredentialSource.SavedStore, CredentialStore.Mask(saved.KeyId));
}
if (config.CredentialOrigin == CredentialSource.ConfigFile)
{
return new CredentialLookup(true, CredentialSource.ConfigFile,
CredentialStore.Mask(config.Alpaca.KeyId));
}
return new CredentialLookup(false, CredentialSource.None, string.Empty);
}
/// <summary>
/// Confirms a key pair actually works by asking Alpaca for the account. Returns the
/// account on success so the caller can show who just logged in.
/// </summary>
public static async Task<(bool Ok, string Message, AlpacaAccount? Account)> VerifyAsync(
string keyId,
string secretKey,
bool paper,
CancellationToken ct)
{
AlpacaOptions probe = new()
{
KeyId = keyId,
SecretKey = secretKey,
Paper = paper,
HttpTimeout = TimeSpan.FromSeconds(20),
MaxRetries = 1,
};
try
{
probe.Validate();
}
catch (InvalidOperationException ex)
{
return (false, ex.Message, null);
}
try
{
using AlpacaTradingClient client = new(probe);
AlpacaAccount account = await client.GetAccountAsync(ct).ConfigureAwait(false);
return (true, $"Conto {account.AccountNumber} — {account.Status}", account);
}
catch (AlpacaApiException ex) when (ex.StatusCode is 401 or 403)
{
string hint = paper && keyId.StartsWith("AK", StringComparison.OrdinalIgnoreCase)
? " Sembra una chiave LIVE ma l'app è impostata su paper."
: !paper && keyId.StartsWith("PK", StringComparison.OrdinalIgnoreCase)
? " Sembra una chiave PAPER ma l'app è impostata su live."
: string.Empty;
return (false, $"Alpaca ha rifiutato le credenziali (HTTP {ex.StatusCode}).{hint}", null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return (false, $"Impossibile contattare Alpaca: {ex.Message}", null);
}
}
/// <summary>Stores a verified key pair and points the config at it.</summary>
public static void Apply(BotConfig config, string keyId, string secretKey, bool save)
{
ArgumentNullException.ThrowIfNull(config);
config.Alpaca.KeyId = keyId;
config.Alpaca.SecretKey = secretKey;
config.CredentialOrigin = CredentialSource.Interactive;
if (save)
{
CredentialStore.Save(config.Alpaca.Paper, keyId, secretKey);
config.CredentialOrigin = CredentialSource.SavedStore;
}
}
}
@@ -1,282 +0,0 @@
using System.Buffers;
using System.Security.Cryptography;
using System.Text.Json;
namespace Encelado.Bot.Configuration;
/// <summary>Credentials plus a human-readable note about where they came from.</summary>
public readonly record struct StoredCredentials(string KeyId, string SecretKey);
/// <summary>
/// Persists Alpaca API keys outside the repository, per user and per environment
/// (paper keys and live keys are different keys, so they are stored separately).
/// <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 PaperKey = "paper";
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 paper)
{
Dictionary<string, StoredCredentials> all = LoadAll();
return all.TryGetValue(paper ? PaperKey : LiveKey, out StoredCredentials found) ? found : null;
}
public static void Save(bool paper, string keyId, string secretKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentException.ThrowIfNullOrWhiteSpace(secretKey);
Dictionary<string, StoredCredentials> all = LoadAll();
all[paper ? PaperKey : LiveKey] = new StoredCredentials(keyId.Trim(), secretKey.Trim());
Write(all);
}
/// <summary>Removes the credentials for one environment. Returns whether anything was removed.</summary>
public static bool Clear(bool paper)
{
Dictionary<string, StoredCredentials> all = LoadAll();
if (!all.Remove(paper ? PaperKey : 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 four characters survive — enough to tell
/// a paper key (<c>PK…</c>) from a live one (<c>AK…</c>) and to recognise which key
/// is loaded, without putting anything reusable into a log file that may be shared.
/// </summary>
public static string Mask(string? value)
{
if (string.IsNullOrEmpty(value))
{
return "(empty)";
}
if (value.Length <= 4)
{
return new string('*', value.Length);
}
return value[..4] + new string('*', Math.Min(12, value.Length - 4));
}
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? keyId = entry.Value.TryGetProperty("keyId", out JsonElement k) ? k.GetString() : null;
string? secret = entry.Value.TryGetProperty("secretKey", out JsonElement s) ? s.GetString() : null;
if (!string.IsNullOrWhiteSpace(keyId) && !string.IsNullOrWhiteSpace(secret))
{
result[entry.Name] = new StoredCredentials(keyId, 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("keyId", credentials.KeyId);
w.WriteString("secretKey", credentials.SecretKey);
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,358 +0,0 @@
using System.Globalization;
using System.Text;
using Encelado.Bot.Configuration;
using Encelado.Bot.Logging;
using Encelado.Core.Market;
using Encelado.Core.Portfolio;
using Encelado.Core.Strategies;
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 evaluated bar per symbol: the bar itself
/// including the aggressor breakdown, every indicator the strategy publishes, the
/// position at the time, and the signal that came out. This is the dataset to load
/// into pandas when asking "why did it do that" or "would a different threshold have
/// helped".</item>
/// <item><b>executions</b> — one row per signal that reached the order path: the risk
/// verdict, the size that survived it, and the broker's answer.</item>
/// </list>
/// <para>
/// CSV rather than JSON on purpose: it opens in Excel, loads in one line of pandas, and
/// stays readable when a run produces tens of thousands of rows. Writes are buffered and
/// flushed on a timer, so the decision path never waits on the disk.
/// </para>
/// </summary>
public sealed class AnalyticsLog : IDisposable
{
private readonly StreamWriter? _decisions;
private readonly StreamWriter? _executions;
private readonly Lock _gate = new();
private readonly StringBuilder _row = new(512);
private string[] _metricNames = [];
private bool _decisionHeaderWritten;
private long _nextId;
private DateTime _lastFlush = DateTime.UtcNow;
public AnalyticsLog(LoggingOptions options)
{
ArgumentNullException.ThrowIfNull(options);
_decisions = Open(options.ResolvePath(options.DecisionLog));
_executions = Open(options.ResolvePath(options.ExecutionLog));
if (_executions is not null && _executions.BaseStream.Length == 0)
{
_executions.WriteLine(
"timestampUtc,decisionId,symbol,side,phase,approved,riskReason,riskDetail," +
"quantity,referencePrice,stopPrice,targetPrice,notional,equity,buyingPower," +
"grossExposure,openPositions,orderId,error,latencyMs");
}
}
public bool IsEnabled => _decisions is not null || _executions is not null;
public string? DecisionPath { get; private init; }
/// <summary>Allocates the id that ties a decision row to its execution row.</summary>
public long NextDecisionId() => Interlocked.Increment(ref _nextId);
/// <summary>
/// Records one bar evaluation. Called on the market-data thread once per closed
/// bar per symbol — a handful of times a day on this configuration, so the cost is
/// irrelevant, but it stays buffered anyway.
/// </summary>
public void Decision(
long decisionId,
string symbol,
in Bar bar,
IStrategy strategy,
in PositionView position,
in Signal signal,
in Quote quote,
double quoteAgeSeconds,
double equity,
bool sessionOpen,
bool halted)
{
if (_decisions is null)
{
return;
}
IReadOnlyList<StrategyMetric> metrics = strategy.Diagnostics;
lock (_gate)
{
if (!_decisionHeaderWritten)
{
WriteDecisionHeader(metrics);
}
_row.Clear();
Add(bar.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
Add(decisionId);
Add(symbol);
Add(strategy.Name);
Add(strategy.IsReady ? 1 : 0);
Add(bar.Open);
Add(bar.High);
Add(bar.Low);
Add(bar.Close);
Add(bar.Volume);
Add(bar.TakerBuyVolume);
Add(bar.Delta);
Add(bar.TradeCount);
// Indicator values, in the same order the header declared.
foreach (string name in _metricNames)
{
double value = 0;
foreach (StrategyMetric m in metrics)
{
if (m.Name == name)
{
value = double.IsFinite(m.Value) ? m.Value : 0;
break;
}
}
Add(value);
}
Add(position.Quantity);
Add(position.AverageEntryPrice);
Add(position.UnrealizedPnl);
Add(position.BarsHeld);
Add(position.StopPrice);
Add(position.TargetPrice);
Add(signal.Kind.ToString());
Add(signal.Strength);
Add(signal.StopPrice);
Add(signal.TargetPrice);
Add(quote.IsValid ? quote.BidPrice : 0);
Add(quote.IsValid ? quote.AskPrice : 0);
Add(quote.IsValid ? quote.RelativeSpread : 0);
Add(quoteAgeSeconds);
Add(equity);
Add(sessionOpen ? 1 : 0);
Add(halted ? 1 : 0);
Add(signal.Reason, last: true);
_decisions.WriteLine(_row.ToString());
MaybeFlush();
}
}
/// <summary>Records what the order path did with a signal.</summary>
public void Execution(
long decisionId,
string symbol,
Side side,
string phase,
bool approved,
string riskReason,
string riskDetail,
double quantity,
double referencePrice,
double stopPrice,
double targetPrice,
double equity,
double buyingPower,
double grossExposure,
int openPositions,
string? orderId,
string? error,
double latencyMs)
{
if (_executions is null)
{
return;
}
lock (_gate)
{
_row.Clear();
Add(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
Add(decisionId);
Add(symbol);
Add(side.ToString());
Add(phase);
Add(approved ? 1 : 0);
Add(riskReason);
Add(riskDetail);
Add(quantity);
Add(referencePrice);
Add(stopPrice);
Add(targetPrice);
Add(quantity * referencePrice);
Add(equity);
Add(buyingPower);
Add(grossExposure);
Add(openPositions);
Add(orderId ?? string.Empty);
Add(error ?? string.Empty);
Add(latencyMs, last: true);
_executions.WriteLine(_row.ToString());
MaybeFlush();
}
}
private void WriteDecisionHeader(IReadOnlyList<StrategyMetric> metrics)
{
string[] names = new string[metrics.Count];
for (int i = 0; i < metrics.Count; i++)
{
names[i] = metrics[i].Name;
}
_metricNames = names;
_decisionHeaderWritten = true;
if (_decisions!.BaseStream.Length > 0)
{
// Appending to an existing file: keep its header rather than writing a
// second one in the middle.
return;
}
StringBuilder header = new(400);
header.Append("barTimeUtc,decisionId,symbol,strategy,ready,")
.Append("open,high,low,close,volume,takerBuyVolume,delta,trades,");
foreach (string name in names)
{
header.Append(name).Append(',');
}
header.Append("positionQty,positionEntry,positionPnl,barsHeld,positionStop,positionTarget,")
.Append("signal,signalStrength,signalStop,signalTarget,")
.Append("bid,ask,spreadPct,quoteAgeSec,equity,sessionOpen,halted,reason");
_decisions.WriteLine(header.ToString());
}
private void Add(double value, bool last = false)
{
if (double.IsFinite(value))
{
_row.Append(value.ToString("G10", CultureInfo.InvariantCulture));
}
if (!last)
{
_row.Append(',');
}
}
private void Add(long value, bool last = false)
{
_row.Append(value.ToString(CultureInfo.InvariantCulture));
if (!last)
{
_row.Append(',');
}
}
private void Add(string? value, bool last = false)
{
if (!string.IsNullOrEmpty(value))
{
// Quote only when necessary; a reason string routinely contains commas.
if (value.AsSpan().IndexOfAny(',', '"', '\n') >= 0)
{
_row.Append('"').Append(value.Replace("\"", "\"\"", StringComparison.Ordinal)).Append('"');
}
else
{
_row.Append(value);
}
}
if (!last)
{
_row.Append(',');
}
}
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($"analytics flush failed: {ex.Message}");
}
}
}
private static StreamWriter? Open(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
return new StreamWriter(
new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 16384),
Encoding.UTF8)
{ AutoFlush = false };
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"cannot open analytics file {path}: {ex.Message}");
return null;
}
}
public void Dispose()
{
lock (_gate)
{
try
{
_decisions?.Flush();
_executions?.Flush();
}
catch (IOException)
{
// Best effort on shutdown.
}
_decisions?.Dispose();
_executions?.Dispose();
}
}
}
@@ -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,13 +34,17 @@
<ItemGroup>
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
<ProjectReference Include="..\Encelado.Alpaca\Encelado.Alpaca.csproj" />
<ProjectReference Include="..\Encelado.Etoro\Encelado.Etoro.csproj" />
</ItemGroup>
<!-- DPAPI (System.Security.Cryptography.ProtectedData) ships inside the Windows
Desktop framework, so no package reference is needed: the app has zero NuGet
dependencies at runtime. -->
<ItemGroup>
<InternalsVisibleTo Include="Encelado.Tests" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\config\encelado.json" Link="encelado.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="..\..\config\*.json" Exclude="..\..\config\*.local.json" Link="config\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
@@ -1,68 +0,0 @@
using Encelado.Alpaca.Rest;
namespace Encelado.Bot.Engine;
/// <summary>
/// Latest known account snapshot, refreshed by the reconciler and read from the
/// order path. Fields are written as a unit under a lock and read without one, which
/// is fine: sizing only needs a recent value, not a transactionally consistent one.
/// </summary>
public sealed class AccountState
{
private double _equity;
private double _buyingPower;
private double _cash;
private int _daytradeCount;
private bool _patternDayTrader;
private bool _canTrade;
private bool _shortingEnabled;
// Broker-side detail the account page shows verbatim. None of it is on the decision
// path, so a reference swap under the same update is all the consistency needed.
private AlpacaAccount? _raw;
public double Equity => Volatile.Read(ref _equity);
public double BuyingPower => Volatile.Read(ref _buyingPower);
public double Cash => Volatile.Read(ref _cash);
public int DaytradeCount => Volatile.Read(ref _daytradeCount);
public bool PatternDayTrader => Volatile.Read(ref _patternDayTrader);
public bool CanTrade => Volatile.Read(ref _canTrade);
public bool ShortingEnabled => Volatile.Read(ref _shortingEnabled);
public DateTime LastUpdateUtc { get; private set; }
public bool HasData => Equity > 0;
/// <summary>
/// The last full account payload from the broker, or <see langword="null"/> before
/// the first reconcile. Dashboard only — the trading path reads the fields above.
/// </summary>
public AlpacaAccount? Raw => Volatile.Read(ref _raw);
public void Update(AlpacaAccount account)
{
ArgumentNullException.ThrowIfNull(account);
Volatile.Write(ref _raw, account);
Volatile.Write(ref _equity, (double)account.Equity);
Volatile.Write(ref _buyingPower, (double)account.BuyingPower);
Volatile.Write(ref _cash, (double)account.Cash);
Volatile.Write(ref _daytradeCount, account.DaytradeCount);
Volatile.Write(ref _patternDayTrader, account.PatternDayTrader);
Volatile.Write(ref _canTrade, account.CanTrade);
Volatile.Write(ref _shortingEnabled, account.ShortingEnabled);
LastUpdateUtc = DateTime.UtcNow;
}
/// <summary>
/// Under the PDT rule an account flagged as a pattern day trader with less than
/// $25 000 in equity cannot open a new day trade.
/// </summary>
public bool IsDayTradeBlocked => PatternDayTrader && Equity < 25_000;
}
@@ -1,83 +0,0 @@
using Encelado.Core.Market;
namespace Encelado.Bot.Engine;
/// <summary>
/// Folds Alpaca's one-minute stream bars into the strategy timeframe. A bucket is
/// emitted as soon as the first bar of the next bucket arrives, which is the earliest
/// moment the previous one is provably complete.
/// </summary>
public sealed class BarAggregator(int minutesPerBar)
{
private readonly long _bucketTicks = TimeSpan.TicksPerMinute * Math.Max(1, minutesPerBar);
private readonly bool _passthrough = minutesPerBar <= 1;
private Bar _current;
private long _bucket = -1;
private bool _has;
public bool IsPassthrough => _passthrough;
/// <summary>
/// Feeds a one-minute bar. Returns <see langword="true"/> when a higher-timeframe
/// bar closed, with the completed bar in <paramref name="closed"/>.
/// </summary>
public bool TryAdd(in Bar minuteBar, out Bar closed)
{
if (_passthrough)
{
closed = minuteBar;
return true;
}
long bucket = minuteBar.TimeUtc.Ticks / _bucketTicks;
if (!_has)
{
_current = minuteBar;
_bucket = bucket;
_has = true;
closed = default;
return false;
}
if (bucket != _bucket)
{
closed = _current;
_current = minuteBar;
_bucket = bucket;
return true;
}
_current = Merge(_current, minuteBar);
closed = default;
return false;
}
public void Reset()
{
_has = false;
_bucket = -1;
_current = default;
}
private static Bar Merge(in Bar acc, in Bar next)
{
double volume = acc.Volume + next.Volume;
double vwap = volume > 0
? ((acc.Vwap > 0 ? acc.Vwap : acc.TypicalPrice) * acc.Volume +
(next.Vwap > 0 ? next.Vwap : next.TypicalPrice) * next.Volume) / volume
: next.Close;
return new Bar(
acc.TimeUtc,
acc.Open,
Math.Max(acc.High, next.High),
Math.Min(acc.Low, next.Low),
next.Close,
volume,
vwap,
acc.TradeCount + next.TradeCount,
acc.TakerBuyVolume + next.TakerBuyVolume);
}
}
+140 -226
View File
@@ -1,3 +1,6 @@
using System.Globalization;
using Encelado.Bot.Ui;
namespace Encelado.Bot.Engine;
public enum BotState
@@ -9,163 +12,120 @@ public enum BotState
Faulted,
}
/// <summary>One open position, as the positions grid shows it.</summary>
public sealed record PositionRow(
string Symbol,
string Side,
double Quantity,
double EntryPrice,
double LastPrice,
double MarketValue,
double UnrealizedPnl,
double UnrealizedPnlPct,
double StopPrice,
double TargetPrice,
/// <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 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,
double EntryZ,
int BarsHeld,
DateTime OpenedAtUtc);
/// <summary>Per-symbol strategy state, including whatever the strategy chooses to expose.</summary>
public sealed record SymbolRow(
string Symbol,
string Strategy,
bool Ready,
int BarsSeen,
int WarmupBars,
double LastPrice,
double BidPrice,
double AskPrice,
double SpreadPct,
double QuoteAgeSeconds,
bool InPosition,
IReadOnlyList<MetricRow> Metrics)
int Adds,
bool IsOpen,
double HalfLife)
{
public double WarmupProgress => WarmupBars > 0 ? Math.Min(1, BarsSeen / (double)WarmupBars) : 1;
public string PnlDisplay => PnlUsd.ToString("+#,##0.00;-#,##0.00;0.00", CultureInfo.CurrentCulture);
/// <summary>The blended conviction, when the strategy publishes one.</summary>
public double? Score
public string PnlPctDisplay => PnlPct.ToString("+0.00%;-0.00%;0.00%", CultureInfo.CurrentCulture);
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
{
foreach (MetricRow m in Metrics)
{
if (m.Name == "score")
{
return m.Value;
}
}
return null;
double m = (TimeUtc - DateTime.UtcNow).TotalMinutes;
return m < 0 ? "passato" : m < 90 ? $"fra {m:0} min" : $"fra {m / 60:0.0} h";
}
}
}
public sealed record MetricRow(string Name, double Value, string Format)
{
public string Display => Format switch
{
"P1" => (Value * 100).ToString("F1", System.Globalization.CultureInfo.CurrentCulture) + "%",
"F0" => Value.ToString("F0", System.Globalization.CultureInfo.CurrentCulture),
_ => Value.ToString("F2", System.Globalization.CultureInfo.CurrentCulture),
};
}
/// <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 broker's own view of the account, shown verbatim on the account page. Kept as a
/// separate record rather than folded into <see cref="BotSnapshot"/> so it can be
/// absent — before the first reconcile there is nothing truthful to display, and
/// showing zeros would look like a funded account that lost everything.
/// </summary>
public sealed record AccountRow(
string AccountNumber,
string Status,
string Currency,
double Equity,
double LastEquity,
double Cash,
double PortfolioValue,
double BuyingPower,
double DaytradingBuyingPower,
double Multiplier,
int DaytradeCount,
bool PatternDayTrader,
bool TradingBlocked,
bool AccountBlocked,
bool TransfersBlocked,
bool ShortingEnabled,
DateTime UpdatedUtc)
{
public double ChangeToday => Equity - LastEquity;
public double ChangeTodayPct => LastEquity > 0 ? (Equity - LastEquity) / LastEquity : 0;
/// <summary>Everything that would make the broker refuse an order, in one line.</summary>
public string Restrictions
{
get
{
List<string> issues = [];
if (TradingBlocked) { issues.Add("trading bloccato"); }
if (AccountBlocked) { issues.Add("conto bloccato"); }
if (TransfersBlocked) { issues.Add("trasferimenti bloccati"); }
if (PatternDayTrader && Equity < 25_000) { issues.Add("PDT sotto i 25.000"); }
return issues.Count == 0 ? "nessuna" : string.Join(", ", issues);
}
}
}
/// <summary>One row of the orders page.</summary>
public sealed record OrderRow(
string OrderId,
string Symbol,
string Side,
string Type,
string Status,
double Quantity,
double FilledQuantity,
double FilledAveragePrice,
double LimitPrice,
DateTime SubmittedUtc,
DateTime? FilledUtc)
{
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",
System.Globalization.CultureInfo.CurrentCulture);
}
/// <summary>Price memory for one charted symbol.</summary>
public sealed record PriceSeriesRow(
string Symbol,
double LastPrice,
double SessionOpen,
double SessionHigh,
double SessionLow,
double SessionChangePct,
IReadOnlyList<double> BarCloses,
IReadOnlyList<double> BarHighs,
IReadOnlyList<double> BarLows,
IReadOnlyList<double> BarOpens,
IReadOnlyList<double> LivePrices)
{
public bool HasBars => BarCloses.Count > 1;
public bool HasLive => LivePrices.Count > 1;
}
/// <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; }
@@ -176,120 +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 Paper { 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 AssetClass { get; init; }
public string Endpoint { get; init; } = string.Empty;
public required string TimeFrame { get; init; }
public string Preset { get; init; } = "—";
public required string Endpoint { 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 Cash { get; init; }
public double Balance { get; init; }
public double BuyingPower { get; init; }
public double AvailableBalance { get; init; }
public double PnlToday { get; init; }
public double PeakEquity { get; init; }
public double PnlTodayPct { 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 PnlAllTime { get; init; }
public double TodayPnl { get; init; }
public double PnlAllTimePct { get; init; }
public double TodayPnlPct { get; init; }
public bool HasAllTime { get; init; }
public double OpenPnl { get; init; }
public double UnrealizedPnl { get; init; }
public double OpenPnlPct { get; init; }
public double RealizedToday { get; init; }
public int OpenBaskets { get; init; }
public double GrossExposure { get; init; }
public double ExposurePct { get; init; }
// ---- session & risk ---------------------------------------------------
public required string SessionStatus { get; init; }
public bool MarketOpen { get; init; }
public int MaxBaskets { get; init; }
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 OpenPositions { 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 MaxOpenPositions { get; init; }
public double RiskPerTradePct { get; init; }
public double MaxDailyLossPct { get; init; }
// ---- plumbing ---------------------------------------------------------
public required string MarketDataState { get; init; }
public required string TradeStreamState { get; init; }
/// <summary>
/// Why a stream is being refused by the broker, when one is. A reconnect loop is
/// otherwise invisible from the window: the state just reads "disconnected" and the
/// explanation sits in the log file, which is the last place anyone looks.
/// </summary>
public string? StreamRejection { get; init; }
public int Reconnects { get; init; }
public long Ticks { get; init; }
public long Quotes { get; init; }
public long Bars { get; init; }
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<PositionRow> Positions { get; init; } = [];
public IReadOnlyList<SymbolRow> Symbols { 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 broker.</summary>
public AccountRow? Account { get; init; }
public IReadOnlyList<BasketRow> Baskets { get; init; } = [];
/// <summary>Named to stay clear of <see cref="Orders"/>, which counts submissions.</summary>
public IReadOnlyList<OrderRow> OrderHistory { get; init; } = [];
public IReadOnlyList<QuoteRow> Quotes { get; init; } = [];
public IReadOnlyList<PriceSeriesRow> Prices { 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);
+78 -432
View File
@@ -1,9 +1,7 @@
using System.Globalization;
using Encelado.Alpaca.Rest;
using Encelado.Bot.Baskets;
using Encelado.Bot.Configuration;
using Encelado.Bot.Logging;
using Encelado.Core.Portfolio;
using Encelado.Core.Strategies;
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, warm-up state and risk counters that were built to
/// live exactly as long as a session does; a new instance is simpler and cannot leak
/// stale state into the next run.
/// Each start creates a <b>fresh</b> 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; } }
@@ -56,24 +53,24 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
{
if (_state is BotState.Running or BotState.Starting)
{
return new CommandResult(false, "the bot is already running");
return new CommandResult(false, "il bot è già in esecuzione");
}
if (_state == BotState.Stopping)
{
return new CommandResult(false, "the previous run is still shutting down");
return new CommandResult(false, "l'esecuzione precedente si sta ancora fermando");
}
_state = BotState.Starting;
_error = null;
}
Log.Info("── start requested ──");
Log.Info("── avvio richiesto ──");
TradingEngine engine;
IEngine engine;
try
{
engine = new TradingEngine(config);
engine = _factory(config, StartConfirmed);
}
catch (Exception ex)
{
@@ -83,17 +80,16 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
_error = ex.Message;
}
Log.Error("could not build the engine", ex);
Log.Error("non sono riuscito a costruire il motore", ex);
return new CommandResult(false, ex.Message);
}
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
@@ -117,49 +113,37 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
}
catch (Exception ex)
{
Log.Error("engine stopped with an error", ex);
Log.Error("il motore si è fermato con un errore", ex);
lock (_gate)
{
_state = BotState.Faulted;
_error = ex.Message;
}
}
finally
{
try
{
await engine.ShutdownAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Warn($"shutdown reported: {ex.Message}");
}
}
});
lock (_gate)
{
_engineTask = task;
}
// Give startup a moment so an immediate failure (bad credentials, blocked
// account) surfaces as a returned error instead of silently on the feed.
await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(3))).ConfigureAwait(false);
lock (_gate)
{
if (_state == BotState.Faulted)
{
return new CommandResult(false, _error ?? "the engine failed to start");
}
if (_state == BotState.Starting)
{
_state = BotState.Running;
}
}
return new CommandResult(true, "bot started");
// 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 ?? "avvio fallito");
}
}
return new CommandResult(true, "bot avviato");
}
public async Task<CommandResult> StopAsync()
@@ -171,7 +155,7 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
{
if (_state is BotState.Stopped or BotState.Stopping)
{
return new CommandResult(false, "the bot is not running");
return new CommandResult(false, "il bot non è in esecuzione");
}
_state = BotState.Stopping;
@@ -179,7 +163,26 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
task = _engineTask;
}
Log.Info("── stop requested ──");
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)
{
@@ -194,14 +197,12 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
}
catch (TimeoutException)
{
Log.Warn("the engine did not stop within 45s");
Log.Warn("il motore non si è fermato entro 45 s");
}
}
TradingEngine? engine;
lock (_gate)
{
engine = _engine;
_engine = null;
_engineTask = null;
_engineCts = null;
@@ -214,405 +215,56 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
}
cts?.Dispose();
Log.Info("bot stopped");
return new CommandResult(true, "bot stopped");
Log.Info("bot fermo");
return new CommandResult(true, "bot fermo");
}
/// <summary>Closes one position on demand from the positions grid.</summary>
public async Task<CommandResult> ClosePositionAsync(string symbol, CancellationToken ct)
/// <summary>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)
{
return new CommandResult(false, "the bot is not running");
return new CommandResult(false, "il bot non è in esecuzione");
}
if (config.Engine.DryRun)
{
return new CommandResult(false, "dry-run mode: no order was sent");
}
try
{
await engine.Router.FlattenSymbolAsync(symbol, "closed manually from the app", ct)
.ConfigureAwait(false);
return new CommandResult(true, $"{symbol} close requested");
}
catch (Exception ex)
{
Log.Error($"manual close of {symbol} failed", 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(),
Paper = config.Alpaca.Paper,
DryRun = config.Engine.DryRun,
AssetClass = config.Engine.AssetClass,
TimeFrame = config.Engine.TimeFrame,
Endpoint = config.Alpaca.TradingBaseUrl,
SessionStatus = "engine stopped",
MarketDataState = "disconnected",
TradeStreamState = "disconnected",
BarToSignal = "—",
SignalToOrder = "—",
MaxOpenPositions = config.Risk.MaxOpenPositions,
MaxTradesPerDay = config.Risk.MaxTradesPerDay,
RiskPerTradePct = config.Risk.MaxRiskPerTradePct,
MaxDailyLossPct = config.Risk.MaxDailyLossPct,
Symbols = IdleSymbols(),
EquityCurve = SnapshotEquityCurve(),
Events = SnapshotEvents(),
Prices = IdlePrices(),
};
/// <summary>
/// Empty price rows for the configured symbols while the engine is stopped. Without
/// them the prices panel says "nessun asset configurato", which is false and reads
/// as a configuration problem rather than as "the bot is not running".
/// </summary>
private IReadOnlyList<PriceSeriesRow> IdlePrices()
{
List<PriceSeriesRow> rows = [];
foreach (SymbolConfig sc in config.EnabledSymbols)
EventRow[] events = SnapshotEvents();
if (engine is null || state is BotState.Stopped or BotState.Faulted)
{
rows.Add(new PriceSeriesRow(sc.Symbol, 0, 0, 0, 0, 0, [], [], [], [], []));
return BasketEngine.IdleSnapshot(config, state, error, events);
}
return rows;
}
private IReadOnlyList<SymbolRow> IdleSymbols()
{
List<SymbolRow> views = [];
foreach (SymbolConfig sc in config.EnabledSymbols)
{
int warmup = 0;
try
{
warmup = StrategyFactory.Create(sc.Strategy, sc.ToStrategyParameters()).WarmupBars;
}
catch (ArgumentException)
{
// A misconfigured strategy is reported by validation, not here.
}
views.Add(new SymbolRow(sc.Symbol, sc.Strategy, false, 0, warmup, 0, 0, 0, 0, 0, false, []));
}
return views;
}
private BotSnapshot LiveSnapshot(TradingEngine engine, BotState state, string? error)
{
AccountState account = engine.Account;
double equity = account.Equity;
SampleEquity(equity);
double sessionBase = engine.StartEquity;
double todayBase = engine.PreviousCloseEquity;
AlpacaPortfolioHistory history = engine.PortfolioHistory;
List<PositionRow> positions = [];
foreach (Position p in engine.Book.Positions)
{
if (!p.IsOpen)
{
continue;
}
positions.Add(new PositionRow(
p.Symbol,
p.Side == Core.Market.Side.Buy ? "long" : "short",
p.Quantity,
p.AverageEntryPrice,
p.LastPrice,
p.MarketValue,
p.UnrealizedPnl,
p.UnrealizedPnlPct,
p.StopPrice,
p.TargetPrice,
p.BarsHeld,
p.OpenedAtUtc));
}
positions.Sort(static (a, b) => Math.Abs(b.MarketValue).CompareTo(Math.Abs(a.MarketValue)));
List<SymbolRow> symbols = [];
foreach (SymbolPipeline pipe in engine.Pipelines)
{
List<MetricRow> metrics = [];
foreach (StrategyMetric m in pipe.Strategy.Diagnostics)
{
metrics.Add(new MetricRow(m.Name, double.IsFinite(m.Value) ? m.Value : 0, m.Format));
}
Core.Market.Quote quote = pipe.LastQuote;
double age = pipe.QuoteAge == TimeSpan.MaxValue ? -1 : pipe.QuoteAge.TotalSeconds;
symbols.Add(new SymbolRow(
pipe.Symbol,
pipe.Strategy.Name,
pipe.Strategy.IsReady,
pipe.BarsSeen,
pipe.Strategy.WarmupBars,
pipe.LastPrice,
quote.IsValid ? quote.BidPrice : 0,
quote.IsValid ? quote.AskPrice : 0,
quote.IsValid ? quote.RelativeSpread : 0,
age,
!engine.Book.View(pipe.Symbol).IsFlat,
metrics));
}
double unrealized = engine.Book.TotalUnrealizedPnl;
double exposure = engine.Book.GrossExposure;
return new BotSnapshot
{
State = state,
Error = error,
StartedAtUtc = engine.StartedAtUtc,
Uptime = engine.StartedAtUtc == default
? TimeSpan.Zero
: DateTime.UtcNow - engine.StartedAtUtc,
Mode = DescribeMode(),
Paper = config.Alpaca.Paper,
DryRun = config.Engine.DryRun,
AssetClass = config.Engine.AssetClass,
TimeFrame = config.Engine.TimeFrame,
Endpoint = config.Alpaca.TradingBaseUrl,
Equity = equity,
Cash = account.Cash,
BuyingPower = account.BuyingPower,
PnlToday = todayBase > 0 ? equity - todayBase : 0,
PnlTodayPct = todayBase > 0 ? (equity - todayBase) / todayBase : 0,
PnlSession = sessionBase > 0 ? equity - sessionBase : 0,
PnlSessionPct = sessionBase > 0 ? (equity - sessionBase) / sessionBase : 0,
PnlAllTime = history.TotalProfitLoss,
PnlAllTimePct = history.TotalProfitLossPct,
HasAllTime = history.HasData && history.BaseValue > 0,
UnrealizedPnl = unrealized,
RealizedToday = engine.Risk.DailyRealizedPnl,
GrossExposure = exposure,
ExposurePct = equity > 0 ? exposure / equity : 0,
SessionStatus = engine.Session.Describe(),
MarketOpen = engine.Session.IsOpen,
Halted = engine.Risk.IsHalted,
HaltReason = engine.Risk.IsHalted ? engine.Risk.HaltReason : null,
TradesToday = engine.Risk.TradesToday,
MaxTradesPerDay = config.Risk.MaxTradesPerDay,
OpenPositions = positions.Count,
MaxOpenPositions = config.Risk.MaxOpenPositions,
RiskPerTradePct = config.Risk.MaxRiskPerTradePct,
MaxDailyLossPct = config.Risk.MaxDailyLossPct,
MarketDataState = engine.MarketData.State.ToString().ToLowerInvariant(),
TradeStreamState = engine.TradeUpdates.State.ToString().ToLowerInvariant(),
StreamRejection = engine.MarketData.RejectionReason ?? engine.TradeUpdates.RejectionReason,
Reconnects = Math.Max(0, engine.MarketData.ConnectCount - 1),
Ticks = engine.Metrics.Trades,
Quotes = engine.Metrics.Quotes,
Bars = engine.Metrics.Bars,
Signals = engine.Metrics.Signals,
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(),
Positions = positions,
Symbols = symbols,
EquityCurve = SnapshotEquityCurve(),
Events = SnapshotEvents(),
Account = BuildAccount(account),
OrderHistory = BuildOrders(engine),
Prices = BuildPrices(engine),
};
}
private static AccountRow? BuildAccount(AccountState state)
{
// Before the first reconcile there is no truthful account to show. Returning
// null lets the page say "in attesa" instead of rendering a zeroed-out account
// that reads like a wiped-out one.
if (state.Raw is not { } a)
{
return null;
}
return new AccountRow(
a.AccountNumber,
a.Status,
a.Currency,
(double)a.Equity,
(double)a.LastEquity,
(double)a.Cash,
(double)a.PortfolioValue,
(double)a.BuyingPower,
(double)a.DaytradingBuyingPower,
(double)a.Multiplier,
a.DaytradeCount,
a.PatternDayTrader,
a.TradingBlocked,
a.AccountBlocked,
a.TransfersBlocked,
a.ShortingEnabled,
state.LastUpdateUtc);
}
private static IReadOnlyList<OrderRow> BuildOrders(TradingEngine engine)
{
IReadOnlyList<AlpacaOrder> source = engine.RecentOrders;
List<OrderRow> rows = new(source.Count);
foreach (AlpacaOrder o in source)
{
rows.Add(new OrderRow(
o.Id,
o.Symbol,
o.Side == Core.Market.Side.Buy ? "acquisto" : "vendita",
o.Type,
DescribeStatus(o.Status),
o.Quantity,
o.FilledQuantity,
o.FilledAveragePrice,
o.LimitPrice,
o.SubmittedAtUtc,
o.FilledAtUtc)
{
IsWorking = o.IsWorking,
});
}
rows.Sort(static (a, b) => b.SubmittedUtc.CompareTo(a.SubmittedUtc));
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 or OrderStatus.Accepted or OrderStatus.PendingNew => "in attesa",
_ => status.ToString().ToLowerInvariant(),
};
private static IReadOnlyList<PriceSeriesRow> BuildPrices(TradingEngine engine)
{
List<PriceSeriesRow> rows = [];
foreach (SymbolPipeline pipe in engine.Pipelines)
{
PriceSnapshot snap = pipe.History.Snapshot();
int n = snap.Bars.Count;
double[] closes = new double[n];
double[] highs = new double[n];
double[] lows = new double[n];
double[] opens = new double[n];
for (int i = 0; i < n; i++)
{
Core.Market.Bar b = snap.Bars[i];
closes[i] = b.Close;
highs[i] = b.High;
lows[i] = b.Low;
opens[i] = b.Open;
}
double[] live = new double[snap.Ticks.Count];
for (int i = 0; i < live.Length; i++)
{
live[i] = snap.Ticks[i].Price;
}
rows.Add(new PriceSeriesRow(
pipe.Symbol,
pipe.LastPrice,
snap.SessionOpen,
snap.SessionHigh,
snap.SessionLow,
snap.SessionChangePct,
closes, highs, lows, opens, live));
}
return rows;
}
private string DescribeMode() =>
config.Engine.DryRun ? "DRY-RUN" : config.Alpaca.Paper ? "PAPER" : "LIVE";
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()
@@ -623,12 +275,10 @@ public sealed class BotSupervisor(BotConfig config) : IAsyncDisposable
}
}
// Fully qualified: the Web SDK's implicit usings also bring in
// Microsoft.Extensions.Logging.LogLevel.
private void RecordEvent(Logging.LogLevel level, DateTime timestamp, string message)
{
EventRow view = new(
timestamp.ToString("HH:mm:ss", CultureInfo.InvariantCulture),
UiClock.Format(timestamp.ToUniversalTime(), "HH:mm:ss"),
level.ToString().ToLowerInvariant(),
message);
@@ -642,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);
@@ -655,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()
@@ -1,477 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Threading.Channels;
using Encelado.Alpaca;
using Encelado.Alpaca.Rest;
using Encelado.Bot.Configuration;
using Encelado.Bot.Diagnostics;
using Encelado.Bot.Logging;
using Encelado.Core.Market;
using Encelado.Core.Portfolio;
using Encelado.Core.Risk;
using Encelado.Core.Strategies;
namespace Encelado.Bot.Engine;
/// <summary>A decision handed from the market-data thread to the order path.</summary>
public readonly record struct ExecutionIntent(
int SymbolId,
string Symbol,
Signal Signal,
double ReferencePrice,
long EnqueuedTimestamp,
long DecisionId);
/// <summary>
/// Turns approved signals into Alpaca orders. Strategies never touch the broker: they
/// publish intents, this class serialises them through a single consumer so sizing,
/// risk checks and submission can never interleave for the same symbol.
/// </summary>
public sealed class ExecutionRouter(
AlpacaTradingClient trading,
PortfolioBook book,
RiskEngine risk,
AccountState account,
SessionGuard session,
EngineOptions options,
Metrics metrics,
TradeJournal journal,
AnalyticsLog analytics,
SymbolPipeline?[] pipelines)
{
private readonly Channel<ExecutionIntent> _queue = Channel.CreateUnbounded<ExecutionIntent>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
private readonly bool _isEquity = options.ResolvedAssetClass == AssetClass.UsEquity;
private Task? _consumer;
private int _sequence;
public int QueueDepth { get; private set; }
public void Start(CancellationToken ct) => _consumer ??= Task.Run(() => ConsumeAsync(ct), CancellationToken.None);
public bool Enqueue(in ExecutionIntent intent) => _queue.Writer.TryWrite(intent);
public async Task StopAsync()
{
_queue.Writer.TryComplete();
if (_consumer is not null)
{
try
{
await _consumer.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Shutdown.
}
}
}
private async Task ConsumeAsync(CancellationToken ct)
{
try
{
await foreach (ExecutionIntent intent in _queue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
QueueDepth = _queue.Reader.Count;
try
{
if (intent.Signal.Kind == SignalKind.Exit)
{
await HandleExitAsync(intent, ct).ConfigureAwait(false);
}
else
{
await HandleEntryAsync(intent, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
metrics.CountOrderError();
Log.Error($"{intent.Symbol}: execution failed", ex);
SymbolPipeline? pipe = Pipeline(intent.SymbolId);
pipe?.ReleaseEntry();
pipe?.ReleaseExit();
}
}
}
catch (OperationCanceledException)
{
// Shutdown.
}
}
// -----------------------------------------------------------------------
// Entries
// -----------------------------------------------------------------------
private async Task HandleEntryAsync(ExecutionIntent intent, CancellationToken ct)
{
SymbolPipeline? pipe = Pipeline(intent.SymbolId);
if (pipe is null)
{
return;
}
if (!session.CanOpenNewPositions)
{
Log.Debug($"{intent.Symbol}: entry skipped, session not accepting new positions ({session.Describe()})");
return;
}
if (!account.HasData)
{
Log.Warn($"{intent.Symbol}: entry skipped, no account snapshot yet");
return;
}
if (!account.CanTrade)
{
Log.Warn($"{intent.Symbol}: entry skipped, the broker has blocked trading on this account");
return;
}
if (_isEquity && account.IsDayTradeBlocked)
{
Log.Warn($"{intent.Symbol}: entry skipped, PDT flag with equity below $25,000");
return;
}
if (options.MaxQuoteAgeSeconds > 0 && pipe.QuoteAge > TimeSpan.FromSeconds(options.MaxQuoteAgeSeconds))
{
Log.Debug($"{intent.Symbol}: entry skipped, top-of-book is {pipe.QuoteAge.TotalSeconds:F0}s stale");
return;
}
PositionView position = book.View(intent.Symbol);
if (!position.IsFlat)
{
return;
}
if (!pipe.TryClaimEntry())
{
Log.Debug($"{intent.Symbol}: entry skipped, another entry is already in flight");
return;
}
bool submitted = false;
try
{
Side side = intent.Signal.EntrySide;
double reference = pipe.EntryReferencePrice(side);
if (reference <= 0)
{
reference = intent.ReferencePrice;
}
EntryRequest request = new(
intent.Symbol,
side,
reference,
intent.Signal.StopPrice,
intent.Signal.Strength,
account.Equity,
account.BuyingPower,
book.GrossExposure,
book.OpenPositionCount,
position.Quantity,
pipe.LastQuote.IsValid ? pipe.LastQuote.RelativeSpread : 0,
options.AllowFractionalShares,
DateTime.UtcNow);
RiskVerdict verdict = risk.ApproveEntry(request);
analytics.Execution(
intent.DecisionId, intent.Symbol, side, "risk", verdict.Approved,
verdict.Reason.ToString(), verdict.Detail,
verdict.Quantity, reference, verdict.StopPrice, intent.Signal.TargetPrice,
account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount,
null, null, Stopwatch.GetElapsedTime(intent.EnqueuedTimestamp).TotalMilliseconds);
if (!verdict.Approved)
{
metrics.CountRiskReject();
Log.Debug($"{intent.Symbol}: {side} rejected by risk [{verdict.Reason}] {verdict.Detail}");
return;
}
NewOrder order = BuildEntryOrder(intent, side, verdict, reference, out double basePrice);
if (options.DryRun)
{
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"[DRY-RUN] {intent.Symbol} {side} {verdict.Quantity:0.####} @ ~{basePrice:F2} " +
$"stop={Fmt(order.StopLossStopPrice)} target={Fmt(order.TakeProfitLimitPrice)} :: {intent.Signal.Reason}"));
journal.Record("dry-run-entry", intent.Symbol, side, verdict.Quantity, basePrice,
intent.Signal.Reason, null, order.StopLossStopPrice, order.TakeProfitLimitPrice, account.Equity);
return;
}
long submitStart = Stopwatch.GetTimestamp();
AlpacaOrder placed = await trading.SubmitOrderAsync(order, ct).ConfigureAwait(false);
metrics.SignalToOrder.RecordSince(intent.EnqueuedTimestamp);
metrics.CountOrderSubmitted();
submitted = true;
risk.RecordEntry(intent.Symbol, DateTime.UtcNow);
// Without a broker-side bracket the engine has to police the stop itself.
if (!order.HasBracket)
{
pipe.LocalStop = verdict.StopPrice;
pipe.LocalTarget = intent.Signal.TargetPrice;
}
else
{
pipe.ClearProtection();
}
book.SetProtection(intent.Symbol, verdict.StopPrice, intent.Signal.TargetPrice);
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"ENTRY {intent.Symbol} {side} {verdict.Quantity:0.####} @ ~{basePrice:F2} " +
$"stop={Fmt(order.StopLossStopPrice)} target={Fmt(order.TakeProfitLimitPrice)} " +
$"[{Stopwatch.GetElapsedTime(submitStart).TotalMilliseconds:F0}ms] :: {intent.Signal.Reason}"));
journal.Record("entry", intent.Symbol, side, verdict.Quantity, basePrice, intent.Signal.Reason,
placed.Id, order.StopLossStopPrice, order.TakeProfitLimitPrice, account.Equity);
analytics.Execution(
intent.DecisionId, intent.Symbol, side, "order", true, "None", intent.Signal.Reason,
verdict.Quantity, basePrice, order.StopLossStopPrice, order.TakeProfitLimitPrice,
account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount,
placed.Id, null, Stopwatch.GetElapsedTime(submitStart).TotalMilliseconds);
}
catch (AlpacaApiException ex)
{
metrics.CountOrderError();
Log.Error($"{intent.Symbol}: order rejected by Alpaca ({ex.StatusCode})", ex);
analytics.Execution(
intent.DecisionId, intent.Symbol, intent.Signal.EntrySide, "order", false,
$"Http{ex.StatusCode}", ex.Message, 0, intent.ReferencePrice, double.NaN, double.NaN,
account.Equity, account.BuyingPower, book.GrossExposure, book.OpenPositionCount,
null, ex.Message, 0);
}
finally
{
// The in-flight latch is only held while an order is actually working.
if (!submitted)
{
pipe.ReleaseEntry();
}
}
}
private NewOrder BuildEntryOrder(
ExecutionIntent intent,
Side side,
RiskVerdict verdict,
double reference,
out double basePrice)
{
bool useLimit = options.UseLimitEntries;
double offset = options.LimitOffsetBps / 10_000.0;
// A marketable limit: priced through the touch so it fills like a market order
// but can never fill at an absurd print.
double limitPrice = side == Side.Buy
? reference * (1 + offset)
: reference * (1 - offset);
basePrice = useLimit ? limitPrice : reference;
bool wholeShares = Math.Abs(verdict.Quantity - Math.Floor(verdict.Quantity)) < 1e-9;
bool bracketAllowed = options.UseBracketOrders && _isEquity && wholeShares;
double stop = double.NaN;
double target = double.NaN;
if (bracketAllowed)
{
double tick = basePrice >= 1 ? 0.01 : 0.0001;
if (!double.IsNaN(verdict.StopPrice) && verdict.StopPrice > 0)
{
stop = side == Side.Buy
? Math.Min(verdict.StopPrice, basePrice - tick)
: Math.Max(verdict.StopPrice, basePrice + tick);
if (stop <= 0)
{
stop = double.NaN;
}
}
double signalTarget = intent.Signal.TargetPrice;
if (!double.IsNaN(signalTarget) && signalTarget > 0)
{
target = side == Side.Buy
? Math.Max(signalTarget, basePrice + tick)
: Math.Min(signalTarget, basePrice - tick);
if (target <= 0)
{
target = double.NaN;
}
}
}
return new NewOrder
{
Symbol = intent.Symbol,
Side = side,
Quantity = verdict.Quantity,
Type = useLimit ? OrderType.Limit : OrderType.Market,
LimitPrice = useLimit ? limitPrice : double.NaN,
TimeInForce = _isEquity ? TimeInForce.Day : TimeInForce.GoodTillCanceled,
ClientOrderId = NextClientOrderId(intent.Symbol),
StopLossStopPrice = stop,
TakeProfitLimitPrice = target,
};
}
// -----------------------------------------------------------------------
// Exits
// -----------------------------------------------------------------------
private async Task HandleExitAsync(ExecutionIntent intent, CancellationToken ct)
{
PositionView position = book.View(intent.Symbol);
SymbolPipeline? pipe = Pipeline(intent.SymbolId);
try
{
if (position.IsFlat)
{
pipe?.ClearProtection();
return;
}
if (options.DryRun)
{
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"[DRY-RUN] EXIT {intent.Symbol} {position.Quantity:0.####} @ ~{intent.ReferencePrice:F2} :: {intent.Signal.Reason}"));
journal.Record("dry-run-exit", intent.Symbol, position.Side.Opposite(),
Math.Abs(position.Quantity), intent.ReferencePrice, intent.Signal.Reason);
return;
}
await FlattenSymbolAsync(intent.Symbol, intent.Signal.Reason, ct).ConfigureAwait(false);
pipe?.ClearProtection();
}
finally
{
pipe?.ReleaseExit();
}
}
/// <summary>
/// Cancels the symbol's working orders and liquidates it at market. The cancel is
/// required first: a resting bracket leg reserves the shares and would make the
/// liquidation fail with "insufficient qty available".
/// </summary>
public async Task FlattenSymbolAsync(string symbol, string reason, CancellationToken ct)
{
PositionView position = book.View(symbol);
try
{
List<AlpacaOrder> open = await trading.ListOrdersAsync("open", 100, symbol, ct).ConfigureAwait(false);
foreach (AlpacaOrder order in open)
{
if (order.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase))
{
await trading.CancelOrderAsync(order.Id, ct).ConfigureAwait(false);
}
}
}
catch (AlpacaApiException ex)
{
Log.Warn($"{symbol}: could not cancel working orders before flattening: {ex.Message}");
}
AlpacaOrder? closing = await trading.ClosePositionAsync(symbol, null, ct).ConfigureAwait(false);
metrics.CountExit();
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"EXIT {symbol} {position.Quantity:0.####} @ ~{position.LastPrice:F2} " +
$"pnl={position.UnrealizedPnl:F2} :: {reason}"));
journal.Record("exit", symbol, position.Side.Opposite(), Math.Abs(position.Quantity),
position.LastPrice, reason, closing?.Id, equity: account.Equity,
realizedPnl: position.UnrealizedPnl);
Pipeline(symbol)?.ReleaseEntry();
}
/// <summary>Cancels every working order and liquidates the whole book.</summary>
public async Task FlattenAllAsync(string reason, CancellationToken ct)
{
if (options.DryRun)
{
Log.Info($"[DRY-RUN] flatten-all requested :: {reason}");
return;
}
Log.Warn($"flattening the entire book :: {reason}");
try
{
await trading.CancelAllOrdersAsync(ct).ConfigureAwait(false);
}
catch (AlpacaApiException ex)
{
Log.Warn($"cancel-all failed: {ex.Message}");
}
try
{
await trading.CloseAllPositionsAsync(cancelOrders: true, ct).ConfigureAwait(false);
journal.Record("flatten-all", "*", Side.None, 0, 0, reason, equity: account.Equity);
}
catch (AlpacaApiException ex)
{
Log.Error($"close-all failed: {ex.Message}", ex);
}
foreach (SymbolPipeline? pipe in pipelines)
{
pipe?.ClearProtection();
pipe?.ReleaseEntry();
pipe?.ReleaseExit();
}
}
private SymbolPipeline? Pipeline(int id) =>
(uint)id < (uint)pipelines.Length ? pipelines[id] : null;
private SymbolPipeline? Pipeline(string symbol)
{
foreach (SymbolPipeline? pipe in pipelines)
{
if (pipe is not null && pipe.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase))
{
return pipe;
}
}
return null;
}
private string NextClientOrderId(string symbol)
{
int seq = Interlocked.Increment(ref _sequence);
string clean = symbol.Replace("/", string.Empty, StringComparison.Ordinal);
return string.Create(CultureInfo.InvariantCulture,
$"enc-{clean}-{DateTime.UtcNow:yyMMddHHmmssfff}-{seq}");
}
private static string Fmt(double value) =>
double.IsNaN(value) ? "-" : value.ToString("F2", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,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,139 +0,0 @@
using Encelado.Core.Market;
namespace Encelado.Bot.Engine;
/// <summary>One point on the live price line.</summary>
public readonly record struct PricePoint(DateTime TimeUtc, double Price);
/// <summary>
/// Rolling price memory for one symbol, feeding the charts.
/// <para>
/// Two series, because they answer different questions. The <b>closed bars</b> are what
/// the strategy actually decides on — on a daily timeframe there is one per day, and a
/// hundred of them is several months of context. The <b>live line</b> is sampled from
/// the quote stream roughly once a second and exists so the operator can see the price
/// moving right now, between two decisions that are a day apart.
/// </para>
/// <para>
/// Both are bounded ring buffers written on the market-data thread and read by the UI
/// thread, so every access is under the same lock. The buffers are small and the lock
/// is held for a copy, never for I/O.
/// </para>
/// </summary>
public sealed class PriceHistory(int barCapacity = 180, int tickCapacity = 1800)
{
private readonly Lock _gate = new();
private readonly Queue<Bar> _bars = new(barCapacity);
private readonly Queue<PricePoint> _ticks = new(tickCapacity);
private DateTime _lastSampleUtc;
private double _sessionOpen;
private double _sessionHigh;
private double _sessionLow = double.MaxValue;
/// <summary>Sampling floor for the live line. One second is well under any chart's resolution.</summary>
private static readonly TimeSpan SampleEvery = TimeSpan.FromSeconds(1);
public void AddBar(in Bar bar)
{
lock (_gate)
{
if (_bars.Count >= barCapacity)
{
_bars.Dequeue();
}
_bars.Enqueue(bar);
}
}
/// <summary>
/// Records a live price. Rate limited: quotes can arrive hundreds of times a second
/// on a busy symbol and a chart that is repainted once a second cannot show them.
/// </summary>
public void AddPrice(double price, DateTime nowUtc)
{
if (price <= 0 || !double.IsFinite(price))
{
return;
}
lock (_gate)
{
if (_sessionOpen <= 0)
{
_sessionOpen = price;
}
if (price > _sessionHigh) { _sessionHigh = price; }
if (price < _sessionLow) { _sessionLow = price; }
if (nowUtc - _lastSampleUtc < SampleEvery)
{
return;
}
_lastSampleUtc = nowUtc;
if (_ticks.Count >= tickCapacity)
{
_ticks.Dequeue();
}
_ticks.Enqueue(new PricePoint(nowUtc, price));
}
}
public PriceSnapshot Snapshot()
{
lock (_gate)
{
return new PriceSnapshot(
[.. _bars],
[.. _ticks],
_sessionOpen,
_sessionHigh,
_sessionLow == double.MaxValue ? 0 : _sessionLow);
}
}
/// <summary>Called when a new trading session starts, so the day's range restarts too.</summary>
public void ResetSession()
{
lock (_gate)
{
_sessionOpen = 0;
_sessionHigh = 0;
_sessionLow = double.MaxValue;
}
}
}
/// <summary>An immutable copy of one symbol's price memory, safe to hand to the UI.</summary>
public sealed record PriceSnapshot(
IReadOnlyList<Bar> Bars,
IReadOnlyList<PricePoint> Ticks,
double SessionOpen,
double SessionHigh,
double SessionLow)
{
public static readonly PriceSnapshot Empty = new([], [], 0, 0, 0);
public bool HasBars => Bars.Count > 1;
public bool HasTicks => Ticks.Count > 1;
/// <summary>Change since the first live sample, which is what the operator reads as "today".</summary>
public double SessionChangePct
{
get
{
if (SessionOpen <= 0 || Ticks.Count == 0)
{
return 0;
}
return (Ticks[^1].Price - SessionOpen) / SessionOpen;
}
}
}
@@ -1,92 +0,0 @@
using Encelado.Alpaca.Rest;
using Encelado.Bot.Configuration;
using Encelado.Bot.Logging;
using Encelado.Core.Market;
namespace Encelado.Bot.Engine;
/// <summary>
/// Owns "may we trade right now?". Wraps Alpaca's clock (the authority on holidays
/// and early closes), detects session rollovers and enforces the end-of-day flatten
/// window.
/// </summary>
public sealed class SessionGuard(AlpacaTradingClient client, EngineOptions options)
{
private readonly bool _alwaysOpen = options.ResolvedAssetClass == AssetClass.Crypto;
private AlpacaClock? _clock;
/// <summary>Raised the first time a new trading session is observed.</summary>
public Action<DateOnly>? OnNewSession { get; set; }
public DateOnly SessionDate { get; private set; }
public bool IsOpen => _alwaysOpen || (_clock?.IsOpen ?? false);
public DateTime NextCloseUtc => _clock?.NextCloseUtc ?? DateTime.MaxValue;
public DateTime NextOpenUtc => _clock?.NextOpenUtc ?? DateTime.MaxValue;
public TimeSpan TimeToClose =>
_alwaysOpen || _clock is null ? TimeSpan.MaxValue : _clock.NextCloseUtc - DateTime.UtcNow;
/// <summary>True inside the last N minutes of the session, where we only reduce risk.</summary>
public bool InFlattenWindow =>
!_alwaysOpen &&
options.FlattenBeforeCloseMinutes > 0 &&
IsOpen &&
TimeToClose <= TimeSpan.FromMinutes(options.FlattenBeforeCloseMinutes);
/// <summary>Entries are allowed only in a live session and outside the flatten window.</summary>
public bool CanOpenNewPositions => IsOpen && !InFlattenWindow;
public async Task RefreshAsync(CancellationToken ct)
{
if (_alwaysOpen)
{
DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow);
if (today != SessionDate)
{
SessionDate = today;
OnNewSession?.Invoke(today);
}
return;
}
try
{
AlpacaClock clock = await client.GetClockAsync(ct).ConfigureAwait(false);
_clock = clock;
// 16:00 ET always lands on the same UTC calendar day, so the close is a
// stable session key without needing a timezone database.
DateOnly sessionDate = DateOnly.FromDateTime(clock.NextCloseUtc);
if (sessionDate != SessionDate)
{
SessionDate = sessionDate;
OnNewSession?.Invoke(sessionDate);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"clock refresh failed: {ex.Message}");
}
}
public string Describe()
{
if (_alwaysOpen)
{
return "24/7 session";
}
if (_clock is null)
{
return "clock unknown";
}
return IsOpen
? $"open, closes in {TimeToClose:hh\\:mm\\:ss}{(InFlattenWindow ? " (FLATTEN WINDOW)" : string.Empty)}"
: $"closed, opens {_clock.NextOpenUtc:yyyy-MM-dd HH:mm}Z";
}
}
@@ -1,216 +0,0 @@
using System.Diagnostics;
using Encelado.Core.Market;
using Encelado.Core.Strategies;
namespace Encelado.Bot.Engine;
/// <summary>
/// Per-symbol state on the market-data path: the strategy instance, the latest
/// top-of-book, the bar aggregator and the flags that stop the engine from firing
/// twice on the same idea.
/// </summary>
public sealed class SymbolPipeline(int id, string symbol, IStrategy strategy, int minutesPerBar)
{
private long _lastQuoteTimestamp;
private int _entryInFlight;
private int _exitInFlight;
private double _pendingTakerBuyVolume;
private double _pendingTakerVolume;
public int Id { get; } = id;
public string Symbol { get; } = symbol;
public IStrategy Strategy { get; } = strategy;
public BarAggregator Aggregator { get; } = new(minutesPerBar);
public Quote LastQuote { get; private set; }
public double LastPrice { get; private set; }
public Bar LastBar { get; private set; }
public int BarsSeen { get; private set; }
/// <summary>Stop/target held locally when the broker could not hold a bracket for us.</summary>
public double LocalStop { get; set; } = double.NaN;
public double LocalTarget { get; set; } = double.NaN;
public bool IsWarm => Strategy.IsReady;
/// <summary>True between submitting an entry and seeing it resolve. Blocks duplicates.</summary>
public bool EntryInFlight => Volatile.Read(ref _entryInFlight) != 0;
public DateTime InFlightSinceUtc { get; private set; }
/// <summary>Atomically claims the in-flight slot. Returns false when another entry is already working.</summary>
public bool TryClaimEntry()
{
if (Interlocked.CompareExchange(ref _entryInFlight, 1, 0) != 0)
{
return false;
}
InFlightSinceUtc = DateTime.UtcNow;
return true;
}
public void ReleaseEntry() => Interlocked.Exchange(ref _entryInFlight, 0);
/// <summary>True while an exit is queued or executing. Stops one stop-loss breach
/// from queueing an exit on every subsequent quote.</summary>
public bool ExitInFlight => Volatile.Read(ref _exitInFlight) != 0;
public bool TryClaimExit() => Interlocked.CompareExchange(ref _exitInFlight, 1, 0) == 0;
public void ReleaseExit() => Interlocked.Exchange(ref _exitInFlight, 0);
public void OnQuote(in Quote quote)
{
if (!quote.IsValid)
{
return;
}
LastQuote = quote;
LastPrice = quote.Mid;
Volatile.Write(ref _lastQuoteTimestamp, Stopwatch.GetTimestamp());
History.AddPrice(quote.Mid, DateTime.UtcNow);
}
/// <summary>Rolling price memory for the charts. Never read on the decision path.</summary>
public PriceHistory History { get; } = new();
/// <summary>
/// Records a print and, when the feed says which side crossed the spread,
/// accumulates the aggressor breakdown for the bar currently being formed.
/// <para>
/// Alpaca's bars carry only total volume, so the taker split has to be rebuilt from
/// the trade stream. Without it the order-flow filter has nothing to read.
/// </para>
/// </summary>
public void OnTrade(in Tick tick, bool takerBought, bool aggressorKnown)
{
if (tick.Price > 0)
{
LastPrice = tick.Price;
History.AddPrice(tick.Price, DateTime.UtcNow);
}
if (!aggressorKnown || tick.Size <= 0)
{
return;
}
_pendingTakerVolume += tick.Size;
if (takerBought)
{
_pendingTakerBuyVolume += tick.Size;
}
}
/// <summary>
/// Stamps a stream bar with the aggressor volume accumulated while it was forming,
/// then resets the accumulator for the next one.
/// </summary>
public Bar AttachOrderFlow(in Bar bar)
{
double takerBuy = _pendingTakerBuyVolume;
double observed = _pendingTakerVolume;
_pendingTakerBuyVolume = 0;
_pendingTakerVolume = 0;
if (observed <= 0 || bar.Volume <= 0)
{
return bar;
}
// The tick stream and the bar's own volume rarely agree exactly (late prints,
// feed gaps), so carry the observed *ratio* onto the bar's volume rather than
// the raw figure. A ratio is what the delta actually depends on.
double ratio = Math.Clamp(takerBuy / observed, 0, 1);
return bar with { TakerBuyVolume = bar.Volume * ratio };
}
/// <summary>Taker-buy volume seen since the last bar closed. Diagnostics only.</summary>
public double PendingTakerBuyVolume => _pendingTakerBuyVolume;
public void OnBarClosed(in Bar bar)
{
LastBar = bar;
BarsSeen++;
if (bar.Close > 0)
{
LastPrice = bar.Close;
}
History.AddBar(bar);
}
/// <summary>Age of the last top-of-book update. <see cref="TimeSpan.MaxValue"/> when none was seen.</summary>
public TimeSpan QuoteAge
{
get
{
long ts = Volatile.Read(ref _lastQuoteTimestamp);
return ts == 0 ? TimeSpan.MaxValue : Stopwatch.GetElapsedTime(ts);
}
}
/// <summary>
/// Reference price for an entry: the far touch when the book is usable (that is
/// what we will actually pay), otherwise the last print.
/// </summary>
public double EntryReferencePrice(Side side)
{
Quote q = LastQuote;
if (q.IsValid)
{
return side == Side.Buy ? q.AskPrice : q.BidPrice;
}
return LastPrice;
}
/// <summary>Checks a locally held stop/target against the latest price.</summary>
public bool ShouldExitLocally(double price, double positionQuantity, out string reason)
{
reason = string.Empty;
if (positionQuantity == 0 || price <= 0)
{
return false;
}
bool isLong = positionQuantity > 0;
if (!double.IsNaN(LocalStop) && LocalStop > 0)
{
if ((isLong && price <= LocalStop) || (!isLong && price >= LocalStop))
{
reason = $"local stop {LocalStop:F4} hit at {price:F4}";
return true;
}
}
if (!double.IsNaN(LocalTarget) && LocalTarget > 0)
{
if ((isLong && price >= LocalTarget) || (!isLong && price <= LocalTarget))
{
reason = $"local target {LocalTarget:F4} hit at {price:F4}";
return true;
}
}
return false;
}
public void ClearProtection()
{
LocalStop = double.NaN;
LocalTarget = double.NaN;
}
}
@@ -1,108 +0,0 @@
using System.Buffers;
using System.IO;
using System.Text.Json;
using Encelado.Core.Market;
namespace Encelado.Bot.Engine;
/// <summary>
/// Append-only JSONL record of everything the engine decided. Separate from the log
/// on purpose: this file is meant to be parsed (pandas, jq, a spreadsheet) when
/// reviewing why the bot did what it did.
/// </summary>
public sealed class TradeJournal : IDisposable
{
private readonly FileStream? _stream;
private readonly Lock _gate = new();
public TradeJournal(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
string full = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
_stream = new FileStream(full, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 4096);
}
public bool IsEnabled => _stream 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)
{
if (_stream is null)
{
return;
}
ArrayBufferWriter<byte> buffer = new(320);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("ts", DateTime.UtcNow.ToString("O"));
w.WriteString("event", @event);
w.WriteString("symbol", symbol);
w.WriteString("side", side switch { Side.Buy => "buy", Side.Sell => "sell", _ => "none" });
w.WriteNumber("qty", Round(quantity));
w.WriteNumber("price", Round(price));
if (stopPrice is { } stop && !double.IsNaN(stop))
{
w.WriteNumber("stop", Round(stop));
}
if (targetPrice is { } target && !double.IsNaN(target))
{
w.WriteNumber("target", Round(target));
}
if (equity is { } eq)
{
w.WriteNumber("equity", Round(eq));
}
if (realizedPnl is { } pnl)
{
w.WriteNumber("realizedPnl", Round(pnl));
}
if (!string.IsNullOrEmpty(orderId))
{
w.WriteString("orderId", orderId);
}
w.WriteString("reason", reason);
w.WriteEndObject();
}
lock (_gate)
{
_stream.Write(buffer.WrittenSpan);
_stream.WriteByte((byte)'\n');
_stream.Flush();
}
}
private static double Round(double value) =>
double.IsNaN(value) || double.IsInfinity(value) ? 0 : Math.Round(value, 6);
public void Dispose()
{
lock (_gate)
{
_stream?.Dispose();
}
}
}
@@ -1,923 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using Encelado.Alpaca.Rest;
using Encelado.Alpaca.Streaming;
using Encelado.Bot.Configuration;
using Encelado.Bot.Diagnostics;
using Encelado.Bot.Logging;
using Encelado.Core.Market;
using Encelado.Core.Portfolio;
using Encelado.Core.Risk;
using Encelado.Core.Strategies;
namespace Encelado.Bot.Engine;
/// <summary>
/// The composition root of the live bot. It wires the market-data stream to the
/// strategies, the strategies to the risk engine, and the risk engine to the order
/// router — then supervises the whole thing: session rollovers, reconciliation with
/// the broker, the end-of-day flatten and the kill switch.
/// </summary>
public sealed class TradingEngine : IAsyncDisposable
{
private readonly BotConfig _config;
private readonly AlpacaTradingClient _trading;
private readonly AlpacaDataClient _data;
private readonly PortfolioBook _book = new();
private readonly RiskEngine _risk;
private readonly AccountState _account = new();
private readonly Metrics _metrics = new();
private readonly TradeJournal _journal;
private readonly AnalyticsLog _analytics;
private readonly bool _logMarketData;
private readonly SessionGuard _session;
private readonly MarketDataStream _marketData;
private readonly TradeUpdateStream _tradeUpdates;
private readonly ExecutionRouter _router;
private readonly SymbolPipeline?[] _pipelines;
private readonly string[] _symbols;
private readonly AssetClass _assetClass;
private readonly int _minutesPerBar;
private DateOnly _flattenedForSession;
private DateTime _lastReconcileUtc;
private DateTime _lastStatusUtc;
private DateTime _lastExplainUtc;
private readonly Dictionary<string, string> _lastIntent = new(StringComparer.OrdinalIgnoreCase);
private DateTime _lastHistoryUtc;
public TradingEngine(BotConfig config)
{
_config = config.Validate();
_assetClass = config.Engine.ResolvedAssetClass;
_minutesPerBar = Math.Max(1, config.Engine.ResolvedTimeFrame.Seconds() / 60);
_symbols = [.. config.EnabledSymbols.Select(s => s.Symbol.Trim())];
_trading = new AlpacaTradingClient(config.Alpaca);
_data = new AlpacaDataClient(config.Alpaca);
_risk = new RiskEngine(config.Risk);
_journal = new TradeJournal(config.Logging.ResolvePath(config.Logging.TradeJournal));
_analytics = new AnalyticsLog(config.Logging);
_logMarketData = config.Logging.LogMarketData && Log.IsEnabled(Logging.LogLevel.Trace);
_session = new SessionGuard(_trading, config.Engine);
_marketData = new MarketDataStream(config.Alpaca, _symbols, _assetClass);
_tradeUpdates = new TradeUpdateStream(config.Alpaca);
_pipelines = new SymbolPipeline?[_marketData.Symbols.Count];
foreach (SymbolConfig sc in config.EnabledSymbols)
{
int id = _marketData.Symbols.Resolve(sc.Symbol.Trim());
if (id < 0)
{
continue;
}
_pipelines[id] = new SymbolPipeline(
id,
sc.Symbol.Trim(),
StrategyFactory.Create(sc.Strategy, sc.ToStrategyParameters()),
_minutesPerBar);
}
_router = new ExecutionRouter(
_trading, _book, _risk, _account, _session, config.Engine, _metrics, _journal, _analytics, _pipelines);
}
public Metrics Metrics => _metrics;
public PortfolioBook Book => _book;
public AccountState Account => _account;
public RiskEngine Risk => _risk;
public SessionGuard Session => _session;
public BotConfig Config => _config;
public MarketDataStream MarketData => _marketData;
public TradeUpdateStream TradeUpdates => _tradeUpdates;
public AlpacaTradingClient Trading => _trading;
/// <summary>How many past orders the orders page keeps. Two hundred is several
/// months for a strategy that trades five to ten times a year per symbol.</summary>
private const int RecentOrderLimit = 200;
private AlpacaOrder[] _recentOrders = [];
/// <summary>Most recent orders as the broker reports them, newest first.</summary>
public IReadOnlyList<AlpacaOrder> RecentOrders => Volatile.Read(ref _recentOrders);
public ExecutionRouter Router => _router;
/// <summary>Equity when this engine instance started — the base for session P&amp;L.</summary>
public double StartEquity { get; private set; }
public DateTime StartedAtUtc { get; private set; }
/// <summary>
/// Lifetime equity curve, refreshed slowly in the background. Purely informational,
/// so a failure to fetch it never interferes with trading.
/// </summary>
public AlpacaPortfolioHistory PortfolioHistory { get; private set; } = AlpacaPortfolioHistory.Empty;
/// <summary>Previous session's closing equity, used for "P&amp;L today".</summary>
public double PreviousCloseEquity { get; private set; }
public IReadOnlyList<SymbolPipeline> Pipelines
{
get
{
List<SymbolPipeline> active = new(_pipelines.Length);
foreach (SymbolPipeline? pipe in _pipelines)
{
if (pipe is not null)
{
active.Add(pipe);
}
}
return active;
}
}
public async Task RunAsync(CancellationToken ct)
{
PrintBanner();
await Task.WhenAll(_trading.WarmupAsync(ct), _data.WarmupAsync(ct)).ConfigureAwait(false);
AlpacaAccount account = await _trading.GetAccountAsync(ct).ConfigureAwait(false);
_account.Update(account);
StartEquity = (double)account.Equity;
StartedAtUtc = DateTime.UtcNow;
PreviousCloseEquity = (double)account.LastEquity;
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"account {account.AccountNumber} status={account.Status} equity={account.Equity:F2} " +
$"buyingPower={account.BuyingPower:F2} pdt={account.PatternDayTrader} shorting={account.ShortingEnabled}"));
if (!account.CanTrade)
{
throw new InvalidOperationException(
$"Alpaca reports the account cannot trade (status={account.Status}, " +
$"blocked={account.TradingBlocked || account.AccountBlocked}).");
}
if (_config.Risk.AllowShorting && !account.ShortingEnabled)
{
Log.Warn("risk.allowShorting is true but the account cannot short; short entries will be refused by Alpaca.");
}
_session.OnNewSession = OnNewSession;
await _session.RefreshAsync(ct).ConfigureAwait(false);
_risk.StartSession((double)account.Equity, _session.SessionDate);
Log.Info($"session: {_session.Describe()}");
await ReconcilePositionsAsync(ct).ConfigureAwait(false);
await WarmupStrategiesAsync(ct).ConfigureAwait(false);
WireStreams();
_router.Start(ct);
await _tradeUpdates.StartAsync(ct).ConfigureAwait(false);
await _marketData.StartAsync(ct).ConfigureAwait(false);
Log.Info("engine running — press Ctrl+C to stop");
await SuperviseAsync(ct).ConfigureAwait(false);
}
// -----------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------
private void PrintBanner()
{
string mode = _config.Engine.DryRun
? "DRY-RUN (no orders will be sent)"
: _config.Alpaca.Paper ? "PAPER" : "*** LIVE MONEY ***";
Log.Info("──────────────────────────────────────────────────────────────");
Log.Info($" Encelado trading engine mode={mode}");
Log.Info($" endpoint={_trading.BaseUrl} feed={_config.Alpaca.DataFeed} assetClass={_assetClass}");
Log.Info($" timeframe={_config.Engine.TimeFrame} symbols={_symbols.Length} " +
$"brackets={_config.Engine.UseBracketOrders} entries={_config.Engine.EntryOrderType}");
Log.Info($" risk: {_config.Risk.MaxRiskPerTradePct:P2}/trade, max {_config.Risk.MaxOpenPositions} positions, " +
$"daily stop {_config.Risk.MaxDailyLossPct:P1}");
foreach (SymbolConfig s in _config.EnabledSymbols)
{
Log.Info($" {s.Symbol,-12} {s.Strategy}");
}
Log.Info("──────────────────────────────────────────────────────────────");
}
/// <summary>Replays recent history through every strategy so signals are valid from the first live bar.</summary>
private async Task WarmupStrategiesAsync(CancellationToken ct)
{
int warmupBars = _config.Engine.WarmupBars;
if (warmupBars <= 0)
{
Log.Warn("warmup is disabled; strategies will need live bars before they can signal");
return;
}
// Cover the requested bar count with slack for weekends, holidays and gaps.
double barsPerDay = _assetClass == AssetClass.Crypto ? 1440.0 / _minutesPerBar : 390.0 / _minutesPerBar;
int days = (int)Math.Ceiling(warmupBars / Math.Max(1, barsPerDay)) + (_assetClass == AssetClass.Crypto ? 2 : 5);
DateTime start = DateTime.UtcNow.AddDays(-Math.Max(2, days));
long t0 = Stopwatch.GetTimestamp();
Dictionary<string, List<Bar>> history;
try
{
history = await _data.GetBarsAsync(
_symbols,
_config.Engine.ResolvedTimeFrame,
start,
endUtc: null,
_assetClass,
warmupBars,
ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"warmup download failed ({ex.Message}); strategies will warm up on live bars");
return;
}
int fed = 0;
foreach (SymbolPipeline? pipe in _pipelines)
{
if (pipe is null || !history.TryGetValue(pipe.Symbol, out List<Bar>? bars))
{
continue;
}
PositionView flat = PositionView.Flat(pipe.Symbol);
foreach (Bar bar in bars)
{
// Signals produced during warm-up are historical and deliberately discarded.
_ = pipe.Strategy.OnBar(bar, flat);
pipe.OnBarClosed(bar);
fed++;
}
Log.Info($"warmup {pipe.Symbol,-12} {bars.Count,4} bars ready={pipe.Strategy.IsReady} " +
$"(needs {pipe.Strategy.WarmupBars})");
}
Log.Info($"warmup complete: {fed} bars in {Stopwatch.GetElapsedTime(t0).TotalMilliseconds:F0}ms");
}
private void WireStreams()
{
_marketData.OnLog = (message, ex) =>
{
if (ex is null)
{
Log.Info(message);
}
else
{
Log.Warn(message);
}
};
_tradeUpdates.OnLog = _marketData.OnLog;
_marketData.OnBar = HandleBar;
_marketData.OnQuote = HandleQuote;
_marketData.OnTrade = HandleTrade;
_tradeUpdates.OnTradeUpdate = HandleTradeUpdate;
_marketData.OnLiveChanged = live =>
Log.Info(live ? "market data stream is live" : "market data stream went down");
_tradeUpdates.OnLiveChanged = live =>
Log.Info(live ? "trade updates stream is live" : "trade updates stream went down");
}
// -----------------------------------------------------------------------
// Market data path — runs on the websocket receive thread
// -----------------------------------------------------------------------
private void HandleBar(int symbolId, string symbol, in Bar bar)
{
long t0 = Stopwatch.GetTimestamp();
_metrics.CountBar();
SymbolPipeline? pipe = PipelineFor(symbolId);
if (pipe is null)
{
return;
}
// Alpaca bars carry total volume only; the aggressor split comes from the trade
// stream and has to be stamped on before the strategy sees the bar.
Bar enriched = pipe.AttachOrderFlow(bar);
// Every incoming bar, not just the ones that close a strategy bucket. On a daily
// timeframe 1439 of every 1440 minute bars are folded in silently, and without
// this the log shows nothing happening for a whole day.
if (_config.Logging.LogEveryBar)
{
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"[{symbol}] barra {enriched.TimeUtc:HH:mm} " +
$"O {enriched.Open:N2} H {enriched.High:N2} L {enriched.Low:N2} C {enriched.Close:N2} " +
$"vol {enriched.Volume:N4} ({enriched.Close - enriched.Open:+0.00;-0.00;0.00} " +
$"{(enriched.Open > 0 ? (enriched.Close - enriched.Open) / enriched.Open : 0):+0.00%;-0.00%;0.00%})"));
}
if (!pipe.Aggregator.TryAdd(enriched, out Bar closed))
{
return;
}
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"[{symbol}] barra {_config.Engine.TimeFrame} CHIUSA {closed.TimeUtc:yyyy-MM-dd HH:mm} " +
$"C {closed.Close:N2} — valuto la strategia"));
Decide(pipe, symbol, closed, t0);
}
/// <summary>
/// Runs one closed bar through the strategy and the order path. Shared by the live
/// stream and by <see cref="PollClosedBarsAsync"/>, so a decision is identical
/// whichever route the bar arrived on.
/// </summary>
private void Decide(SymbolPipeline pipe, string symbol, in Bar closed, long startedAt)
{
pipe.OnBarClosed(closed);
_book.Mark(symbol, closed.Close);
_book.OnBarClosed(symbol);
PositionView position = _book.View(symbol);
Signal signal = pipe.Strategy.OnBar(closed, position);
_metrics.BarToSignal.RecordSince(startedAt);
long t0 = startedAt;
long decisionId = _analytics.NextDecisionId();
_analytics.Decision(
decisionId, symbol, closed, pipe.Strategy, position, signal,
pipe.LastQuote,
pipe.QuoteAge == TimeSpan.MaxValue ? -1 : pipe.QuoteAge.TotalSeconds,
_account.Equity, _session.IsOpen, _risk.IsHalted);
// The decision itself, always at info: this is the answer to "why did it (not)
// trade", and burying it at debug is how that question became unanswerable.
string verdict = signal.Kind switch
{
SignalKind.EnterLong => $"COMPRO — {signal.Reason}",
SignalKind.EnterShort => $"VENDO ALLO SCOPERTO — {signal.Reason}",
SignalKind.Exit => $"CHIUDO — {signal.Reason}",
_ => $"NON FACCIO NULLA — {pipe.Strategy.Explain(closed.Close, position)}",
};
Log.Info($"[{symbol}] {verdict}");
if (Log.IsEnabled(Logging.LogLevel.Debug))
{
// Built separately: concatenating onto an interpolated string breaks the
// handler chain that string.Create needs.
string internals = string.Join(" ", pipe.Strategy.Diagnostics
.Select(static m => string.Create(CultureInfo.InvariantCulture, $"{m.Name}={m.Value:F4}")));
Log.Debug($"[{symbol}] stato interno: {internals}");
}
Publish(pipe, signal, closed.Close, position, decisionId);
}
private void HandleQuote(int symbolId, string symbol, in Quote quote)
{
_metrics.CountQuote();
SymbolPipeline? pipe = PipelineFor(symbolId);
if (pipe is null || !quote.IsValid)
{
return;
}
pipe.OnQuote(quote);
_book.Mark(symbol, quote.Mid);
if (_logMarketData)
{
Log.Trace(string.Create(CultureInfo.InvariantCulture,
$"{symbol} quote {quote.BidPrice:F2} x {quote.AskPrice:F2} spread {quote.RelativeSpread:P3}"));
}
PositionView position = _book.View(symbol);
if (position.IsFlat)
{
return;
}
// Locally held protection (crypto and fractional sizes cannot use broker brackets).
if (pipe.ShouldExitLocally(quote.Mid, position.Quantity, out string reason))
{
Publish(pipe, Signal.Exit(reason), quote.Mid, position, _analytics.NextDecisionId());
return;
}
Signal signal = pipe.Strategy.OnQuote(quote, position);
Publish(pipe, signal, quote.Mid, position, _analytics.NextDecisionId());
}
private void HandleTrade(int symbolId, string symbol, in Tick tick, Aggressor aggressor)
{
_metrics.CountTrade();
SymbolPipeline? pipe = PipelineFor(symbolId);
if (pipe is null || tick.Price <= 0)
{
return;
}
pipe.OnTrade(tick, aggressor == Aggressor.Buy, aggressor != Aggressor.Unknown);
_book.Mark(symbol, tick.Price);
if (_logMarketData)
{
Log.Trace(string.Create(CultureInfo.InvariantCulture,
$"{symbol} print {tick.Price:F2} x {tick.Size:F6} taker={aggressor}"));
}
}
/// <summary>Applies the cross-cutting gates and hands the signal to the order path.</summary>
private void Publish(
SymbolPipeline pipe, in Signal signal, double referencePrice, in PositionView position, long decisionId)
{
if (signal.Kind == SignalKind.None)
{
return;
}
if (signal.Kind == SignalKind.Exit)
{
if (position.IsFlat || !pipe.TryClaimExit())
{
return;
}
_metrics.CountSignal();
EnqueueOrRelease(pipe, signal, referencePrice, decisionId, isExit: true);
return;
}
// Entries. Every refusal is recorded: a signal that never became an order is
// exactly the kind of thing worth reviewing later.
if (!position.IsFlat || pipe.EntryInFlight || _risk.IsHalted || !_session.CanOpenNewPositions ||
!pipe.Strategy.IsReady)
{
string why =
!position.IsFlat ? "already in position"
: pipe.EntryInFlight ? "an entry is already in flight"
: _risk.IsHalted ? $"trading halted: {_risk.HaltReason}"
: !_session.CanOpenNewPositions ? "session not accepting new positions"
: "strategy still warming up";
Log.Debug($"{pipe.Symbol}: {signal.Kind} suppressed — {why}");
_analytics.Execution(
decisionId, pipe.Symbol, signal.EntrySide, "suppressed", false, "Suppressed", why,
0, referencePrice, signal.StopPrice, signal.TargetPrice,
_account.Equity, _account.BuyingPower, _book.GrossExposure, _book.OpenPositionCount,
null, null, 0);
return;
}
_metrics.CountSignal();
EnqueueOrRelease(pipe, signal, referencePrice, decisionId, isExit: false);
}
private void EnqueueOrRelease(
SymbolPipeline pipe, in Signal signal, double referencePrice, long decisionId, bool isExit)
{
ExecutionIntent intent = new(
pipe.Id, pipe.Symbol, signal, referencePrice, Stopwatch.GetTimestamp(), decisionId);
if (_router.Enqueue(intent))
{
return;
}
Log.Warn($"{pipe.Symbol}: execution queue refused an intent; dropping the signal");
if (isExit)
{
pipe.ReleaseExit();
}
}
// -----------------------------------------------------------------------
// Order events — runs on the trade-updates receive thread
// -----------------------------------------------------------------------
private void HandleTradeUpdate(TradeUpdate update)
{
SymbolPipeline? pipe = PipelineFor(update.Symbol);
if (update.IsExecution && update.Quantity > 0 && update.Price > 0)
{
FillResult fill = _book.ApplyFill(
update.Symbol, update.Side, update.Quantity, update.Price, update.TimestampUtc);
_metrics.CountOrderFilled();
if (fill.RealizedPnlDelta != 0)
{
_risk.RecordRealizedPnl(fill.RealizedPnlDelta);
}
// Alpaca's position_qty is authoritative; trust it over our own arithmetic.
if (Math.Abs(fill.QuantityAfter - update.PositionQuantity) > 1e-6)
{
_book.Reconcile(update.Symbol, update.PositionQuantity, _book.View(update.Symbol).AverageEntryPrice,
update.Price);
}
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"FILL {update.Symbol} {update.Side} {update.Quantity:0.####} @ {update.Price:F2} " +
$"position={update.PositionQuantity:0.####} realized={fill.RealizedPnlDelta:F2}"));
_journal.Record("fill", update.Symbol, update.Side, update.Quantity, update.Price,
update.Event, update.Order.Id, equity: _account.Equity, realizedPnl: fill.RealizedPnlDelta);
if (fill.Closed)
{
pipe?.ClearProtection();
}
}
else if (update.Event is "rejected" or "canceled" or "expired")
{
Log.Warn($"{update.Symbol}: order {update.Event} (status={update.Order.Status}, id={update.Order.Id})");
_journal.Record(update.Event, update.Symbol, update.Side, update.Order.Quantity,
update.Order.LimitPrice, $"order {update.Event}", update.Order.Id);
}
if (update.IsTerminal)
{
pipe?.ReleaseEntry();
}
}
// -----------------------------------------------------------------------
// Supervision
// -----------------------------------------------------------------------
private async Task SuperviseAsync(CancellationToken ct)
{
TimeSpan reconcileEvery = TimeSpan.FromSeconds(_config.Engine.ReconcileSeconds);
TimeSpan statusEvery = TimeSpan.FromSeconds(Math.Max(10, _config.Engine.StatusSeconds));
TimeSpan explainEvery = TimeSpan.FromSeconds(Math.Max(1, _config.Engine.ExplainSeconds));
using PeriodicTimer timer = new(TimeSpan.FromSeconds(1));
try
{
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
DateTime now = DateTime.UtcNow;
if (now - _lastReconcileUtc >= reconcileEvery)
{
_lastReconcileUtc = now;
await _session.RefreshAsync(ct).ConfigureAwait(false);
await ReconcileAsync(ct).ConfigureAwait(false);
await EnforceEndOfDayAsync(ct).ConfigureAwait(false);
}
if (now - _lastExplainUtc >= explainEvery)
{
_lastExplainUtc = now;
LogIntent();
}
if (now - _lastStatusUtc >= statusEvery)
{
_lastStatusUtc = now;
LogStatus();
}
}
}
catch (OperationCanceledException)
{
Log.Info("shutdown requested");
}
}
/// <summary>
/// Asks the broker for the most recent <b>completed</b> bars at the strategy
/// timeframe and runs the strategy on any it has not seen.
/// <para>
/// This exists because building the strategy's bars out of the live minute stream is
/// not enough on a daily timeframe. The aggregator only closes a bucket when a minute
/// bar arrives belonging to the next one — at 1440 minutes per bucket that is once a
/// day, at UTC midnight, and <i>only if the process happens to be running at that
/// instant</i>. Started at ten and stopped at six, the bot would receive thousands of
/// quotes, log hundreds of bars, and never evaluate the strategy once: the symptom
/// was <c>bars=40 signals=0</c> with an empty decision log.
/// </para>
/// <para>
/// Polling closed bars from REST also keeps the live bot faithful to the backtest,
/// which decides on true daily closes rather than on a partial day assembled from
/// whichever minutes the process was connected for.
/// </para>
/// </summary>
private async Task PollClosedBarsAsync(CancellationToken ct)
{
Dictionary<string, List<Bar>> latest;
try
{
// A handful of bars is enough to close any gap left by a short outage, and
// cheap enough to ask for on every reconcile.
latest = await _data.GetBarsAsync(
_symbols,
_config.Engine.ResolvedTimeFrame,
DateTime.UtcNow.AddMinutes(-_minutesPerBar * 5.0),
endUtc: null,
_assetClass,
maxBarsPerSymbol: 5,
ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Debug($"bar poll failed: {ex.Message}");
return;
}
foreach (SymbolPipeline? pipe in _pipelines)
{
if (pipe is null || !latest.TryGetValue(pipe.Symbol, out List<Bar>? bars))
{
continue;
}
foreach (Bar bar in bars)
{
// Strictly newer only. The last bar the broker returns is usually the one
// still forming; acting on it would mean deciding on a partial close and
// then deciding again when it finishes.
if (bar.TimeUtc <= pipe.LastBar.TimeUtc || bar.TimeUtc >= CurrentBucketStart())
{
continue;
}
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"{pipe.Symbol} barra chiusa {bar.TimeUtc:yyyy-MM-dd HH:mm} C{bar.Close:F2} — valuto la strategia"));
Decide(pipe, pipe.Symbol, bar, Stopwatch.GetTimestamp());
}
}
}
/// <summary>Start of the bar currently forming. Anything at or after it is incomplete.</summary>
private DateTime CurrentBucketStart()
{
long ticks = TimeSpan.TicksPerMinute * _minutesPerBar;
return new DateTime(DateTime.UtcNow.Ticks - (DateTime.UtcNow.Ticks % ticks), DateTimeKind.Utc);
}
private async Task ReconcileAsync(CancellationToken ct)
{
try
{
AlpacaAccount account = await _trading.GetAccountAsync(ct).ConfigureAwait(false);
_account.Update(account);
PreviousCloseEquity = (double)account.LastEquity;
// The order history the orders page shows. Polled on the reconcile cadence
// rather than kept in sync from the trade stream, because the stream only
// reports what happened while we were connected: after a reconnect the
// broker's list is the only complete one.
_recentOrders = [.. await _trading
.ListOrdersAsync("all", RecentOrderLimit, null, ct)
.ConfigureAwait(false)];
// Lifetime history moves slowly and is dashboard-only; poll it sparingly.
if (DateTime.UtcNow - _lastHistoryUtc > TimeSpan.FromMinutes(5))
{
_lastHistoryUtc = DateTime.UtcNow;
PortfolioHistory = await _trading
.GetPortfolioHistoryAsync("all", "1D", ct)
.ConfigureAwait(false);
}
if (_risk.UpdateEquity((double)account.Equity))
{
Log.Warn($"KILL SWITCH: {_risk.HaltReason} — flattening and standing down for the session");
await _router.FlattenAllAsync(_risk.HaltReason, ct).ConfigureAwait(false);
}
await ReconcilePositionsAsync(ct).ConfigureAwait(false);
await ReleaseStaleLatchesAsync(ct).ConfigureAwait(false);
await PollClosedBarsAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"reconcile failed: {ex.Message}");
}
}
private async Task ReconcilePositionsAsync(CancellationToken ct)
{
List<AlpacaPosition> positions = await _trading.ListPositionsAsync(ct).ConfigureAwait(false);
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (AlpacaPosition p in positions)
{
seen.Add(p.Symbol);
PositionView before = _book.View(p.Symbol);
_book.Reconcile(p.Symbol, p.Quantity, p.AverageEntryPrice, p.CurrentPrice);
if (Math.Abs(before.Quantity - p.Quantity) > 1e-6)
{
Log.Debug($"reconciled {p.Symbol}: {before.Quantity:0.####} -> {p.Quantity:0.####} " +
$"@ {p.AverageEntryPrice:F2}");
}
}
_book.ReconcileMissing(seen);
}
/// <summary>
/// Frees the per-symbol in-flight latch when the broker has no working order left,
/// so a dropped websocket message cannot wedge a symbol permanently.
/// </summary>
private async Task ReleaseStaleLatchesAsync(CancellationToken ct)
{
bool anyStale = false;
foreach (SymbolPipeline? pipe in _pipelines)
{
if (pipe is not null && pipe.EntryInFlight &&
DateTime.UtcNow - pipe.InFlightSinceUtc > TimeSpan.FromSeconds(30))
{
anyStale = true;
break;
}
}
if (!anyStale)
{
return;
}
List<AlpacaOrder> open = await _trading.ListOpenOrdersAsync(ct).ConfigureAwait(false);
HashSet<string> working = new(StringComparer.OrdinalIgnoreCase);
foreach (AlpacaOrder order in open)
{
if (order.IsWorking)
{
working.Add(order.Symbol);
}
}
foreach (SymbolPipeline? pipe in _pipelines)
{
if (pipe is null || !pipe.EntryInFlight)
{
continue;
}
if (DateTime.UtcNow - pipe.InFlightSinceUtc > TimeSpan.FromSeconds(30) && !working.Contains(pipe.Symbol))
{
pipe.ReleaseEntry();
Log.Debug($"{pipe.Symbol}: released a stale in-flight latch");
}
}
}
private async Task EnforceEndOfDayAsync(CancellationToken ct)
{
if (!_session.InFlattenWindow || _flattenedForSession == _session.SessionDate)
{
return;
}
if (_book.OpenPositionCount == 0)
{
_flattenedForSession = _session.SessionDate;
return;
}
_flattenedForSession = _session.SessionDate;
await _router.FlattenAllAsync(
$"end-of-day flatten, {_config.Engine.FlattenBeforeCloseMinutes} min before the close", ct)
.ConfigureAwait(false);
}
private void OnNewSession(DateOnly date)
{
Log.Info($"── new trading session {date:yyyy-MM-dd} ──");
_risk.StartSession(_account.Equity, date);
_book.ResetDailyCounters();
foreach (SymbolPipeline? pipe in _pipelines)
{
pipe?.Aggregator.Reset();
}
}
/// <summary>
/// Says what each strategy is doing right now and what it is waiting for.
/// <para>
/// On a daily timeframe the bot is legitimately silent for weeks, and from the
/// outside that is indistinguishable from a hang. This turns the silence into a
/// sentence. The line is only written when it <i>changes</i>, so a bot that has been
/// waiting for the same threshold all week does not fill the log with the same
/// sentence a thousand times — but the moment anything moves, it says so.
/// </para>
/// </summary>
private void LogIntent()
{
foreach (SymbolPipeline? pipe in _pipelines)
{
if (pipe is null || pipe.LastPrice <= 0)
{
continue;
}
string intent = pipe.Strategy.Explain(pipe.LastPrice, _book.View(pipe.Symbol));
if (intent.Length == 0)
{
continue;
}
if (_lastIntent.TryGetValue(pipe.Symbol, out string? previous) && previous == intent)
{
continue;
}
_lastIntent[pipe.Symbol] = intent;
Log.Info($"[{pipe.Symbol}] {intent}");
}
}
private void LogStatus()
{
double unrealized = _book.TotalUnrealizedPnl;
double realized = _risk.DailyRealizedPnl;
double equity = _account.Equity;
double startEquity = _risk.SessionStartEquity;
double dayChange = startEquity > 0 ? (equity - startEquity) / startEquity : 0;
string halt = _risk.IsHalted ? $" HALTED: {_risk.HaltReason}" : string.Empty;
Log.Info(string.Create(CultureInfo.InvariantCulture,
$"[status] equity={equity:F2} ({dayChange:P2} today) realized={realized:F2} unrealized={unrealized:F2} " +
$"open={_book.OpenPositionCount} trades={_risk.TradesToday}{halt}"));
Log.Info($"[status] {_metrics.Summary()} queue={_router.QueueDepth}");
Log.Info($"[status] {_metrics.BarToSignal.Summary()} | {_metrics.SignalToOrder.Summary()}");
Log.Info($"[status] session: {_session.Describe()} | data={_marketData.State} " +
$"(reconnects={Math.Max(0, _marketData.ConnectCount - 1)}) | orders={_tradeUpdates.State}");
foreach (Position p in _book.Positions)
{
if (p.IsOpen)
{
Log.Info(string.Create(CultureInfo.InvariantCulture,
$" {p.Symbol,-12} {p.Quantity,10:0.####} @ {p.AverageEntryPrice,10:F2} " +
$"last={p.LastPrice,10:F2} pnl={p.UnrealizedPnl,9:F2} ({p.UnrealizedPnlPct:P2})"));
}
}
}
private SymbolPipeline? PipelineFor(int symbolId) =>
(uint)symbolId < (uint)_pipelines.Length ? _pipelines[symbolId] : null;
private SymbolPipeline? PipelineFor(string symbol)
{
int id = _marketData.Symbols.Resolve(symbol);
return id >= 0 ? PipelineFor(id) : null;
}
public async Task ShutdownAsync()
{
Log.Info("stopping streams…");
await _marketData.StopAsync().ConfigureAwait(false);
await _tradeUpdates.StopAsync().ConfigureAwait(false);
if (_config.Engine.CloseOnShutdown && !_config.Engine.DryRun)
{
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await _router.FlattenAllAsync("engine shutdown", cts.Token).ConfigureAwait(false);
}
await _router.StopAsync().ConfigureAwait(false);
LogStatus();
Log.Info("engine stopped");
}
public async ValueTask DisposeAsync()
{
await _marketData.DisposeAsync().ConfigureAwait(false);
await _tradeUpdates.DisposeAsync().ConfigureAwait(false);
_trading.Dispose();
_data.Dispose();
_journal.Dispose();
_analytics.Dispose();
}
}
@@ -4,6 +4,7 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Net.Http;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
+272 -47
View File
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Channels;
using Encelado.Bot.Configuration;
@@ -18,13 +19,32 @@ public enum LogLevel : byte
}
/// <summary>
/// Non-blocking logger. Call sites only enqueue; a single background writer does the
/// formatting and the I/O, so a burst of ticks never stalls the decode loop on a
/// console write.
/// Non-blocking, structured logger. Call sites only enqueue; a single background writer
/// does the formatting and the I/O, so a burst of ticks never stalls the decode loop on
/// a disk write.
/// <para>
/// The file is a <c>;</c>-separated table with a header, not a stream of prose:
/// </para>
/// <code>
/// timestamp;level;source;subject;event;message;exception;stack
/// </code>
/// <para>
/// This is what makes a problem findable after the fact. The previous format carried a
/// time with no date across a file that spanned weeks, no stack trace on errors, and no
/// way to isolate one pair — so answering "what went wrong with SOL/AVAX on the 30th"
/// meant reading three megabytes. Now it is one filter: <c>;ERR;</c> for every failure,
/// a pair name in the <c>subject</c> column for one instrument, and the file opens in a
/// spreadsheet as-is. The <c>source</c> is the class that wrote the line, captured from
/// the compiler for free; the <c>subject</c> is lifted from the <c>[ETHUSDT/BTCUSDT]</c>
/// prefix the code already uses, so no call site had to change to become searchable.
/// </para>
/// </summary>
public static class Log
{
private const string AnsiReset = "\u001b[0m";
private const string AnsiReset = "";
/// <summary>The columns, in order. Written once at the top of every new file.</summary>
public const string Header = "timestamp;level;source;subject;event;message;exception;stack";
private static readonly Channel<Entry> Queue = Channel.CreateUnbounded<Entry>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
@@ -81,9 +101,26 @@ public static class Log
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path))!);
// A file left over from the previous, unstructured format is moved aside
// rather than appended to: a table whose first thousand rows have no columns
// is not a table, and the old lines are still there under the .old name.
if (File.Exists(_path) && !HasHeader(_path))
{
string aside = Path.ChangeExtension(_path, ".old" + Path.GetExtension(_path));
File.Move(_path, aside, overwrite: true);
}
FileStream stream = new(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 8192);
_written = stream.Length;
_file = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = false };
_file = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) { AutoFlush = false };
if (_written == 0)
{
_file.WriteLine(Header);
_file.Flush();
_written = Header.Length + Environment.NewLine.Length;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -92,10 +129,24 @@ public static class Log
}
}
private static bool HasHeader(string path)
{
try
{
using StreamReader reader = new(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
string? first = reader.ReadLine();
return first is null || first.StartsWith("timestamp;level;", StringComparison.Ordinal);
}
catch (IOException)
{
return true;
}
}
/// <summary>
/// Rolls <c>encelado.log</c> to <c>encelado.1.log</c>, shifting the older ones up and
/// dropping the oldest. Keeps a long-running bot from filling the disk while still
/// preserving recent history for analysis.
/// preserving recent history for analysis. Both the size and the count are settings.
/// </summary>
private static void RotateIfNeeded()
{
@@ -142,18 +193,35 @@ public static class Log
}
}
public static void Trace(string message) => Write(LogLevel.Trace, message, null);
// -----------------------------------------------------------------------
// Call sites
// -----------------------------------------------------------------------
public static void Debug(string message) => Write(LogLevel.Debug, message, null);
public static void Trace(string message, [CallerFilePath] string caller = "") =>
Write(LogLevel.Trace, message, null, null, caller);
public static void Info(string message) => Write(LogLevel.Info, message, null);
public static void Debug(string message, [CallerFilePath] string caller = "") =>
Write(LogLevel.Debug, message, null, null, caller);
public static void Warn(string message) => Write(LogLevel.Warn, message, null);
public static void Info(string message, [CallerFilePath] string caller = "") =>
Write(LogLevel.Info, message, null, null, caller);
public static void Error(string message, Exception? exception = null) =>
Write(LogLevel.Error, message, exception);
public static void Warn(string message, [CallerFilePath] string caller = "") =>
Write(LogLevel.Warn, message, null, null, caller);
private static void Write(LogLevel level, string message, Exception? exception)
public static void Error(string message, Exception? exception = null, [CallerFilePath] string caller = "") =>
Write(LogLevel.Error, message, exception, null, caller);
/// <summary>
/// A line with an explicit event code — <c>order.submitted</c>, <c>entry.refused</c>,
/// <c>kill-switch</c> — so the moments that matter can be counted and filtered
/// without matching on prose.
/// </summary>
public static void Event(LogLevel level, string eventCode, string message, Exception? exception = null,
[CallerFilePath] string caller = "") =>
Write(level, message, exception, eventCode, caller);
private static void Write(LogLevel level, string message, Exception? exception, string? eventCode, string caller)
{
if (level < _minimum)
{
@@ -162,7 +230,7 @@ public static class Log
DateTime now = DateTime.Now;
if (Queue.Writer.TryWrite(new Entry(now, level, message, exception)))
if (Queue.Writer.TryWrite(new Entry(now, level, message, exception, eventCode, SourceOf(caller))))
{
Interlocked.Increment(ref _enqueued);
}
@@ -185,6 +253,25 @@ public static class Log
}
}
/// <summary>The class that logged, from the compiler-supplied file path. Free at the call site.</summary>
private static string SourceOf(string callerPath)
{
if (string.IsNullOrEmpty(callerPath))
{
return string.Empty;
}
ReadOnlySpan<char> span = callerPath.AsSpan();
int slash = span.LastIndexOfAny('\\', '/');
if (slash >= 0)
{
span = span[(slash + 1)..];
}
int dot = span.IndexOf('.');
return dot > 0 ? span[..dot].ToString() : span.ToString();
}
/// <summary>
/// Waits until the writer has caught up with everything enqueued so far. Needed
/// before writing to the console directly — an interactive prompt must not be
@@ -217,9 +304,13 @@ public static class Log
}
}
// -----------------------------------------------------------------------
// Writer
// -----------------------------------------------------------------------
private static async Task WriteLoopAsync()
{
StringBuilder sb = new(256);
StringBuilder sb = new(512);
long lastFlush = Stopwatch.GetTimestamp();
await foreach (Entry entry in Queue.Reader.ReadAllAsync().ConfigureAwait(false))
@@ -228,39 +319,16 @@ public static class Log
// full disk should cost log lines, not the trading session.
try
{
sb.Clear();
sb.Append(entry.Timestamp.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture))
.Append(' ')
.Append(Tag(entry.Level))
.Append(' ')
.Append(entry.Message);
if (entry.Exception is not null)
{
sb.Append(" | ").Append(entry.Exception.GetType().Name)
.Append(": ").Append(entry.Exception.Message);
}
string line = sb.ToString();
if (_console)
{
if (_colors)
{
Console.Out.Write(Color(entry.Level));
Console.Out.Write(line);
Console.Out.WriteLine(AnsiReset);
}
else
{
Console.Out.WriteLine(line);
}
WriteConsole(entry, sb);
}
if (_file is not null)
{
await _file.WriteLineAsync(line).ConfigureAwait(false);
_written += line.Length + Environment.NewLine.Length;
string row = FormatRow(entry, sb);
await _file.WriteLineAsync(row).ConfigureAwait(false);
_written += row.Length + Environment.NewLine.Length;
// Warnings and errors flush immediately; routine lines are batched so
// a busy session is not one fsync per entry.
@@ -292,6 +360,157 @@ public static class Log
}
}
/// <summary>
/// One row of the table. The pair or symbol is lifted out of the message's leading
/// <c>[…]</c> tag into its own column; everything else is CSV-quoted only when it
/// has to be, so the common line stays readable in a plain editor.
/// </summary>
internal static string FormatRow(in Entry entry, StringBuilder sb)
{
sb.Clear();
(string subject, string message) = SplitSubject(entry.Message);
sb.Append(entry.Timestamp.ToString("yyyy-MM-dd'T'HH:mm:ss.fffzzz", CultureInfo.InvariantCulture)).Append(';');
sb.Append(Tag(entry.Level)).Append(';');
Quote(sb, entry.Source).Append(';');
Quote(sb, subject).Append(';');
Quote(sb, entry.EventCode ?? string.Empty).Append(';');
Quote(sb, message).Append(';');
if (entry.Exception is { } ex)
{
Quote(sb, DescribeException(ex)).Append(';');
Quote(sb, FlattenStack(ex));
}
else
{
sb.Append(';');
}
return sb.ToString();
}
private static void WriteConsole(in Entry entry, StringBuilder sb)
{
sb.Clear();
sb.Append(entry.Timestamp.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture))
.Append(' ').Append(Tag(entry.Level))
.Append(' ').Append(entry.Message);
if (entry.Exception is not null)
{
sb.Append(" | ").Append(entry.Exception.GetType().Name)
.Append(": ").Append(entry.Exception.Message);
}
if (_colors)
{
Console.Out.Write(Color(entry.Level));
Console.Out.Write(sb.ToString());
Console.Out.WriteLine(AnsiReset);
}
else
{
Console.Out.WriteLine(sb.ToString());
}
}
/// <summary>Splits <c>[ETHUSDT/BTCUSDT] rest</c> into its tag and the rest.</summary>
internal static (string Subject, string Message) SplitSubject(string message)
{
if (message.Length > 2 && message[0] == '[')
{
int close = message.IndexOf(']', StringComparison.Ordinal);
if (close > 1 && close < 40)
{
string subject = message[1..close];
string rest = message[(close + 1)..].TrimStart();
return (subject, rest);
}
}
return (string.Empty, message);
}
/// <summary>CSV quoting for a <c>;</c>-separated file: only when the value needs it.</summary>
private static StringBuilder Quote(StringBuilder sb, string value)
{
if (value.Length == 0)
{
return sb;
}
if (value.AsSpan().IndexOfAny(";\"\r\n") < 0)
{
return sb.Append(value);
}
sb.Append('"');
foreach (char c in value)
{
if (c == '"')
{
sb.Append('"');
}
sb.Append(c is '\r' or '\n' ? ' ' : c);
}
return sb.Append('"');
}
/// <summary>Type and message of the exception and every inner one, innermost last.</summary>
private static string DescribeException(Exception ex)
{
StringBuilder sb = new(128);
Exception? current = ex;
while (current is not null)
{
if (sb.Length > 0)
{
sb.Append(" <- ");
}
sb.Append(current.GetType().Name).Append(": ").Append(current.Message);
current = current.InnerException;
}
return sb.ToString();
}
/// <summary>
/// The stack trace on one line, frames separated by <c> | </c>. A stack is what turns
/// "execution failed" into a line number, and the old format never wrote one.
/// </summary>
private static string FlattenStack(Exception ex)
{
string? stack = ex.StackTrace ?? ex.InnerException?.StackTrace;
if (string.IsNullOrWhiteSpace(stack))
{
return string.Empty;
}
StringBuilder sb = new(stack.Length);
foreach (string line in stack.Split('\n'))
{
string frame = line.Trim();
if (frame.Length == 0)
{
continue;
}
if (sb.Length > 0)
{
sb.Append(" | ");
}
sb.Append(frame);
}
return sb.ToString();
}
/// <summary>Drains the queue and flushes the file. Call before the process exits.</summary>
public static async Task ShutdownAsync()
{
@@ -356,13 +575,19 @@ public static class Log
private static string Color(LogLevel level) => level switch
{
LogLevel.Trace => "\u001b[90m",
LogLevel.Debug => "\u001b[36m",
LogLevel.Trace => "",
LogLevel.Debug => "",
LogLevel.Info => AnsiReset,
LogLevel.Warn => "\u001b[33m",
LogLevel.Error => "\u001b[31m",
LogLevel.Warn => "",
LogLevel.Error => "",
_ => AnsiReset,
};
private readonly record struct Entry(DateTime Timestamp, LogLevel Level, string Message, Exception? Exception);
internal readonly record struct Entry(
DateTime Timestamp,
LogLevel Level,
string Message,
Exception? Exception,
string? EventCode,
string Source);
}
+55 -62
View File
@@ -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>
+232 -217
View File
@@ -12,19 +12,16 @@ using Encelado.Bot.Engine;
using Encelado.Bot.Logging;
using Encelado.Bot.Ui;
using Encelado.Bot.Ui.Pages;
using Encelado.Core.Strategies;
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>
/// 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
{
@@ -32,15 +29,11 @@ public partial class MainWindow : Window, IUiActions
private readonly MainViewModel _vm;
private readonly BotSupervisor _supervisor;
private readonly DispatcherTimer _timer;
private readonly List<ChartWindow> _chartWindows = [];
private readonly StatusPage _status = new();
private readonly PositionsPage _positions = new();
private readonly ChartsPage _charts = new();
private readonly DashboardPage _dashboard = new();
private readonly LogPage _log = new();
private readonly OrdersPage _orders = new();
private readonly SettingsPage _settings = new();
private readonly AccountPage _account = new();
private readonly UserControl[] _pages;
private bool _busy;
private bool _closing;
@@ -61,22 +54,19 @@ public partial class MainWindow : Window, IUiActions
_supervisor.EventLogged += _vm.Log.Enqueue;
DataContext = _vm;
foreach (UserControl page in new UserControl[]
{ _status, _positions, _charts, _log, _settings, _account, _orders })
_pages = [_dashboard, _log, _settings];
foreach (UserControl page in _pages)
{
page.DataContext = _vm;
}
_status.Actions = this;
_positions.Actions = this;
_charts.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();
@@ -95,33 +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", "\uE80F", () => _status),
new("Conto", "\uE8C7", () => _account),
new("Posizioni", "\uE8A1", () => _positions),
new("Ordini", "\uE8A5", () => _orders),
new("Grafici", "\uE9D2", () => _charts),
new("Log", "\uE81C", () => _log),
new("Impostazioni", "\uE713", () => _settings),
];
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,72 +104,55 @@ public partial class MainWindow : Window, IUiActions
foreach (string warning in App.ConfigWarnings)
{
Log.Warn($"config: {warning}");
Log.Warn($"configurazione: {warning}");
}
Log.Info($"Encelado avviato — configurazione {App.ConfigPath}");
Log.Info($"log in {_config.Logging.ResolveDirectory()}");
Log.Info($"log in {_config.Logging.ResolveDirectory()}; orari mostrati nel fuso {UiClock.ZoneName}");
CredentialLookup lookup = CredentialResolver.Resolve(_config);
if (!lookup.Found)
if (App.SeedNote is { } seeded)
{
Log.Info("nessuna credenziale trovata: apro la finestra di login");
PromptForCredentials();
Log.Warn(seeded);
}
if (EtoroKeyStore.Resolve(_config, out string origin))
{
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();
AvvisaSeLaStrategiaNonEsistePiu();
WarnIfConfigurationIsStale();
}
/// <summary>
/// Se la configurazione nomina una strategia che non esiste più, lo dice adesso e
/// porta dov'è la soluzione.
/// <para>
/// Prima il problema veniva a galla solo premendo AVVIA, e sembrava un guasto: il
/// bot rifiutava di partire con un errore su un nome che l'utente non ricordava di
/// aver scritto. Capita perché l'installazione conserva l'<c>encelado.json</c>
/// esistente — che è giusto, le tarature sono sue — quindi un aggiornamento che
/// rimuove una strategia lascia dietro un riferimento morto.
/// </para>
/// 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.
/// </summary>
private void AvvisaSeLaStrategiaNonEsistePiu()
private void WarnIfConfigurationIsStale()
{
List<string> rotte = [.. _config.EnabledSymbols
.Where(static s => !StrategyFactory.IsKnown(s.Strategy))
.Select(static s => $"{s.Symbol}: '{s.Strategy}'")];
if (rotte.Count == 0)
bool stale = App.ConfigWarnings.Any(static w => w.Contains("versione precedente", StringComparison.Ordinal));
if (!stale)
{
return;
}
Log.Warn($"strategia non più disponibile in configurazione — {string.Join(", ", rotte)}");
Log.Warn("la configurazione contiene chiavi di una versione precedente");
MessageBox.Show(
this,
"La configurazione fa riferimento a una strategia che non esiste più:\n\n" +
string.Join("\n", rotte) + "\n\n" +
$"Disponibili: {string.Join(", ", StrategyFactory.Available)}.\n\n" +
"Succede dopo un aggiornamento, perché l'installazione non sovrascrive la tua " +
"configurazione. Vai in Impostazioni → Strategia, scegline una dall'elenco e salva: " +
"il bot non può partire finché resta così.",
"Strategia non disponibile",
"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);
// Portarlo dove si risolve vale più che dirgli dove andare.
foreach (NavItem item in Nav.Items.OfType<NavItem>())
{
if (item.Title == "Impostazioni")
{
Nav.SelectedItem = item;
break;
}
}
}
private void LoadLogo()
@@ -217,16 +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.
/// Ignored on builds that predate the attribute, which simply leaves it light.
/// </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;
@@ -254,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
{
"Posizioni" when _vm.OpenPositions > 0 =>
_vm.OpenPositions.ToString(System.Globalization.CultureInfo.CurrentCulture),
_ => string.Empty,
};
}
}
_vm.Clock = UiClock.Format(DateTime.UtcNow, "HH:mm:ss");
_vm.ClockZone = UiClock.Label;
}
// -----------------------------------------------------------------------
@@ -285,15 +216,14 @@ public partial class MainWindow : Window, IUiActions
return;
}
if (!_vm.IsRunning && !_config.Alpaca.Paper && !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;
@@ -317,21 +247,41 @@ public partial class MainWindow : Window, IUiActions
}
}
private bool ConfirmLiveTrading() =>
MessageBox.Show(
this,
"Questa configurazione opera sul conto LIVE con denaro reale.\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;
@@ -341,56 +291,176 @@ public partial class MainWindow : Window, IUiActions
// IUiActions — everything the pages can ask the shell to do
// -----------------------------------------------------------------------
public async Task ClosePositionAsync(string symbol)
public async Task CloseBasketAsync(string basket)
{
if (string.IsNullOrWhiteSpace(symbol))
if (string.IsNullOrWhiteSpace(basket))
{
return;
}
if (MessageBox.Show(
this,
$"Chiudere la posizione su {symbol} al prezzo di mercato?",
"Chiusura posizione",
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
.ClosePositionAsync(symbol, 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.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 = 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, never while the engine runs.</summary>
public void RestoreDefaults()
{
if (_vm.IsRunning)
{
MessageBox.Show(this,
"Ferma il bot prima di ripristinare la configurazione.\n\n" +
"Il motore legge la configurazione all'avvio: riscriverla mentre opera " +
"lascerebbe in esecuzione qualcosa che non corrisponde più a nessun file.",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (MessageBox.Show(
this,
$"Rimuovere le chiavi salvate per l'ambiente {(_config.Alpaca.Paper ? "PAPER" : "LIVE")}?",
"Rimozione credenziali",
"Riscrivere l'intera configurazione con i valori di fabbrica?\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 eToro e strategy.json non vengono toccati.",
"Ripristino dei valori predefiniti",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxImage.Warning,
MessageBoxResult.No) != MessageBoxResult.Yes)
{
return;
}
bool removed = CredentialStore.Clear(_config.Alpaca.Paper);
Log.Info(removed ? "credenziali salvate rimosse" : "non c'erano credenziali salvate da rimuovere");
RefreshSettings();
string? backup;
try
{
backup = ConfigDefaults.Restore(App.ConfigPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
MessageBox.Show(this,
$"Non sono riuscito a riscrivere la configurazione:\n\n{ex.Message}",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
Log.Info(backup is null
? "configurazione ripristinata ai valori predefiniti"
: $"configurazione ripristinata; la precedente è in {backup}");
MessageBox.Show(this,
"Configurazione ripristinata.\n\n" +
(backup is null ? string.Empty : $"La precedente è stata salvata in:\n{backup}\n\n") +
"Riavvia l'applicazione perché i nuovi valori vengano caricati.",
"Ripristino completato", MessageBoxButton.OK, MessageBoxImage.Information);
}
public void OpenConfigFile() => OpenInShell(App.ConfigPath);
public void 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();
@@ -505,44 +575,6 @@ public partial class MainWindow : Window, IUiActions
}
}
public void OpenChartWindow(string symbol)
{
SymbolChartViewModel? chart = null;
foreach (SymbolChartViewModel candidate in _vm.Charts)
{
if (string.Equals(candidate.Symbol, symbol, StringComparison.OrdinalIgnoreCase))
{
chart = candidate;
break;
}
}
if (chart is null)
{
return;
}
// Raise the existing one rather than stacking duplicates on top of each other.
foreach (ChartWindow open in _chartWindows)
{
if (string.Equals(open.Symbol, symbol, StringComparison.OrdinalIgnoreCase))
{
if (open.WindowState == WindowState.Minimized)
{
open.WindowState = WindowState.Normal;
}
open.Activate();
return;
}
}
ChartWindow window = new(chart) { Owner = this };
_chartWindows.Add(window);
window.Closed += (_, _) => _chartWindows.Remove(window);
window.Show();
}
private static void OpenInShell(string path)
{
try
@@ -561,25 +593,23 @@ public partial class MainWindow : Window, IUiActions
private void RefreshSettings()
{
CredentialLookup lookup = CredentialResolver.Resolve(_config);
string status = lookup.Found
? $"Origine: {lookup.Describe()} — ambiente {(_config.Alpaca.Paper ? "PAPER" : "LIVE")}."
: "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 — motore di trading automatico su Alpaca.\n" +
"Encelado — Correlation Baskets su eToro (CFD forex).\n" +
$"Versione {Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}\n" +
$"Configurazione: {App.ConfigPath}\n" +
$"Endpoint: {_config.Alpaca.TradingBaseUrl} feed dati: {_config.Alpaca.DataFeed}";
$"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);
_account.Describe(_config.Risk);
}
// -----------------------------------------------------------------------
@@ -589,24 +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;
@@ -619,7 +638,10 @@ public partial class MainWindow : Window, IUiActions
MessageBox.Show(
this,
"Il bot è in esecuzione. Chiudere l'applicazione lo ferma.\n\n" +
"Le posizioni aperte restano aperte sul conto Alpaca.\n\nContinuare?",
(_config.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,
MessageBoxImage.Warning,
@@ -634,23 +656,16 @@ public partial class MainWindow : Window, IUiActions
_timer.Stop();
_supervisor.EventLogged -= _vm.Log.Enqueue;
foreach (ChartWindow window in _chartWindows.ToArray())
{
window.Close();
}
try
{
await _supervisor.DisposeAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
// Uno spegnimento andato storto non deve lasciare la finestra inchiodata:
// si registra e si chiude comunque.
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);
@@ -1,50 +0,0 @@
<Window x:Class="Encelado.Bot.Ui.ChartWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:Encelado.Bot.Ui"
Height="620" Width="1020" MinHeight="320" MinWidth="520"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Bg}"
UseLayoutRounding="True"
TextOptions.TextRenderingMode="ClearType">
<DockPanel Margin="14">
<Border DockPanel.Dock="Top" Style="{StaticResource Card}" Padding="16,13" Margin="0,0,0,10">
<Grid>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Symbol}" FontSize="17" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<TextBlock FontFamily="{StaticResource Mono}" FontSize="25" FontWeight="SemiBold"
Margin="16,0,0,0" VerticalAlignment="Center"
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
<TextBlock FontFamily="{StaticResource Mono}" FontSize="13.5" Margin="12,0,0,0"
VerticalAlignment="Center"
Foreground="{Binding ChangePct, Converter={StaticResource PnlBrush}}"
Text="{Binding ChangePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Style="{StaticResource Sub}" Margin="0,0,14,0"
Text="{Binding SessionHigh, Converter={StaticResource Price}, StringFormat='max {0}'}"/>
<TextBlock Style="{StaticResource Sub}" Margin="0,0,20,0"
Text="{Binding SessionLow, Converter={StaticResource Price}, StringFormat='min {0}'}"/>
<RadioButton x:Name="CandleMode" Content="Candele" IsChecked="True" GroupName="mode"
Checked="OnModeChanged" Margin="0,0,12,0"
Foreground="{StaticResource Dim}" FontSize="12.5" VerticalAlignment="Center"/>
<RadioButton x:Name="LiveMode" Content="Diretta" GroupName="mode" Checked="OnModeChanged"
Foreground="{StaticResource Dim}" FontSize="12.5" VerticalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
<Border Style="{StaticResource Card}">
<ui:PriceChart x:Name="Chart"
Opens="{Binding Opens}" Highs="{Binding Highs}"
Lows="{Binding Lows}" Closes="{Binding Closes}"
LivePrices="{Binding Live}"
EmptyText="in attesa di dati — avvia il bot"/>
</Border>
</DockPanel>
</Window>
@@ -1,33 +0,0 @@
using System.Windows;
namespace Encelado.Bot.Ui;
/// <summary>
/// One symbol's chart in its own window, so it can be put on a second monitor and left
/// there. It binds to the same <see cref="SymbolChartViewModel"/> the main window uses,
/// which means it updates on the same tick without any extra plumbing.
/// </summary>
public partial class ChartWindow : Window
{
public ChartWindow(SymbolChartViewModel chart)
{
ArgumentNullException.ThrowIfNull(chart);
InitializeComponent();
DataContext = chart;
Title = $"{chart.Symbol} — Encelado";
}
/// <summary>The symbol this window is showing, so the shell can raise an existing one.</summary>
public string Symbol => ((SymbolChartViewModel)DataContext).Symbol;
private void OnModeChanged(object sender, RoutedEventArgs e)
{
if (Chart is null)
{
return;
}
Chart.Mode = LiveMode.IsChecked == true ? PriceChartMode.Live : PriceChartMode.Candles;
}
}
+11 -146
View File
@@ -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();
}
@@ -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 Alpaca"
Width="520" SizeToContent="Height"
Title="Chiavi eToro"
Width="600" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
Background="{StaticResource Bg}"
@@ -11,30 +11,30 @@
<Border Padding="24">
<StackPanel>
<TextBlock Text="Credenziali Alpaca" FontSize="19" FontWeight="SemiBold"/>
<TextBlock x:Name="EnvLine" Style="{StaticResource Sub}" Margin="0,4,0,0"/>
<TextBlock Text="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="Genera una coppia di chiavi dalla dashboard Alpaca. 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"
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 ID" 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 KEY" 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,103 +0,0 @@
using System.Windows;
using Encelado.Alpaca.Rest;
using Encelado.Bot.Configuration;
namespace Encelado.Bot.Ui;
/// <summary>
/// Collects an Alpaca key pair and proves it works before accepting it. Verifying here
/// rather than at the first order means a typo surfaces immediately, with a message
/// that says what is wrong, instead of as a 401 twenty minutes into a session.
/// </summary>
public partial class LoginWindow : Window
{
private readonly BotConfig _config;
public LoginWindow(BotConfig config)
{
InitializeComponent();
_config = config;
bool paper = config.Alpaca.Paper;
EnvLine.Text = paper
? "Ambiente PAPER — conto simulato, denaro finto."
: "Ambiente LIVE — denaro reale.";
PortalLink.Text = paper
? "https://app.alpaca.markets/paper/dashboard/overview"
: "https://app.alpaca.markets/live/dashboard/overview";
StorageNote.Text = CredentialStore.IsEncrypted
? "Salvate cifrate con DPAPI: leggibili solo dal tuo account Windows."
: "Su questo sistema DPAPI non è disponibile: il file sarà in chiaro, con permessi di solo proprietario.";
if (CredentialStore.Load(paper) is { } existing)
{
KeyBox.Text = existing.KeyId;
ShowStatus($"Sono già salvate delle chiavi ({CredentialStore.Mask(existing.KeyId)}). " +
"Inseriscine di nuove per sostituirle.", warning: false);
}
Loaded += (_, _) => KeyBox.Focus();
}
private async void OnConfirm(object sender, RoutedEventArgs e)
{
string keyId = CredentialStore.Clean(KeyBox.Text) ?? string.Empty;
string secret = CredentialStore.Clean(SecretBox.Password) ?? string.Empty;
if (keyId.Length == 0 || secret.Length == 0)
{
ShowStatus("Inserisci sia la key id sia il secret.", warning: true);
return;
}
SetBusy(true);
try
{
(bool ok, string message, AlpacaAccount? account) = await CredentialResolver
.VerifyAsync(keyId, secret, _config.Alpaca.Paper, CancellationToken.None)
.ConfigureAwait(true);
if (!ok)
{
ShowStatus(message, warning: true);
return;
}
CredentialResolver.Apply(_config, keyId, secret, SaveBox.IsChecked == true);
string where = SaveBox.IsChecked == true
? $" Salvate in {CredentialStore.FilePath}."
: " Non salvate: valgono solo per questa sessione.";
ShowStatus($"{message} — equity {account!.Equity:N2} {account.Currency}.{where}", warning: false);
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;
}
}
+159 -175
View File
@@ -7,14 +7,15 @@ using Encelado.Bot.Engine;
namespace Encelado.Bot.Ui;
/// <summary>
/// Presentation state for the operating tab. 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 = "PAPER";
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,65 +24,60 @@ public sealed class MainViewModel : INotifyPropertyChanged
private string _banner = string.Empty;
private bool _hasBanner;
private bool _bannerIsWarning;
private double _equity;
private double _cash;
private double _pnlToday;
private double _pnlTodayPct;
private double _pnlSession;
private double _pnlSessionPct;
private double _pnlAllTime;
private double _pnlAllTimePct;
private bool _hasAllTime;
private double _unrealized;
private double _exposurePct;
private int _openPositions;
private int _maxOpenPositions;
private int _tradesToday;
private int _maxTradesPerDay;
private double _maxDailyLossPct;
private double _riskPerTradePct;
private string _sessionStatus = "";
private string _marketData = "—";
private string _tradeStream = "—";
private double _balance;
private double _availableBalance;
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 _latency = string.Empty;
private string _counters = string.Empty;
private IReadOnlyList<double> _equityCurve = [];
public ObservableCollection<PositionRow> Positions { get; } = [];
public ObservableCollection<SymbolRow> Symbols { get; } = [];
private string _nextEvent = "—";
private string _volForecast = "—";
private string _mlState = "—";
private string _banditProposal = "—";
private string _calendarState = "—";
private string _newsState = "—";
public ObservableCollection<EventRow> Events { get; } = [];
public ObservableCollection<OrderRow> Orders { get; } = [];
public ObservableCollection<BasketRow> Baskets { get; } = [];
/// <summary>
/// One entry per charted symbol, created once and updated in place. See
/// <see cref="SymbolChartViewModel"/> for why these are not swapped each tick.
/// </summary>
public ObservableCollection<SymbolChartViewModel> Charts { get; } = [];
public ObservableCollection<QuoteRow> Quotes { get; } = [];
private AccountRow? _account;
public ObservableCollection<SentimentRow> Sentiment { get; } = [];
/// <summary>The broker's own account view, or null before the first reconcile.</summary>
public AccountRow? Account { get => _account; private set => Set(ref _account, value); }
public ObservableCollection<CalendarRow> NextEvents { get; } = [];
public bool HasAccount => _account is not null;
public IReadOnlyList<string> Presets { get; } = ["CONSERVATIVE", "MODERATE", "AGGRESSIVE"];
/// <summary>How many lines the status strip keeps. Set once from the configuration.</summary>
/// <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); }
@@ -89,22 +85,11 @@ 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");
/// <summary>
/// Set by the shell around an in-flight start or stop. Guards against a second click
/// landing before the engine has reported its new state.
/// </summary>
public bool IsBusy
{
get => _isBusy;
@@ -131,72 +116,78 @@ public sealed class MainViewModel : INotifyPropertyChanged
public double Equity { get => _equity; private set => Set(ref _equity, value); }
public double Cash { get => _cash; private set => Set(ref _cash, value); }
public double Balance { get => _balance; private set => Set(ref _balance, value); }
public double PnlToday { get => _pnlToday; private set => Set(ref _pnlToday, value); }
public double AvailableBalance { get => _availableBalance; private set => Set(ref _availableBalance, value); }
public double PnlTodayPct { get => _pnlTodayPct; private set => Set(ref _pnlTodayPct, value); }
public double PeakEquity { get => _peakEquity; private set => Set(ref _peakEquity, value); }
public double PnlSession { get => _pnlSession; private set => Set(ref _pnlSession, value); }
public double DrawdownPct { get => _drawdownPct; private set => Set(ref _drawdownPct, value); }
public double PnlSessionPct { get => _pnlSessionPct; private set => Set(ref _pnlSessionPct, value); }
public double EquityStopPct { get => _equityStopPct; private set => Set(ref _equityStopPct, value); }
public double PnlAllTime { get => _pnlAllTime; private set => Set(ref _pnlAllTime, value); }
public string DrawdownSub => _equityStopPct > 0
? string.Create(CultureInfo.CurrentCulture, $"picco {_peakEquity:N2} · stop a {_equityStopPct:P0}")
: string.Create(CultureInfo.CurrentCulture, $"picco {_peakEquity:N2}");
public double PnlAllTimePct { get => _pnlAllTimePct; private set => Set(ref _pnlAllTimePct, value); }
public double TodayPnl { get => _todayPnl; private set => Set(ref _todayPnl, value); }
public bool HasAllTime { get => _hasAllTime; private set => Set(ref _hasAllTime, value); }
public double TodayPnlPct { get => _todayPnlPct; private set => Set(ref _todayPnlPct, value); }
public double Unrealized { get => _unrealized; private set => Set(ref _unrealized, value); }
public double OpenPnl { get => _openPnl; private set => Set(ref _openPnl, value); }
public double ExposurePct { get => _exposurePct; private set => Set(ref _exposurePct, value); }
public double OpenPnlPct { get => _openPnlPct; private set => Set(ref _openPnlPct, value); }
public int OpenPositions { get => _openPositions; private set => Set(ref _openPositions, value); }
public int OpenBaskets { get => _openBaskets; private set => Set(ref _openBaskets, value); }
public int MaxOpenPositions { get => _maxOpenPositions; private set => Set(ref _maxOpenPositions, value); }
public int MaxBaskets { get => _maxBaskets; private set => Set(ref _maxBaskets, value); }
public int TradesToday { get => _tradesToday; private set => Set(ref _tradesToday, value); }
public string BasketsDisplay => _maxBaskets > 0 ? $"{_openBaskets} / {_maxBaskets}" : _openBaskets.ToString(CultureInfo.CurrentCulture);
public int MaxTradesPerDay { get => _maxTradesPerDay; private set => Set(ref _maxTradesPerDay, value); }
public bool EquityStopped { get => _equityStopped; private set => Set(ref _equityStopped, value); }
/// <summary>
/// Open positions, with the cap appended only when there is one. A limit of 0 means
/// "no limit", so rendering it as "0 / 0" states the opposite of what it means.
/// </summary>
public string PositionsDisplay => Counter(_openPositions, _maxOpenPositions);
public bool KillSwitched { get => _killSwitched; private set => Set(ref _killSwitched, value); }
public string TradesDisplay => Counter(_tradesToday, _maxTradesPerDay);
/// <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); }
private static string Counter(int value, int limit) =>
limit > 0
? string.Create(CultureInfo.CurrentCulture, $"{value} / {limit}")
: value.ToString(CultureInfo.CurrentCulture);
public string StrategyVersion { get => _strategyVersion; private set => Set(ref _strategyVersion, value); }
/// <summary>The loss that halts the session, shown on the account page.</summary>
public double MaxDailyLossPct { get => _maxDailyLossPct; private set => Set(ref _maxDailyLossPct, value); }
public string ApiState { get => _apiState; private set => Set(ref _apiState, value); }
public double RiskPerTradePct { get => _riskPerTradePct; private set => Set(ref _riskPerTradePct, value); }
public string ApiLatency { get => _apiLatency; private set => Set(ref _apiLatency, value); }
public string SessionStatus { get => _sessionStatus; private set => Set(ref _sessionStatus, 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 MarketDataState { get => _marketData; private set => Set(ref _marketData, value); }
public string ClockZone { get => _clockZone; set => Set(ref _clockZone, value); }
public string TradeStreamState { get => _tradeStream; private set => Set(ref _tradeStream, 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 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.Paper ? "paper" : "live";
ModeKind = s.EnvironmentKind;
ExecutionMode = s.ExecutionMode;
IsRunning = s.State is BotState.Running or BotState.Starting;
PowerText = IsRunning ? "FERMA" : "AVVIA";
@@ -214,115 +205,108 @@ public sealed class MainViewModel : INotifyPropertyChanged
_ => "fermo",
};
if (s.Halted)
ApplyBanner(s);
Equity = s.Equity;
Balance = s.Balance;
AvailableBalance = s.AvailableBalance;
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;
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) : "—";
Counters = s.Counters;
if (s.Context is { } c)
{
Banner = $"KILL SWITCH — {s.HaltReason}";
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);
}
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.</summary>
private void ApplyBanner(BotSnapshot s)
{
if (s.EquityStopped)
{
Banner = $"EQUITY STOP — {s.HaltReason}. Serve un reset manuale con motivazione.";
HasBanner = true;
BannerIsWarning = false;
return;
}
else if (!string.IsNullOrEmpty(s.StreamRejection))
if (s.Halted)
{
// Above the engine error on purpose: when the broker is refusing the data
// stream, that is the cause and anything else is a consequence.
Banner = $"DATI DI MERCATO — {s.StreamRejection}";
Banner = $"OPERATIVITÀ SOSPESA — {s.HaltReason}";
HasBanner = true;
BannerIsWarning = true;
BannerIsWarning = false;
return;
}
else if (!string.IsNullOrEmpty(s.Error))
if (!string.IsNullOrEmpty(s.Error))
{
Banner = s.Error;
HasBanner = true;
BannerIsWarning = false;
return;
}
else 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;
}
else
if (IsRunning && s.ApiState is "caduta" or "disconnesso")
{
HasBanner = false;
Banner = "Collegamento a eToro caduto: il motore prova a riconnettersi da solo.";
HasBanner = true;
BannerIsWarning = true;
return;
}
Equity = s.Equity;
Cash = s.Cash;
PnlToday = s.PnlToday;
PnlTodayPct = s.PnlTodayPct;
PnlSession = s.PnlSession;
PnlSessionPct = s.PnlSessionPct;
PnlAllTime = s.PnlAllTime;
PnlAllTimePct = s.PnlAllTimePct;
HasAllTime = s.HasAllTime;
Unrealized = s.UnrealizedPnl;
ExposurePct = s.ExposurePct;
OpenPositions = s.OpenPositions;
MaxOpenPositions = s.MaxOpenPositions;
TradesToday = s.TradesToday;
MaxTradesPerDay = s.MaxTradesPerDay;
MaxDailyLossPct = s.MaxDailyLossPct;
RiskPerTradePct = s.RiskPerTradePct;
Raise(nameof(PositionsDisplay));
Raise(nameof(TradesDisplay));
SessionStatus = s.SessionStatus;
MarketDataState = s.MarketDataState;
TradeStreamState = s.TradeStreamState;
Uptime = s.Uptime > TimeSpan.Zero ? FormatUptime(s.Uptime) : "—";
Latency = $"{s.BarToSignal}\n{s.SignalToOrder}";
Counters = $"barre {s.Bars} segnali {s.Signals} ordini {s.Orders} fill {s.Fills} " +
$"uscite {s.Exits} blocchi risk {s.RiskRejects} errori {s.Errors}";
double[] curve = new double[s.EquityCurve.Count];
for (int i = 0; i < curve.Length; i++)
if (s.EnvironmentKind == "live" && IsRunning)
{
curve[i] = s.EquityCurve[i].Equity;
Banner = "Conto REALE: gli ordini impegnano denaro vero.";
HasBanner = true;
BannerIsWarning = true;
return;
}
EquityCurve = curve;
Account = s.Account;
Raise(nameof(HasAccount));
Sync(Positions, s.Positions, static (a, b) => a.Symbol == b.Symbol);
Sync(Symbols, s.Symbols, static (a, b) => a.Symbol == b.Symbol);
Sync(Orders, s.OrderHistory, static (a, b) => a.OrderId == b.OrderId);
SyncCharts(s.Prices);
SyncEvents(s.Events);
}
/// <summary>
/// Matches chart view models to symbols by name and updates them in place. The
/// collection only changes when the configured symbol set does, which is never
/// while the bot is running.
/// </summary>
private void SyncCharts(IReadOnlyList<PriceSeriesRow> source)
{
foreach (PriceSeriesRow row in source)
{
SymbolChartViewModel? target = null;
foreach (SymbolChartViewModel candidate in Charts)
{
if (string.Equals(candidate.Symbol, row.Symbol, StringComparison.OrdinalIgnoreCase))
{
target = candidate;
break;
}
}
if (target is null)
{
target = new SymbolChartViewModel(row.Symbol);
Charts.Add(target);
}
target.Apply(row);
}
HasBanner = false;
}
/// <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,73 +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 all
/// seven 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
{
Task ClosePositionAsync(string symbol);
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>Opens the price chart for a symbol in its own resizable window.</summary>
void OpenChartWindow(string symbol);
}
@@ -1,207 +0,0 @@
<UserControl x:Class="Encelado.Bot.Ui.Pages.AccountPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
<UserControl.Resources>
<!-- One label/value line, the shape the whole page is built from. -->
<Style x:Key="Row" TargetType="Grid">
<Setter Property="Margin" Value="0,0,0,9"/>
</Style>
</UserControl.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
<StackPanel>
<StackPanel Style="{StaticResource PageHeader}">
<TextBlock Text="Conto" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Hint}"
ToolTip="I valori arrivano da Alpaca e sono aggiornati a ogni riconciliazione, circa ogni 30 secondi. Sono quello che vede il broker, non un calcolo del bot: se non coincidono con la pagina Stato, quelli giusti sono questi."/>
</StackPanel>
<!-- Nothing truthful to show before the first reconcile. -->
<Border Style="{StaticResource Card}" Padding="26"
Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}, ConverterParameter=invert}">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="&#xE9F3;" FontFamily="Segoe MDL2 Assets" FontSize="26"
Foreground="{StaticResource Faint}" HorizontalAlignment="Center"/>
<TextBlock Style="{StaticResource Sub}" Margin="0,10,0,0" TextAlignment="Center"
Text="Nessun dato dal conto.&#x0a;Avvia il bot: i valori compaiono alla prima riconciliazione."/>
</StackPanel>
</Border>
<StackPanel Visibility="{Binding HasAccount, Converter={StaticResource BoolVis}}">
<!-- ==================== headline ==================== -->
<UniformGrid Rows="1" Columns="4" Margin="0,0,-10,10">
<Border Style="{StaticResource Kpi}">
<StackPanel>
<TextBlock Text="VALORE DEL PORTAFOGLIO" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.PortfolioValue, StringFormat=N2}"/>
<TextBlock Style="{StaticResource Sub}" Text="{Binding Account.Currency}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource Kpi}">
<StackPanel>
<TextBlock Text="VARIAZIONE DI OGGI" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}"
Text="{Binding Account.ChangeToday, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
Foreground="{Binding Account.ChangeToday, Converter={StaticResource PnlBrush}}"/>
<TextBlock Style="{StaticResource Sub}"
Text="{Binding Account.ChangeTodayPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource Kpi}">
<StackPanel>
<TextBlock Text="LIQUIDITÀ" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.Cash, StringFormat=N2}"/>
<TextBlock Style="{StaticResource Sub}" Text="non investita"/>
</StackPanel>
</Border>
<Border Style="{StaticResource Kpi}">
<StackPanel>
<TextBlock Text="POTERE D'ACQUISTO" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}" Text="{Binding Account.BuyingPower, StringFormat=N2}"/>
<TextBlock Style="{StaticResource Sub}"
Text="{Binding Account.Multiplier, StringFormat='leva {0:0.#}x'}"/>
</StackPanel>
</Border>
</UniformGrid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- ==================== identity ==================== -->
<Border Grid.Column="0" Style="{StaticResource Card}" VerticalAlignment="Top">
<StackPanel>
<TextBlock Text="ANAGRAFICA" Style="{StaticResource Head}"/>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Numero di conto" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Text="{Binding Account.AccountNumber}"/>
</Grid>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Stato" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Text="{Binding Account.Status}"/>
</Grid>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Ambiente" Style="{StaticResource Sub}" Margin="0"/>
<Border Style="{StaticResource ModeBadge}" HorizontalAlignment="Right" Padding="7,2">
<TextBlock Text="{Binding Mode}" FontFamily="{StaticResource Mono}"
FontSize="10.5" FontWeight="SemiBold"/>
</Border>
</Grid>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Vendita allo scoperto" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Text="{Binding Account.ShortingEnabled, Converter={StaticResource YesNo}}"/>
</Grid>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Ultimo aggiornamento" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Foreground="{StaticResource Dim}"
Text="{Binding Account.UpdatedUtc, Converter={StaticResource LocalTime}}"/>
</Grid>
</StackPanel>
</Border>
<!-- ==================== balances & limits ==================== -->
<Border Grid.Column="2" Style="{StaticResource Card}" VerticalAlignment="Top">
<StackPanel>
<TextBlock Text="SALDI E LIMITI" Style="{StaticResource Head}"/>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Equity" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Text="{Binding Account.Equity, StringFormat=N2}"/>
</Grid>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Equity alla chiusura precedente" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Text="{Binding Account.LastEquity, StringFormat=N2}"/>
</Grid>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Potere d'acquisto intraday" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Text="{Binding Account.DaytradingBuyingPower, StringFormat=N2}"/>
</Grid>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Operazioni intraday (5 giorni)" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Text="{Binding Account.DaytradeCount}"/>
</Grid>
<Grid Style="{StaticResource Row}">
<TextBlock Text="Pattern day trader" Style="{StaticResource Sub}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" FontFamily="{StaticResource Mono}" FontSize="12"
Text="{Binding Account.PatternDayTrader, Converter={StaticResource YesNo}}"/>
</Grid>
<Border Background="{StaticResource Bg}" BorderBrush="{StaticResource Line}"
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,4,0,0">
<StackPanel>
<TextBlock Text="RESTRIZIONI" Style="{StaticResource Label}" FontSize="9"/>
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11.5"
TextWrapping="Wrap" Text="{Binding Account.Restrictions}"
Foreground="{Binding Account.Restrictions, Converter={StaticResource RestrictionBrush}}"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
</Grid>
<!-- ==================== what the bot is allowed to do ==================== -->
<Border Style="{StaticResource Card}" Margin="0,10,0,20">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="LIMITI IMPOSTI DAL BOT" Style="{StaticResource Head}" Margin="0"/>
<TextBlock Style="{StaticResource Hint}" FontSize="12"
ToolTip="Questi non vengono dal broker: sono i vincoli della configurazione, e si applicano prima che un ordine venga inviato. Il broker dice cosa è possibile, questi dicono cosa è permesso."/>
</StackPanel>
<UniformGrid Rows="1" Columns="4" Margin="0,0,-8,0">
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
<StackPanel>
<TextBlock Text="DIMENSIONE" Style="{StaticResource Label}" FontSize="9"/>
<TextBlock x:Name="SizingText" Margin="0,4,0,0" FontFamily="{StaticResource Mono}"
FontSize="11" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
<StackPanel>
<TextBlock Text="PERDITA MASSIMA GIORNALIERA" Style="{StaticResource Label}" FontSize="9"/>
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
Text="{Binding MaxDailyLossPct, StringFormat=P2}"/>
</StackPanel>
</Border>
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
<StackPanel>
<TextBlock Text="POSIZIONI CONTEMPORANEE" Style="{StaticResource Label}" FontSize="9"/>
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
Text="{Binding MaxOpenPositions}"/>
</StackPanel>
</Border>
<Border Background="{StaticResource Panel2}" BorderBrush="{StaticResource Line}"
BorderThickness="1" CornerRadius="7" Padding="11,9" Margin="0,0,8,0">
<StackPanel>
<TextBlock Text="OPERAZIONI AL GIORNO" Style="{StaticResource Label}" FontSize="9"/>
<TextBlock Margin="0,4,0,0" FontFamily="{StaticResource Mono}" FontSize="11"
Text="{Binding MaxTradesPerDay}"/>
</StackPanel>
</Border>
</UniformGrid>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -1,24 +0,0 @@
using System.Windows.Controls;
using Encelado.Core.Risk;
namespace Encelado.Bot.Ui.Pages;
/// <summary>
/// The broker's view of the account, plus the limits the bot imposes on top of it.
/// Both are shown because they answer different questions: the broker says what is
/// possible, the configuration says what is permitted.
/// </summary>
public partial class AccountPage : UserControl
{
public AccountPage() => InitializeComponent();
/// <summary>
/// Set once by the shell. The sizing rule is described from the configuration
/// rather than bound, because it is one sentence assembled from three fields.
/// </summary>
public void Describe(RiskLimits limits)
{
ArgumentNullException.ThrowIfNull(limits);
SizingText.Text = limits.DescribeSizing();
}
}
@@ -1,71 +0,0 @@
<UserControl x:Class="Encelado.Bot.Ui.Pages.ChartsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Grafici" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Hint}"
ToolTip="Le candele sono le barre su cui la strategia decide davvero, una al giorno. La linea è il prezzo in diretta, campionato una volta al secondo, e serve a vedere cosa succede fra una decisione e l'altra — non a suggerirne un'altra."/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<RadioButton x:Name="CandleMode" Content="Candele" IsChecked="True"
GroupName="mode" Checked="OnModeChanged" Margin="0,0,14,0"
Foreground="{StaticResource Dim}" FontSize="12.5"/>
<RadioButton x:Name="LiveMode" Content="Diretta" GroupName="mode" Checked="OnModeChanged"
Foreground="{StaticResource Dim}" FontSize="12.5"/>
</StackPanel>
</Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
<ItemsControl ItemsSource="{Binding Charts}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,10">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Symbol}" FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<TextBlock FontFamily="{StaticResource Mono}" FontSize="19" FontWeight="SemiBold"
Margin="14,0,0,0" VerticalAlignment="Center"
Text="{Binding LastPrice, Converter={StaticResource Price}}"/>
<TextBlock FontFamily="{StaticResource Mono}" FontSize="12.5" Margin="10,0,0,0"
VerticalAlignment="Center"
Foreground="{Binding ChangePct, Converter={StaticResource PnlBrush}}"
Text="{Binding ChangePct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Style="{StaticResource Sub}" Margin="0,0,12,0"
Text="{Binding SessionHigh, Converter={StaticResource Price}, StringFormat='max {0}'}"/>
<TextBlock Style="{StaticResource Sub}" Margin="0,0,14,0"
Text="{Binding SessionLow, Converter={StaticResource Price}, StringFormat='min {0}'}"/>
<Button Content="Finestra separata" Padding="10,4" FontSize="11"
Click="OnPopOut" Tag="{Binding Symbol}"/>
</StackPanel>
</Grid>
<!-- Bound to the page's own property, not to the view model: which way
the charts are drawn is a view preference, not bot state. -->
<ui:PriceChart Height="300"
Mode="{Binding ChartMode,
RelativeSource={RelativeSource AncestorType=UserControl}}"
Opens="{Binding Opens}" Highs="{Binding Highs}"
Lows="{Binding Lows}" Closes="{Binding Closes}"
LivePrices="{Binding Live}"
EmptyText="in attesa di dati — avvia il bot"/>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -1,44 +0,0 @@
using System.Windows;
using System.Windows.Controls;
namespace Encelado.Bot.Ui.Pages;
public partial class ChartsPage : UserControl
{
/// <summary>
/// A dependency property rather than a plain field so the charts inside the
/// ItemsControl can bind to it and repaint themselves when it changes.
/// </summary>
public static readonly DependencyProperty ChartModeProperty = DependencyProperty.Register(
nameof(ChartMode), typeof(PriceChartMode), typeof(ChartsPage),
new FrameworkPropertyMetadata(PriceChartMode.Candles));
public ChartsPage() => InitializeComponent();
public IUiActions? Actions { get; set; }
public PriceChartMode ChartMode
{
get => (PriceChartMode)GetValue(ChartModeProperty);
set => SetValue(ChartModeProperty, value);
}
private void OnModeChanged(object sender, RoutedEventArgs e)
{
// Fires during InitializeComponent, before the field is assigned.
if (LiveMode is null)
{
return;
}
ChartMode = LiveMode.IsChecked == true ? PriceChartMode.Live : PriceChartMode.Candles;
}
private void OnPopOut(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement { Tag: string symbol } && symbol.Length > 0)
{
Actions?.OpenChartWindow(symbol);
}
}
}
@@ -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&amp;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&amp;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&amp;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&amp;L aperto complessivo dei basket, netto dei costi già maturati, e in percentuale dell'equity.">
<StackPanel>
<TextBlock Text="P&amp;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&amp;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,96 +0,0 @@
<UserControl x:Class="Encelado.Bot.Ui.Pages.OrdersPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
<TextBlock Text="Ordini" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Hint}"
ToolTip="Lo storico come lo riporta Alpaca, non come lo ricorda il bot: dopo una riconnessione l'elenco del broker è l'unico completo. Si aggiorna a ogni riconciliazione, circa ogni 30 secondi. Le righe evidenziate sono ordini ancora aperti."/>
</StackPanel>
<Border Style="{StaticResource Card}" Padding="0,14,0,6">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="16,0,16,8">
<TextBlock Text="STORICO" Style="{StaticResource Head}" Margin="0"/>
<TextBlock HorizontalAlignment="Right" Style="{StaticResource Label}">
<Run Text="{Binding Orders.Count, Mode=OneWay}"/><Run Text=" ordini"/>
</TextBlock>
</Grid>
<TextBlock DockPanel.Dock="Bottom" HorizontalAlignment="Center" Margin="0,26,0,26"
TextAlignment="Center"
Text="Nessun ordine.&#x0a;Compaiono qui appena il bot ne invia uno, o se il conto ne ha di precedenti.">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Orders.Count}" Value="0">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<DataGrid ItemsSource="{Binding Orders}">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow" BasedOn="{StaticResource {x:Type DataGridRow}}">
<Style.Triggers>
<!-- A working order is the one thing on this page that can still change. -->
<DataTrigger Binding="{Binding IsWorking}" Value="True">
<Setter Property="Background" Value="#145B8CFF"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="ORA" Width="1.3*" Binding="{Binding SubmittedLocal}">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="ASSET" Width="1.2*" Binding="{Binding Symbol}">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="LATO" Width="1*" Binding="{Binding Side}">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Side, Converter={StaticResource SideBrush}}"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="TIPO" Width="0.9*" Binding="{Binding Type}"/>
<DataGridTextColumn Header="QUANTITÀ" Width="1.2*"
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
<DataGridTextColumn Header="ESEGUITA" Width="1.2*"
Binding="{Binding FilledQuantity, Converter={StaticResource Qty}}"/>
<DataGridTextColumn Header="PREZZO MEDIO" Width="1.3*"
Binding="{Binding FilledAveragePrice, Converter={StaticResource Price}}"/>
<DataGridTextColumn Header="CONTROVALORE" Width="1.3*"
Binding="{Binding Notional, StringFormat=N2}"/>
<DataGridTextColumn Header="STATO" Width="1.2*" Binding="{Binding Status}">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Status, Converter={StaticResource OrderStatusBrush}}"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
</DockPanel>
</UserControl>
@@ -1,8 +0,0 @@
using System.Windows.Controls;
namespace Encelado.Bot.Ui.Pages;
public partial class OrdersPage : UserControl
{
public OrdersPage() => InitializeComponent();
}
@@ -1,115 +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="Posizioni" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Hint}"
ToolTip="Tutto ciò che è aperto adesso. Lo stop in tabella è sorvegliato dal motore a ogni quotazione, non è un ordine depositato sul broker: Alpaca non accetta bracket order sulle crypto. Se il processo si chiude con una posizione aperta, quella posizione resta senza stop."/>
</StackPanel>
<!-- Totals strip: reading a dozen rows to work out the exposure is what this avoids. -->
<Border DockPanel.Dock="Top" Style="{StaticResource Card}" Margin="0,0,0,10" Padding="16,13">
<UniformGrid Rows="1" Columns="4">
<StackPanel>
<TextBlock Text="APERTE" Style="{StaticResource Label}"/>
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
Text="{Binding PositionsDisplay}"/>
</StackPanel>
<StackPanel>
<TextBlock Text="ESPOSIZIONE" Style="{StaticResource Label}"/>
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
Text="{Binding ExposurePct, StringFormat='{}{0:0.0%}'}"/>
</StackPanel>
<StackPanel>
<TextBlock Text="NON REALIZZATO" Style="{StaticResource Label}"/>
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
Text="{Binding Unrealized, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
Foreground="{Binding Unrealized, Converter={StaticResource PnlBrush}}"/>
</StackPanel>
<StackPanel>
<TextBlock Text="OPERAZIONI OGGI" Style="{StaticResource Label}"/>
<TextBlock FontFamily="{StaticResource Mono}" FontSize="17" FontWeight="SemiBold" Margin="0,5,0,0"
Text="{Binding TradesDisplay}"/>
</StackPanel>
</UniformGrid>
</Border>
<Border Style="{StaticResource Card}" Padding="0,14,0,6">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="DETTAGLIO" Style="{StaticResource Head}" Margin="16,0,0,8"/>
<TextBlock DockPanel.Dock="Bottom" Text="Nessuna posizione aperta"
HorizontalAlignment="Center" Margin="0,26,0,26">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Positions.Count}" Value="0">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<DataGrid ItemsSource="{Binding Positions}">
<DataGrid.Columns>
<DataGridTextColumn Header="ASSET" Binding="{Binding Symbol}" Width="1.3*">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="LATO" Binding="{Binding Side}" Width="0.7*"/>
<DataGridTextColumn Header="QUANTITÀ" Width="1.2*"
Binding="{Binding Quantity, Converter={StaticResource Qty}}"/>
<DataGridTextColumn Header="PREZZO MEDIO" Width="1.2*"
Binding="{Binding EntryPrice, Converter={StaticResource Price}}"/>
<DataGridTextColumn Header="ULTIMO" Width="1.2*"
Binding="{Binding LastPrice, Converter={StaticResource Price}}"/>
<DataGridTextColumn Header="VALORE" Width="1.2*"
Binding="{Binding MarketValue, StringFormat=N2}"/>
<DataGridTextColumn Header="P&amp;L" Width="1.2*"
Binding="{Binding UnrealizedPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="%" Width="0.9*"
Binding="{Binding UnrealizedPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding UnrealizedPnl, Converter={StaticResource PnlBrush}}"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="STOP" Width="1.1*"
Binding="{Binding StopPrice, Converter={StaticResource Price}}"/>
<DataGridTextColumn Header="BARRE" Width="0.7*" Binding="{Binding BarsHeld}"/>
<DataGridTextColumn Header="APERTA" Width="1.2*"
Binding="{Binding OpenedAtUtc, Converter={StaticResource LocalTime}}"/>
<DataGridTemplateColumn Header="" Width="92">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Content="Chiudi" Style="{StaticResource Danger}"
Padding="10,3" FontSize="11"
Click="OnClosePosition" Tag="{Binding Symbol}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
</DockPanel>
</UserControl>
@@ -1,34 +0,0 @@
using System.Windows;
using System.Windows.Controls;
namespace Encelado.Bot.Ui.Pages;
public partial class PositionsPage : UserControl
{
public PositionsPage() => InitializeComponent();
public IUiActions? Actions { get; set; }
private async void OnClosePosition(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement { Tag: string symbol } button || symbol.Length == 0)
{
return;
}
// Disabled for the round trip so an impatient second click cannot submit a
// second closing order against a position that is already on its way out.
button.IsEnabled = false;
try
{
if (Actions is not null)
{
await Actions.ClosePositionAsync(symbol);
}
}
finally
{
button.IsEnabled = true;
}
}
}

Some files were not shown because too many files have changed in this diff Show More