8 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
Alby96andClaude Opus 5 9453e20cfc Corregge il crash in chiusura e rende recuperabile una strategia rimossa
Chiusura. Il gestore annullava la chiusura, aspettava lo spegnimento del motore
e la richiedeva alla fine. Un secondo clic sulla X durante l'attesa usciva pero'
SENZA annullare: la finestra entrava nella propria sequenza di chiusura e la
Close() del primo tentativo ci finiva dentro, sollevando "non e' possibile
chiamare Close durante la chiusura di un oggetto Window". Ora ogni tentativo
successivo viene annullato e la chiusura vera si rimanda a un frame nuovo del
dispatcher, cosi' non puo' mai eseguire dentro il gestore.

La correzione ovvia — annullare tutto quando lo spegnimento e' in corso —
sarebbe stata peggiore del difetto: la chiusura finale ripassa dallo stesso
gestore, veniva annullata anche lei e la finestra non si chiudeva piu'. Passa
solo quella, riconosciuta da un flag alzato prima di chiamarla.

Strategia. Un aggiornamento che rimuove una strategia lascia il suo nome nella
configurazione dell'utente, perche' l'installazione la conserva — ed e' giusto,
le tarature sono sue. Il campo pero' era di sola lettura: l'unica via d'uscita
era modificare il JSON a mano. Ora e' un elenco a discesa che propone solo cio'
che il programma sa costruire, quindi non ci si puo' scrivere un nome
inesistente, e un valore obsoleto si segnala da se' all'apertura della pagina
invece di aspettare che qualcuno prema AVVIA. All'avvio l'applicazione lo dice e
porta in Impostazioni.

Stessa cosa per gli altri campi a insieme chiuso — barre, tipo di ordine,
livello del registro e i booleani — che ora si scelgono e non si scrivono.

Trovato provando il giro completo a video: applicare due volte lo stesso lotto
di modifiche falliva con "The node already has a parent", perche' la pagina lo
applica prima a una copia temporanea per validarlo e poi al file vero, e un
JsonNode appartiene a un albero solo. ConfigWriter ora clona.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:18:39 +02:00
Alby96andClaude Opus 5 e080a0e867 Crea la copia portabile nel pacchetto, non nel rilascio
Entrambi i pacchetti devono esistere anche costruendo senza pubblicare su
Gitea, quindi lo zip si crea in Pacchetto e Rilascia si limita ad allegarlo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:30:48 +02:00
Alby96andClaude Opus 5 2b4c53ec70 Unifica la catena di rilascio con Mimante/AutoBidder
La generazione del setup, il versionamento e il caricamento su Gitea passano
ora da un solo file MSBuild in build/Release.proj, com'e' gia' per AutoBidder,
al posto dello script PowerShell make-installer.ps1.

Il cambiamento di sostanza e' da dove viene la versione: dal tag git, non piu'
da Directory.Build.props. Il numero arriva a dotnet publish come proprieta' da
riga di comando, quindi tag, eseguibile, installatore e release lo portano
uguale per costruzione invece che per disciplina. Il tag si crea in fondo, a
installatore esistente, e non si crea affatto se l'albero e' sporco: un giro
andato male non lascia dietro un tag per una versione mai costruita.

Tre adattamenti rispetto ad AutoBidder, segnati sul posto: la versione sta in
Directory.Build.props e non nel csproj; la pubblicazione produce una cartella
e non un eseguibile unico, perche' l'app legge encelado.json accanto a se',
quindi la copia portabile allegata alla release e' uno zip; il target Backtest
rigioca serie storiche di prezzi invece dei dossier delle aste.

Il setup si chiama Encelado_<versione>.exe e la pubblicazione ora fallisce se
nella cartella finiscono sorgenti o simboli di debug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:26:34 +02:00
Alby96 ebc391eadd Refactor code structure for improved readability and maintainability 2026-08-05 10:06:34 +02:00
Alby96 f96ed670ca Refactor code structure for improved readability and maintainability 2026-08-05 10:05:20 +02:00
269 changed files with 31954 additions and 76350 deletions
+21
View File
@@ -0,0 +1,21 @@
# Build output
bin/
obj/
artifacts/
# Runtime output — never commit logs or the trade journal
logs/
*.log
*.jsonl
# Local configuration: credentials and machine-specific overrides live here
*.local.json
.env
# Il token di Gitea per la catena di rilascio. Il modello versionato è
# build/gitea.example.json; questo file contiene una credenziale e non entra
# mai nel repository.
build/gitea.json
# Output dei test (coverlet)
TestResults/
+12
View File
@@ -0,0 +1,12 @@
{
"recommendations": [
// Fornisce il debugger "coreclr" richiesto da launch.json.
"ms-dotnettools.csharp",
"ms-dotnettools.csdevkit",
// Colora installer\Encelado.iss e ne conosce direttive e costanti. Serve solo a
// leggere e scrivere quel file: l'installer si costruisce con il task
// "installer", che non dipende da nessuna estensione.
"idleberg.innosetup"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
// One way to launch, on purpose. Encelado is a desktop application: F5 here starts
// the same window you get by double-clicking Encelado.exe. Everything else — login,
// start/stop, backtest, settings — lives inside that window.
"version": "0.2.0",
"configurations": [
{
"name": "Encelado",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows/Encelado.exe",
"cwd": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows",
"console": "internalConsole",
"stopAtEntry": false
}
]
}
+142
View File
@@ -0,0 +1,142 @@
{
// Tutte le attività passano da build/Release.proj: la catena è un solo file
// MSBuild versionato col codice, e qui restano soltanto i nomi e le domande.
// MSBuild non può chiedere niente a nessuno — i prompt stanno in "inputs".
//
// È la stessa impostazione di Mimante/AutoBidder.
"version": "2.0.0",
"tasks": [
{
"label": "build",
"detail": "Compilazione di debug, per F5 e per il controllo rapido degli errori.",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Encelado.slnx",
"-c",
"Debug",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": { "kind": "build", "isDefault": true },
"problemMatcher": "$msCompile"
},
{
"label": "verifica",
"detail": "Compila e lancia i test. Da eseguire dopo ogni modifica.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Verifica",
"-nologo",
"-v:m"
],
"group": { "kind": "test", "isDefault": true },
"problemMatcher": "$msCompile"
},
{
"label": "backtest",
"detail": "Ricerca sui basket: ticks (tick MT5 → barre), baskets (griglia, PSR/DSR, PBO, walk-forward), falsify (test di falsificazione).",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Backtest",
"-p:Dati=${input:dati}",
"-p:Comando=${input:comando}",
"-nologo",
"-v:m"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": []
},
{
"label": "crea installatore",
"detail": "Verifica, pubblica ed esegue Inno Setup: bin/installer/Encelado-<versione>-setup.exe. Crea il tag a pacchetto pronto. Non tocca Gitea.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Pacchetto",
"-p:Versione=${input:versione}",
"-nologo",
"-v:m"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": "$msCompile"
},
{
"label": "crea installatore (senza rieseguire i test)",
"detail": "Solo pubblicazione e Inno Setup. Da usare quando i test sono appena passati.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Pacchetto",
"-p:Versione=${input:versione}",
"-p:SaltaVerifica=true",
"-nologo",
"-v:m"
],
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": "$msCompile"
},
{
"label": "rilascia su Gitea",
"detail": "Verifica, pubblica, installatore, tag e release su Gitea con i file allegati. La versione viene dal tag su HEAD. Richiede build/gitea.json.",
"type": "process",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Rilascia",
"-p:Versione=${input:versione}",
"-nologo",
"-v:m"
],
// Le note passano dall'ambiente, non da -p:. MSBuild spezza il valore di
// una proprietà sulle virgole e una nota in italiano ne ha quasi sempre
// una: si otterrebbe MSB1006 «proprietà non valida». Vedi Release.proj.
"options": {
"env": {
"ENCELADO_NOTE": "${input:note}"
}
},
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": "$msCompile"
}
],
"inputs": [
{
"id": "versione",
"type": "promptString",
"description": "Versione — lascia vuoto se hai già taggato (git tag v3.3.0), o per la minor successiva",
"default": ""
},
{
"id": "note",
"type": "promptString",
"description": "Note di rilascio (vuoto = solo il numero di versione)",
"default": ""
},
{
"id": "dati",
"type": "promptString",
"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": ["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`).
+45
View File
@@ -0,0 +1,45 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
<NeutralLanguage>en</NeutralLanguage>
<Deterministic>true</Deterministic>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<Product>Encelado</Product>
<Company>Encelado</Company>
<!-- Numero delle compilazioni di sviluppo: è quello che compare nella finestra
mentre si lavora. La versione RILASCIATA viene dal tag git — vedi
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>4.0.0</Version>
</PropertyGroup>
<!--
Hot-path tuning. The bot is a latency-sensitive process: we want the server GC
(background, multiple heaps), full PGO and no culture-dependent parsing on the
market-data decode path.
-->
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<TieredCompilationQuickJitForLoops>true</TieredCompilationQuickJitForLoops>
<TieredPGO>true</TieredPGO>
<InvariantGlobalization>true</InvariantGlobalization>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<EventSourceSupport>false</EventSourceSupport>
</PropertyGroup>
<!-- Everything on the runtime path must stay reflection-free so PublishAot works. -->
<PropertyGroup Condition="'$(MSBuildProjectName)' != 'Encelado.Tests'">
<IsAotCompatible>true</IsAotCompatible>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
</Project>
+13
View File
@@ -0,0 +1,13 @@
<Solution>
<Folder Name="/src/">
<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" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/Encelado.Backtest/Encelado.Backtest.csproj" />
</Folder>
</Solution>
+2
View File
@@ -0,0 +1,2 @@
Ottimo! Ora effettua le seguenti modifiche:
-
+153
View File
@@ -0,0 +1,153 @@
; ─────────────────────────────────────────────────────────────────────────────
; Encelado — script di installazione (Inno Setup 6)
;
; Non si compila a mano: lo lancia build/Release.proj, che prima pubblica
; l'applicazione e poi passa qui versione e percorsi con /D. Compilarlo da solo
; produrrebbe un pacchetto con la versione sbagliata, perché il numero vive nel
; tag git e non in questo file.
;
; dotnet msbuild build/Release.proj -t:Pacchetto
;
; ── Due differenze rispetto ad AutoBidder.iss ───────────────────────────────
;
; La prima: Encelado non è un eseguibile unico. È una cartella — l'applicazione
; legge encelado.json accanto a sé — quindi si copia SourceDir, non SourceExe.
;
; La seconda: l'installazione è per utente e non è possibile forzarla altrove.
; Non è per evitare l'UAC. Encelado scrive log, diario operazioni e CSV di
; analisi accanto al proprio eseguibile: dentro C:\Program Files quelle
; scritture fallirebbero, e siccome il logger degrada in silenzio piuttosto che
; fermare il bot, l'utente se ne accorgerebbe solo cercando i log per capire
; cosa è successo — cioè nel momento peggiore.
; ─────────────────────────────────────────────────────────────────────────────
#ifndef AppVersion
#define AppVersion "0.0.0"
#endif
#ifndef SourceDir
#define SourceDir "..\bin\publish\win-x64"
#endif
#ifndef OutputDir
#define OutputDir "..\bin\installer"
#endif
#define AppName "Encelado"
#define AppPublisher "Alberto Balbo"
#define AppExeName "Encelado.exe"
#define AppDescription "Correlation Baskets su eToro (CFD forex)"
[Setup]
; L'AppId identifica il prodotto fra una versione e l'altra: cambiarlo farebbe
; comparire due voci in "App installate" invece di un aggiornamento.
AppId={{7C4F1E62-2B8A-4D19-9C55-3E0A6B1D8F44}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion}
AppPublisher={#AppPublisher}
VersionInfoVersion={#AppVersion}
VersionInfoDescription={#AppDescription}
; Vedi la nota in testa al file: l'applicazione deve poter scrivere nella
; propria cartella, quindi l'installazione resta nel profilo dell'utente e non
; è consentito spostarla altrove.
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=
DefaultDirName={autopf}\{#AppName}
DefaultGroupName={#AppName}
DisableProgramGroupPage=yes
DisableDirPage=auto
OutputDir={#OutputDir}
OutputBaseFilename=Encelado_{#AppVersion}
SetupIconFile=..\src\Encelado.Bot\Assets\encelado.ico
UninstallDisplayIcon={app}\{#AppExeName}
UninstallDisplayName={#AppName} {#AppVersion}
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
; Se Encelado è in esecuzione, il Restart Manager lo chiude invece di lasciare
; l'installazione a metà con i file bloccati.
CloseApplications=yes
RestartApplications=no
[Languages]
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
[Tasks]
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
[Files]
; Tutto il publish tranne la configurazione, che ha una regola sua, e i simboli
; di debug, che non servono a chi installa.
Source: "{#SourceDir}\*"; DestDir: "{app}"; \
Excludes: "encelado.json,*.pdb,*.xml,logs\*"; \
Flags: ignoreversion recursesubdirs createallsubdirs
; La configurazione è il prodotto — ogni numero dentro encelado.json è tarato su
; due dataset indipendenti — ma è anche l'unico posto dove l'utente mette mano,
; dalla scheda Impostazioni o a mano. "onlyifdoesntexist" fa sì che un
; aggiornamento non cancelli quelle modifiche; "uninsneveruninstall" che una
; disinstallazione non le butti via. Le chiavi nuove introdotte da una versione
; successiva non rompono nulla: il loader usa i valori di default per quelle che
; non trova.
Source: "{#SourceDir}\encelado.json"; DestDir: "{app}"; \
Flags: onlyifdoesntexist uninsneveruninstall
; Copia sempre aggiornata dei valori di fabbrica, per poter vedere cosa è
; cambiato rispetto al proprio encelado.json dopo un aggiornamento.
Source: "{#SourceDir}\encelado.json"; DestDir: "{app}"; \
DestName: "encelado.default.json"; Flags: ignoreversion
[Icons]
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Comment: "{#AppDescription}"
Name: "{group}\Disinstalla {#AppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; \
Comment: "{#AppDescription}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#AppExeName}"; Description: "Avvia {#AppName}"; \
Flags: nowait postinstall skipifsilent
[UninstallDelete]
; Prodotti a runtime, quindi non tracciati dall'installatore: senza questo
; resterebbero una cartella e dei file orfani.
Type: filesandordirs; Name: "{app}\logs"
Type: dirifempty; Name: "{app}"
[Code]
{ 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
risposta predefinita: chi disinstalla per reinstallare una versione nuova non
deve ritrovarsi a reinserire le chiavi solo perché ha premuto Invio di fretta. }
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
DataDir: String;
begin
if CurUninstallStep <> usPostUninstall then
Exit;
{ In modalità silenziosa non c'è nessuno a cui chiedere, e la risposta che non
si può disfare è quella che cancella. Nel dubbio le credenziali restano. }
if UninstallSilent then
Exit;
DataDir := ExpandConstant('{localappdata}\Encelado');
if not DirExists(DataDir) then
Exit;
if MsgBox(
'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.',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES then
DelTree(DataDir, True, True, True);
end;
+123
View File
@@ -0,0 +1,123 @@
# Catena di verifica, pacchetto e rilascio
Tutto quello che serve a controllare, impacchettare e pubblicare Encelado sta in questa
cartella. La radice del progetto non contiene script.
È la stessa catena di [Mimante/AutoBidder](http://192.168.30.23:3000/Alby96/Mimante),
adattata a una soluzione con più progetti. Le differenze sono tre, tutte segnate sul
posto in `Release.proj`:
| | AutoBidder | Encelado |
|---|---|---|
| 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 | barre M15 bid/ask dei basket |
| File | Cos'è |
|---|---|
| `Release.proj` | La catena. Un solo file MSBuild, nessuno script. |
| `Encelado.iss` | Lo script di Inno Setup. Non si compila a mano: lo lancia `Release.proj`. |
| `gitea.example.json` | Modello per `gitea.json` (che è escluso dal controllo di versione). |
## Da VS Code
**Terminale ▸ Esegui attività…**
| Attività | Cosa fa |
|---|---|
| `verifica` | Compila e lancia i test. |
| `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. |
## Da riga di comando
```powershell
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"
# 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 |
|---|---|---|
| `Versione` | vuoto | Vuoto = incrementa la minor (3.2.0 → 3.3.0). Altrimenti la scrive, se ha la forma `X.Y.Z`. |
| `Note` | vuoto | Note di rilascio. |
| `SaltaVerifica` | `false` | Non rieseguire i test. |
| `Sovrascrivi` | `false` | Sostituisci una release Gitea con lo stesso tag. |
| `Bozza` | `false` | Crea la release come bozza. |
| `ConsentiModifiche` | `false` | Tagga anche con l'albero sporco. Serve saperlo. |
| `Dati` | — | La cartella 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
MSBuild non può chiedere niente a nessuno: è un motore di compilazione. La domanda la fa
l'attività di VS Code (`inputs` in `.vscode/tasks.json`) e passa la risposta in
`-p:Versione=`. Lasciando il campo vuoto si prende la minor successiva, che è il caso
normale di fine sessione.
**La versione arriva dal tag e non viene scritta da nessuna parte.** `dotnet publish` la
riceve come proprietà da riga di comando, che è globale e vince su quella dichiarata in
`Directory.Build.props`. Tag, eseguibile, installatore e release portano quindi lo stesso
numero per costruzione.
Il tag si crea **in fondo**, quando l'installatore esiste davvero. Il contrario sembra più
naturale — decidi il numero, poi costruisci — ma lascia dietro un tag quando la verifica
fallisce, e il tentativo dopo riparte da lì: il numero sale senza che sia mai esistito un
pacchetto con quella versione.
## Gitea
Servono quattro valori. Le variabili d'ambiente hanno la precedenza sul file, così una
macchina condivisa può rilasciare senza scrivere un token su disco:
- `GITEA_URL`, `GITEA_OWNER`, `GITEA_REPO`, `GITEA_TOKEN`
- oppure `build/gitea.json`, copiato da `gitea.example.json`
Il token si crea in Gitea da *Impostazioni ▸ Applicazioni ▸ Genera nuovo token*, con il
permesso `repository: read and write`.
Il token non passa mai dalla riga di comando: sta in un file di configurazione di curl,
cancellato subito dopo il rilascio. Gli `Exec` hanno `EchoOff` perché un registro di
compilazione è la classica cosa che si incolla in una chat.
Nella release vengono caricati **sia l'installatore sia la copia portabile**: chi non
vuole installare niente deve continuare a poter scaricare l'applicazione e basta.
## Una trappola già pagata
`dotnet test` e `dotnet publish` lanciati **da dentro** MSBuild ereditano l'ambiente del
processo padre. La compilazione WPF crea un progetto temporaneo (`_wpftmp.csproj`) e con
`MSBUILD_EXE_PATH` puntata al build in corso non genera più le classi parziali dello XAML:
si ottengono decine di errori su membri che esistono benissimo.
Per questo gli `Exec` azzerano `MSBUILD_EXE_PATH` e `MSBuildLoadMicrosoftTargetsReadOnly`.
**Solo quelle due**: la ricetta che gira in rete azzera anche `MSBuildExtensionsPath` e
`MSBuildSDKsPath`, e così il figlio perde la posizione dell'SDK — *«l'SDK Microsoft.NET.Sdk
specificato non è stato trovato»*. Serve isolare il motore, non nascondergli dove abita.
I test girano in una cartella a parte (`%TEMP%\Encelado.Verifica`) perché l'applicazione
può essere aperta mentre si lavora e tiene bloccato `Encelado.exe`: senza, la compilazione
si ferma su MSB3027.
## Prerequisiti
- .NET SDK 10
- [Inno Setup 6](https://jrsoftware.org/isinfo.php) — `winget install -e --id JRSoftware.InnoSetup`
- `curl` e `git`, entrambi di serie in Windows 11
+722
View File
@@ -0,0 +1,722 @@
<!--
═══════════════════════════════════════════════════════════════════════════
Encelado — catena di verifica, pacchetto e rilascio
═══════════════════════════════════════════════════════════════════════════
Un solo file, nessuno script. Si richiama con `dotnet msbuild`, dalle attività
di VS Code (Terminale ▸ Esegui attività…) oppure a mano:
dotnet msbuild build/Release.proj -t:Verifica
dotnet msbuild build/Release.proj -t:Backtest
dotnet msbuild build/Release.proj -t:Pacchetto
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=3.3.0
È la stessa catena di Mimante/AutoBidder, adattata a una soluzione con più
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 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
sei mesi, ripescato un tag, questo file ricostruisce quel pacchetto e non
quello di oggi. La logica non banale (leggere e riscrivere la versione,
parlare con Gitea) sta in attività C# in linea: si legge come codice, non
come una successione di comandi.
── Da dove viene la versione ────────────────────────────────────────────
Dal tag git, e da nient'altro. Directory.Build.props non viene mai riscritto:
il numero arriva a `dotnet publish` come proprietà da riga di comando, quindi
tag, eseguibile, installatore e release portano lo stesso numero per
costruzione, non per disciplina.
Il modo previsto è taggare e poi rilasciare:
git tag v3.3.0
dotnet msbuild build/Release.proj -t:Rilascia
Se HEAD non ha un tag di versione la catena lo crea da sé — con il numero
passato in `-p:Versione=`, oppure la minor successiva all'ultimo tag — e lo
fa in fondo, quando l'installatore esiste davvero. Un giro andato male non
lascia dietro un tag per una versione che non è mai stata costruita.
Il numero in Directory.Build.props resta quello delle compilazioni di
sviluppo. Continua ad avere senso alzarlo a ogni modifica — è quello che
compare nel titolo della finestra durante il lavoro — ma non decide più cosa
viene rilasciato: serve solo come seme al primissimo rilascio, quando non
esiste ancora nessun tag da cui ripartire.
-->
<Project DefaultTargets="Pacchetto" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Radice>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..'))</Radice>
<Csproj>$(Radice)\src\Encelado.Bot\Encelado.Bot.csproj</Csproj>
<TestProj>$(Radice)\tests\Encelado.Tests\Encelado.Tests.csproj</TestProj>
<BacktestProj>$(Radice)\tools\Encelado.Backtest\Encelado.Backtest.csproj</BacktestProj>
<Iss>$(MSBuildThisFileDirectory)Encelado.iss</Iss>
<!-- La versione non sta nel .csproj come in AutoBidder: sta in
Directory.Build.props, da cui la ereditano tutti e quattro i progetti.
È il file che VersioneDaTag legge quando non esiste ancora nessun tag. -->
<Props>$(Radice)\Directory.Build.props</Props>
<CartellaPubblicazione>$(Radice)\bin\publish\win-x64</CartellaPubblicazione>
<CartellaPacchetti>$(Radice)\bin\installer</CartellaPacchetti>
<!-- ── Perché una cartella a parte per verifica e backtest ─────────────
Due ragioni, e servono entrambe.
La prima: l'applicazione può essere aperta mentre si lavora, e tiene
bloccato Encelado.exe — la compilazione si fermerebbe su MSB3027.
La seconda: l'opzione artifacts-path sposta anche gli INTERMEDI, non
solo il risultato. La sola -o li lascia nella obj/ condivisa, e la
compilazione WPF — che genera un progetto temporaneo `_wpftmp.csproj` a
ogni giro — ogni tanto ci trovava stato altrui e smetteva di produrre
le classi parziali dello XAML. Il sintomo era una raffica di
"AuctionMonitorControl non contiene una definizione di ...", a giri
alterni, senza che il codice fosse cambiato. -->
<CartellaProve>$([System.IO.Path]::GetTempPath())Encelado.Verifica</CartellaProve>
<!-- Serve solo quando HEAD non è ancora taggato: è il numero del tag da
creare. Vuoto = la minor successiva all'ultimo tag. Vedi VersioneDaTag. -->
<Versione Condition="'$(Versione)' == ''"></Versione>
<!--
Note di rilascio. Vuote = solo il numero di versione.
Arrivano da una variabile d'ambiente, non da -p:. MSBuild spezza il valore
di una proprietà sulle virgole: `-p:Note=uno, due` diventa la proprietà
Note=uno più l'opzione " due", e si finisce su MSB1006 "proprietà non
valida". Una nota di rilascio in italiano contiene quasi sempre una
virgola, quindi il passaggio per riga di comando è inutilizzabile.
L'ambiente non ha questo problema, e MSBuild legge le variabili
d'ambiente come proprietà.
-p:Note= resta accettato per chi lo passa a mano senza virgole.
-->
<Note Condition="'$(Note)' == ''">$(ENCELADO_NOTE)</Note>
<!-- ── Perché serve azzerare queste variabili ──────────────────────────
`dotnet test` e `dotnet publish` lanciati da dentro MSBuild ereditano
l'ambiente del processo padre. La compilazione WPF crea un progetto
temporaneo (_wpftmp.csproj) e con quelle variabili puntate al build in
corso non genera più le classi parziali dello XAML: si ottengono decine
di "AuctionMonitorControl non contiene una definizione di ..." che non
hanno niente a che vedere col codice.
Si azzerano SOLO queste due. Togliere anche MSBuildExtensionsPath o
MSBuildSDKsPath — la ricetta che gira in rete — fa perdere al figlio la
posizione dell'SDK: "l'SDK Microsoft.NET.Sdk specificato non è stato
trovato". Serve isolare il motore, non nascondergli dove abita. -->
<AmbientePulito>MSBUILD_EXE_PATH=;MSBuildLoadMicrosoftTargetsReadOnly=</AmbientePulito>
<SaltaVerifica Condition="'$(SaltaVerifica)' == ''">false</SaltaVerifica>
<Sovrascrivi Condition="'$(Sovrascrivi)' == ''">false</Sovrascrivi>
<Bozza Condition="'$(Bozza)' == ''">false</Bozza>
<!-- Un rilascio da albero sporco produce un tag che non corrisponde a
nessuno stato ricostruibile: è esattamente la garanzia che il tag come
fonte unica dovrebbe dare. Si può forzare, ma va detto. -->
<ConsentiModifiche Condition="'$(ConsentiModifiche)' == ''">false</ConsentiModifiche>
</PropertyGroup>
<!-- ═════════════════════ Attività in linea ═════════════════════ -->
<!--
Decide quale versione si sta costruendo, senza toccare niente.
Il tag su HEAD, se c'è, comanda: è la fonte. Se non c'è se ne propone uno —
il numero chiesto, o la minor successiva al tag più alto esistente — che
verrà creato solo a pacchetto pronto, da CreaTag.
-->
<UsingTask TaskName="VersioneDaTag" TaskFactory="RoslynCodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<TagsHead ParameterType="System.String" />
<TagsTutti ParameterType="System.String" />
<Richiesta ParameterType="System.String" />
<FileVersione ParameterType="System.String" Required="true" />
<Versione ParameterType="System.String" Output="true" />
<Tag ParameterType="System.String" Output="true" />
<DaCreare ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Using Namespace="System.Collections.Generic" />
<Using Namespace="System.Text.RegularExpressions" />
<Code Type="Fragment" Language="cs">
<![CDATA[
// ConsoleToMSBuild unisce le righe con il punto e virgola; git le separa
// con a-capo. Si accettano entrambi e non si fanno domande.
Func<string, string[]> spezza = s =>
(s ?? "").Split(new[] { ';', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var forma = new Regex(@"^v(\d+)\.(\d+)\.(\d+)$");
var suHead = new List<string>();
foreach (var t in spezza(TagsHead))
if (forma.IsMatch(t.Trim())) suHead.Add(t.Trim());
if (suHead.Count > 1)
{
Log.LogError(
"HEAD ha piu' di un tag di versione (" + string.Join(", ", suHead) + ").\n" +
"Non si puo' sapere quale sia il rilascio: tienine uno solo con git tag -d <tag>");
return false;
}
var chiesta = (Richiesta ?? "").Trim();
if (chiesta.Length > 0 && !Regex.IsMatch(chiesta, @"^\d+\.\d+\.\d+$"))
{
// Un refuso qui produrrebbe un tag e un pacchetto sbagliati.
Log.LogError("Versione '" + chiesta + "' non valida: serve la forma X.Y.Z");
return false;
}
if (suHead.Count == 1)
{
var dalTag = suHead[0].Substring(1);
if (chiesta.Length > 0 && chiesta != dalTag)
{
Log.LogError(
"HEAD e' gia' taggato " + suHead[0] + ", ma e' stata chiesta la versione " + chiesta + ".\n" +
"Il tag e' la fonte: o rilasci " + dalTag + " lasciando vuota la versione,\n" +
"oppure togli il tag con git tag -d " + suHead[0] + " e rilancia.");
return false;
}
Versione = dalTag;
Tag = suHead[0];
DaCreare = "false";
Log.LogMessage(MessageImportance.High, " versione " + Versione + " — dal tag " + Tag + " su HEAD");
return true;
}
if (chiesta.Length > 0)
{
Versione = chiesta;
}
else
{
// La minor successiva al tag piu' alto: cosi' il numero proposto e'
// sempre libero, anche se il tag piu' alto sta su un altro ramo.
int maggiore = -1, minore = -1;
foreach (var t in spezza(TagsTutti))
{
var m = forma.Match(t.Trim());
if (!m.Success) continue;
int ma = int.Parse(m.Groups[1].Value), mi = int.Parse(m.Groups[2].Value);
if (ma > maggiore || (ma == maggiore && mi > minore)) { maggiore = ma; minore = mi; }
}
if (maggiore < 0)
{
// Primo rilascio: non c'e' nessun tag da cui ripartire, e l'unico
// numero che esiste e' quello delle compilazioni di sviluppo.
var testo = File.Exists(FileVersione) ? File.ReadAllText(FileVersione) : "";
var m = Regex.Match(testo, @"<Version>(\d+)\.(\d+)\.\d+</Version>");
if (!m.Success)
{
Log.LogError(
"Nessun tag di versione nel repository e <Version> illeggibile in " + FileVersione + ".\n" +
"Indica la versione con -p:Versione=X.Y.Z");
return false;
}
maggiore = int.Parse(m.Groups[1].Value);
minore = int.Parse(m.Groups[2].Value);
}
Versione = maggiore + "." + (minore + 1) + ".0";
}
Tag = "v" + Versione;
DaCreare = "true";
Log.LogMessage(MessageImportance.High, " versione " + Versione + " — il tag " + Tag + " sara' creato a pacchetto pronto");
]]>
</Code>
</Task>
</UsingTask>
<UsingTask TaskName="TrovaInnoSetup" TaskFactory="RoslynCodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<Percorso ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Code Type="Fragment" Language="cs">
<![CDATA[
var candidati = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Inno Setup 6\ISCC.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Inno Setup 6\ISCC.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), @"Inno Setup 6\ISCC.exe"),
};
foreach (var c in candidati)
if (File.Exists(c)) { Percorso = c; break; }
if (string.IsNullOrEmpty(Percorso))
Log.LogError("Inno Setup 6 non trovato. Installalo con: winget install -e --id JRSoftware.InnoSetup");
]]>
</Code>
</Task>
</UsingTask>
<!--
Gitea si raggiunge con curl, di serie in Windows 10 e 11.
L'alternativa naturale sarebbe HttpClient in un'attività C# in linea, ma
RoslynCodeTaskFactory referenzia solo gli assembly di base: System.Net.Http e
System.Text.Json andrebbero indicati per percorso assoluto, dentro il runtime
condiviso, con il numero di versione nel mezzo. Un percorso che oggi funziona e
al prossimo aggiornamento di .NET no. curl non ha questo problema.
Il token NON passa mai dalla riga di comando: sta in un file di configurazione
di curl, che viene cancellato subito dopo. Gli Exec hanno EchoOff perché un
registro di compilazione è la classica cosa che si incolla in una chat.
-->
<UsingTask TaskName="LeggiConfigGitea" TaskFactory="RoslynCodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<Percorso ParameterType="System.String" Required="true" />
<Url ParameterType="System.String" Output="true" />
<Owner ParameterType="System.String" Output="true" />
<Repo ParameterType="System.String" Output="true" />
<Token ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Using Namespace="System.Text.RegularExpressions" />
<Code Type="Fragment" Language="cs">
<![CDATA[
var testo = File.Exists(Percorso) ? File.ReadAllText(Percorso) : "";
// Le variabili d'ambiente hanno la precedenza sul file: una macchina
// condivisa deve poter rilasciare senza scrivere un token su disco, e un
// file dimenticato non deve vincere su una scelta esplicita.
Func<string,string,string> leggi = (env, campo) =>
{
var v = Environment.GetEnvironmentVariable(env);
if (!string.IsNullOrWhiteSpace(v)) return v.Trim();
var m = Regex.Match(testo, "\"" + campo + "\"\\s*:\\s*\"([^\"]*)\"");
return m.Success ? m.Groups[1].Value.Trim() : "";
};
Url = leggi("GITEA_URL", "url").TrimEnd('/');
Owner = leggi("GITEA_OWNER", "owner");
Repo = leggi("GITEA_REPO", "repo");
Token = leggi("GITEA_TOKEN", "token");
if (Url.Length == 0 || Owner.Length == 0 || Repo.Length == 0 || Token.Length == 0)
{
Log.LogError(
"Configurazione di Gitea incompleta. Servono url, owner, repo, token:\n" +
" copia build/gitea.example.json in build/gitea.json e riempilo,\n" +
" oppure imposta GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_TOKEN.\n" +
"Non e' stato costruito niente: si controlla prima di compilare, non dopo.\n" +
"Per il solo installatore, senza Gitea, usa il target Pacchetto.");
// Senza questo il target prosegue lo stesso: git tag, poi curl con
// l'indirizzo vuoto, e infine un "codice 3" che non dice niente a
// nessuno. Un errore va fermato dove si capisce ancora cos'era.
return false;
}
]]>
</Code>
</Task>
</UsingTask>
<UsingTask TaskName="PreparaCorpoRelease" TaskFactory="RoslynCodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<Destinazione ParameterType="System.String" Required="true" />
<Tag ParameterType="System.String" Required="true" />
<Versione ParameterType="System.String" Required="true" />
<Note ParameterType="System.String" />
<Bozza ParameterType="System.Boolean" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Code Type="Fragment" Language="cs">
<![CDATA[
// Le note arrivano da un prompt: possono contenere virgolette, barre e
// a-capo. Scritte grezze romperebbero il JSON, o peggio lo cambierebbero.
Func<string,string> esc = t => (t ?? "")
.Replace("\\", "\\\\").Replace("\"", "\\\"")
.Replace("\r", "").Replace("\n", "\\n").Replace("\t", " ");
var note = string.IsNullOrWhiteSpace(Note) ? "Versione " + Versione + "." : Note;
File.WriteAllText(Destinazione,
"{\"tag_name\":\"" + esc(Tag) + "\"," +
"\"name\":\"Encelado " + esc(Versione) + "\"," +
"\"body\":\"" + esc(note) + "\"," +
"\"draft\":" + (Bozza ? "true" : "false") + "," +
"\"prerelease\":false}");
]]>
</Code>
</Task>
</UsingTask>
<UsingTask TaskName="LeggiIdRelease" TaskFactory="RoslynCodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<Risposta ParameterType="System.String" Required="true" />
<Id ParameterType="System.String" Output="true" />
<Errore ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Using Namespace="System.Text.RegularExpressions" />
<Code Type="Fragment" Language="cs">
<![CDATA[
var testo = File.Exists(Risposta) ? File.ReadAllText(Risposta) : "";
// L'id della release e' il primo campo "id" della risposta; quelli annidati
// (autore, allegati) vengono dopo. Si prende il primo e basta.
var m = Regex.Match(testo, "\"id\"\\s*:\\s*(\\d+)");
Id = m.Success ? m.Groups[1].Value : "";
if (Id.Length == 0)
{
var msg = Regex.Match(testo, "\"message\"\\s*:\\s*\"([^\"]*)\"");
Errore = msg.Success ? msg.Groups[1].Value
: (testo.Length > 200 ? testo.Substring(0, 200) : testo);
}
]]>
</Code>
</Task>
</UsingTask>
<!-- ═════════════════════ Verifica ═════════════════════ -->
<Target Name="Verifica" Condition="'$(SaltaVerifica)' != 'true'">
<Message Importance="High" Text="== Verifica (compilazione + test) ==" />
<!-- In una cartella a parte: l'applicazione puo' essere aperta e tenere
bloccato Encelado.exe. -->
<Exec Command="dotnet test &quot;$(TestProj)&quot; --nologo -v q --artifacts-path &quot;$(CartellaProve)&quot;"
WorkingDirectory="$(Radice)"
EnvironmentVariables="$(AmbientePulito)" />
<Message Importance="High" Text=" tutto a posto" />
</Target>
<!-- ═════════════════════ Ricerca sui basket ═════════════════════ -->
<!--
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.
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)' == ''">baskets</Comando>
<BacktestExe>$(Radice)\tools\Encelado.Backtest\bin\Release\net10.0\backtest.exe</BacktestExe>
</PropertyGroup>
<Error Condition="'$(Dati)' == ''"
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="Cartella dati non trovata: $(Dati)" />
<Message Importance="High" Text="== Ricerca sui basket ==" />
<Message Importance="High" Text=" dati : $(Dati)" />
<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) --data &quot;$(Dati)&quot; $(Extra)" />
</Target>
<!-- ═════════════════════ Eseguibile ═════════════════════ -->
<!--
I quattro numeri di versione arrivano da qui, non da Directory.Build.props:
le proprietà da riga di comando sono globali e vincono su quelle scritte nei
progetti. Vengono passati tutti e quattro anche se il file ne dichiara uno
solo — la finestra legge Assembly.GetName().Version, e vederne divergere uno
significa un numero a schermo che mente.
── Perché una cartella e non un singolo eseguibile ────────────────────────
AutoBidder pubblica con PublishSingleFile e allega quel file alla release.
Qui non si può: Encelado legge `encelado.json` accanto al proprio eseguibile
e ci scrive log, diario e CSV di analisi. Un singolo file estratto in una
cartella temporanea a ogni avvio metterebbe la configurazione dell'utente e
i suoi log in un percorso che cambia da un avvio all'altro.
Una cartella non si allega a una release, quindi al suo posto viene allegato
uno zip: vedi il target Rilascia.
-->
<Target Name="Pubblica" DependsOnTargets="DeterminaVersione">
<Message Importance="High" Text="== Pubblicazione dell'eseguibile ($(V)) ==" />
<!-- Cartella pulita a ogni giro. Senza questo i resti di una pubblicazione
precedente — una DLL rinominata, un runtime cambiato — finiscono nel
pacchetto, ed è il tipo di problema che si manifesta solo sulla
macchina di qualcun altro. -->
<RemoveDir Directories="$(CartellaPubblicazione)" ContinueOnError="true" />
<Exec WorkingDirectory="$(Radice)"
EnvironmentVariables="$(AmbientePulito)"
Command="dotnet publish &quot;$(Csproj)&quot; -c Release -r win-x64 --nologo -v q --self-contained true -p:PublishReadyToRun=true -p:PublishTrimmed=false -p:DebugType=none -p:Version=$(V) -p:AssemblyVersion=$(V).0 -p:FileVersion=$(V).0 -p:InformationalVersion=$(V) -o &quot;$(CartellaPubblicazione)&quot;" />
<Error Condition="!Exists('$(CartellaPubblicazione)\Encelado.exe')"
Text="Pubblicazione fallita: Encelado.exe non trovato." />
<!-- Senza configurazione l'applicazione non parte, e l'installatore la
copierebbe senza accorgersi che manca. -->
<Error Condition="!Exists('$(CartellaPubblicazione)\encelado.json')"
Text="Pubblicazione incompleta: encelado.json non è finito accanto all'eseguibile." />
<!--
Nella release non devono finire i sorgenti: si pubblica il programma, non
il progetto. La cartella pubblicata diventa lo zip portabile, quindi basta
controllare qui.
Non è teorico: un <None CopyToOutputDirectory> aggiunto per comodità, o un
pacchetto che porta i propri .cs, li farebbe scivolare dentro senza che
nessuno se ne accorga fino a quando qualcuno non apre lo zip.
-->
<ItemGroup>
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.cs" />
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.csproj" />
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.xaml" />
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.pdb" />
</ItemGroup>
<Error Condition="'@(SorgenteIntruso)' != ''"
Text="Nella cartella pubblicata ci sono file che non sono programma: @(SorgenteIntruso->'%(Filename)%(Extension)', ', ').%0ANon devono finire nella release." />
</Target>
<!-- ═════════════════════ Pacchetto ═════════════════════ -->
<!--
L'ordine conta: si verifica, si costruisce, e il tag si crea per ultimo.
Il contrario sembra piu' naturale — decidi il numero, poi costruisci — ma
lascia dietro un tag quando qualcosa va storto, e il tentativo successivo
riparte da li'. Un giro andato male e il numero e' salito lo stesso, senza
che sia mai esistito un pacchetto con quella versione. Taggando in fondo,
ogni tag corrisponde a un installatore che esiste davvero.
-->
<Target Name="Pacchetto" DependsOnTargets="Verifica;DeterminaVersione;Pubblica">
<Message Importance="High" Text="== Creazione dell'installatore ==" />
<TrovaInnoSetup>
<Output TaskParameter="Percorso" PropertyName="Iscc" />
</TrovaInnoSetup>
<MakeDir Directories="$(CartellaPacchetti)" />
<Exec WorkingDirectory="$(MSBuildThisFileDirectory)"
Command="&quot;$(Iscc)&quot; /Qp &quot;/DAppVersion=$(V)&quot; &quot;/DSourceDir=$(CartellaPubblicazione)&quot; &quot;/DOutputDir=$(CartellaPacchetti)&quot; &quot;$(Iss)&quot;" />
<PropertyGroup>
<Setup>$(CartellaPacchetti)\Encelado_$(V).exe</Setup>
<Portabile>$(CartellaPacchetti)\Encelado_$(V)_portabile.zip</Portabile>
</PropertyGroup>
<Error Condition="!Exists('$(Setup)')" Text="Installatore non trovato: $(Setup)" />
<!--
La copia portabile per chi non vuole installare niente.
In AutoBidder è il solo .exe, perché lì la pubblicazione è un file unico.
Qui l'applicazione ha bisogno di encelado.json accanto a sé, quindi è uno
zip della cartella. Si crea qui e non nel rilascio: entrambi i pacchetti
devono esistere anche costruendo senza pubblicare su Gitea.
-->
<Delete Files="$(Portabile)" ContinueOnError="true" />
<ZipDirectory SourceDirectory="$(CartellaPubblicazione)" DestinationFile="$(Portabile)" />
<!-- Adesso: il pacchetto c'e', il tag puo' esistere. -->
<CallTarget Targets="CreaTag" />
<Message Importance="High" Text=" " />
<Message Importance="High" Text="Pacchetto pronto:" />
<Message Importance="High" Text=" $(Setup)" />
<Message Importance="High" Text=" $(Portabile)" />
</Target>
<!-- ═════════════════════ Versione e tag ═════════════════════ -->
<Target Name="DeterminaVersione">
<!-- Nessuno dei due comandi fallisce mai: senza tag l'uscita e' vuota, e
basta. `git describe` invece esce in errore, e qui non serve. -->
<Exec Command="git tag --points-at HEAD" WorkingDirectory="$(Radice)"
ConsoleToMSBuild="true" StandardOutputImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="TagSuHead" />
</Exec>
<Exec Command="git tag --list v*" WorkingDirectory="$(Radice)"
ConsoleToMSBuild="true" StandardOutputImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="TagEsistenti" />
</Exec>
<VersioneDaTag TagsHead="$(TagSuHead)" TagsTutti="$(TagEsistenti)"
Richiesta="$(Versione)" FileVersione="$(Props)">
<Output TaskParameter="Versione" PropertyName="V" />
<Output TaskParameter="Tag" PropertyName="Tag" />
<Output TaskParameter="DaCreare" PropertyName="TagDaCreare" />
</VersioneDaTag>
</Target>
<Target Name="CreaTag" Condition="'$(TagDaCreare)' == 'true'">
<!-- Solo le modifiche ai file gia' tracciati: un appunto non versionato
accanto al progetto non cambia cosa viene compilato. -->
<Exec Command="git status --porcelain --untracked-files=no" WorkingDirectory="$(Radice)"
ConsoleToMSBuild="true" StandardOutputImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="AlberoSporco" />
</Exec>
<Error Condition="'$(AlberoSporco)' != '' AND '$(ConsentiModifiche)' != 'true'"
Text="Ci sono modifiche non committate: il tag $(Tag) indicherebbe uno stato che non e' ricostruibile.%0ACommitta prima di rilasciare, oppure rilancia con -p:ConsentiModifiche=true se sai cosa stai facendo.%0A%0A$(AlberoSporco)" />
<Exec Command="git tag -a $(Tag) -m &quot;Encelado $(V)&quot;" WorkingDirectory="$(Radice)"
StandardOutputImportance="low" StandardErrorImportance="low" />
<Message Importance="High" Text=" tag $(Tag) creato" />
</Target>
<!-- ═════════════════════ Rilascio ═════════════════════ -->
<!-- Prima di tutto il resto: un token mancante non deve costare due minuti di
compilazione per poi fermarsi all'ultimo passo. -->
<Target Name="ConfigGitea">
<LeggiConfigGitea Percorso="$(MSBuildThisFileDirectory)gitea.json">
<Output TaskParameter="Url" PropertyName="GUrl" />
<Output TaskParameter="Owner" PropertyName="GOwner" />
<Output TaskParameter="Repo" PropertyName="GRepo" />
<Output TaskParameter="Token" PropertyName="GToken" />
</LeggiConfigGitea>
</Target>
<Target Name="Rilascia" DependsOnTargets="ConfigGitea;Pacchetto">
<Message Importance="High" Text="== Pubblicazione su Gitea ==" />
<PropertyGroup>
<Api>$(GUrl)/api/v1/repos/$(GOwner)/$(GRepo)</Api>
<Tmp>$([System.IO.Path]::GetTempPath())Encelado.Rilascio</Tmp>
<CurlCfg>$(Tmp)\curl.cfg</CurlCfg>
<CorpoJson>$(Tmp)\release.json</CorpoJson>
<RispostaJson>$(Tmp)\risposta.json</RispostaJson>
<Setup>$(CartellaPacchetti)\Encelado_$(V).exe</Setup>
</PropertyGroup>
<MakeDir Directories="$(Tmp)" />
<!-- Il token vive qui e solo qui, per il tempo del rilascio. -->
<WriteLinesToFile File="$(CurlCfg)" Overwrite="true"
Lines="header = &quot;Authorization: token $(GToken)&quot;" />
<!--
Il tag esiste gia' in locale: l'ha creato Pacchetto, o c'era prima. Qui va
spinto, e va verificato che ci sia arrivato.
Non e' una formalita'. Il push qui sotto spinge i tag, non i commit: se il
ramo e' indietro, il tag punta a un oggetto che il remoto non conosce e il
push viene rifiutato. A quel punto la creazione della release non
fallisce — Gitea, non trovando il tag, lo crea da se' sulla testa del ramo
predefinito. Verrebbe pubblicata una release che dichiara di essere il
commit X mentre il codice allegato e' il commit Y, e nessuno se ne
accorgerebbe. Meglio fermarsi e dire cosa manca.
I commit non si spingono da qui: quando spingere il ramo lo decide chi
lavora, non la catena di rilascio.
-->
<Exec Command="git push --tags" WorkingDirectory="$(Radice)" ContinueOnError="true"
StandardOutputImportance="low" StandardErrorImportance="low" />
<Exec Command="git ls-remote --tags origin refs/tags/$(Tag)" WorkingDirectory="$(Radice)"
ConsoleToMSBuild="true" ContinueOnError="true" StandardOutputImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="TagSulRemoto" />
</Exec>
<Error Condition="'$(TagSulRemoto)' == ''"
Text="Il tag $(Tag) non e' arrivato sul remoto, quasi sempre perche' il ramo e' indietro.%0AGitea creerebbe la release sulla testa del ramo predefinito: il codice allegato non corrisponderebbe al commit dichiarato.%0A%0ASpingi il ramo e rilancia: git push" />
<Message Importance="High" Text=" tag $(Tag) sul remoto" />
<!-- Release gia' presente? -->
<Exec EchoOff="true" ContinueOnError="true" StandardOutputImportance="low"
Command="curl -s -K &quot;$(CurlCfg)&quot; -o &quot;$(RispostaJson)&quot; &quot;$(Api)/releases/tags/$(Tag)&quot;" />
<LeggiIdRelease Risposta="$(RispostaJson)">
<Output TaskParameter="Id" PropertyName="IdEsistente" />
</LeggiIdRelease>
<Error Condition="'$(IdEsistente)' != '' AND '$(Sovrascrivi)' != 'true'"
Text="La release $(Tag) esiste gia'. Alza la versione, oppure rilancia con -p:Sovrascrivi=true." />
<Exec Condition="'$(IdEsistente)' != ''" EchoOff="true" StandardOutputImportance="low"
Command="curl -s -K &quot;$(CurlCfg)&quot; -X DELETE &quot;$(Api)/releases/$(IdEsistente)&quot;" />
<Message Condition="'$(IdEsistente)' != ''" Importance="High"
Text=" release $(Tag) esistente: sostituita" />
<!-- Creazione -->
<PreparaCorpoRelease Destinazione="$(CorpoJson)" Tag="$(Tag)" Versione="$(V)"
Note="$(Note)" Bozza="$(Bozza)" />
<Exec EchoOff="true" StandardOutputImportance="low"
Command="curl -s -K &quot;$(CurlCfg)&quot; -X POST -H &quot;Content-Type: application/json&quot; --data-binary &quot;@$(CorpoJson)&quot; -o &quot;$(RispostaJson)&quot; &quot;$(Api)/releases&quot;" />
<LeggiIdRelease Risposta="$(RispostaJson)">
<Output TaskParameter="Id" PropertyName="IdRelease" />
<Output TaskParameter="Errore" PropertyName="ErroreRelease" />
</LeggiIdRelease>
<Error Condition="'$(IdRelease)' == ''"
Text="Creazione della release non riuscita: $(ErroreRelease)" />
<Message Importance="High" Text=" release creata" />
<!--
Allegati: l'installatore e la copia portabile, entrambi già costruiti da
Pacchetto. Nient'altro — in particolare nessun sorgente: si pubblica il
programma, non il progetto.
Gli archivi "Source code" che Gitea mostra da sé sulla pagina della release
non arrivano da qui: li genera il server dal tag, e si tolgono solo dalla
sua configurazione (DISABLE_DOWNLOAD_SOURCE_ARCHIVES in app.ini).
-->
<ItemGroup>
<Allegato Include="$(Setup)" />
<Allegato Include="$(CartellaPacchetti)\Encelado_$(V)_portabile.zip" />
</ItemGroup>
<Exec Condition="Exists('%(Allegato.FullPath)')" EchoOff="true" StandardOutputImportance="low"
Command="curl -s -K &quot;$(CurlCfg)&quot; -X POST -F &quot;attachment=@%(Allegato.FullPath)&quot; &quot;$(Api)/releases/$(IdRelease)/assets?name=%(Allegato.Filename)%(Allegato.Extension)&quot;" />
<Message Importance="High" Text=" caricato %(Allegato.Filename)%(Allegato.Extension)"
Condition="Exists('%(Allegato.FullPath)')" />
<!-- Il token non deve sopravvivere al rilascio. -->
<Delete Files="$(CurlCfg);$(CorpoJson);$(RispostaJson)" ContinueOnError="true" />
<Message Importance="High" Text=" " />
<Message Importance="High" Text="Rilascio completato:" />
<Message Importance="High" Text=" $(GUrl)/$(GOwner)/$(GRepo)/releases/tag/$(Tag)" />
</Target>
</Project>
+10
View File
@@ -0,0 +1,10 @@
{
"_commento": "Copia questo file in gitea.json e metti il token. gitea.json e' escluso dal controllo di versione perche' contiene una credenziale. In alternativa usa le variabili d'ambiente GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_TOKEN, che hanno la precedenza su questo file.",
"url": "http://192.168.30.23:3000",
"owner": "Alby96",
"repo": "Encelado",
"_token": "Gitea > Impostazioni > Applicazioni > Genera nuovo token, permesso 'repository: read and write'.",
"token": "INSERISCI_QUI_IL_TOKEN"
}
+52
View File
@@ -0,0 +1,52 @@
{
"_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
}
}
+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
+12
View File
@@ -0,0 +1,12 @@
<Application x:Class="Encelado.Bot.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnMainWindowClose">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Ui/Theme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+174
View File
@@ -0,0 +1,174 @@
using System.IO;
using System.Windows;
using System.Windows.Threading;
using Encelado.Bot.Configuration;
using Encelado.Bot.Logging;
namespace Encelado.Bot;
public partial class App : Application
{
/// <summary>Loaded once at startup and shared by every window.</summary>
public static BotConfig Config { get; private set; } = new();
public static IReadOnlyList<string> ConfigWarnings { get; private set; } = [];
public static string ConfigPath { get; private set; } = string.Empty;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// A crash in a background task must show a dialog, not vanish silently.
DispatcherUnhandledException += OnDispatcherException;
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
Log.Error("unhandled exception", args.ExceptionObject as Exception);
TaskScheduler.UnobservedTaskException += (_, args) =>
{
Log.Error("unobserved task exception", args.Exception);
args.SetObserved();
};
try
{
ConfigPath = ResolveConfigPath();
Config = ConfigLoader.Load(ConfigPath, out List<string> warnings);
ConfigWarnings = warnings;
}
catch (Exception ex)
{
MessageBox.Show(
$"Impossibile leggere la configurazione:\n\n{ex.Message}",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(2);
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
// failure above has to be able to abort startup before any window exists.
MainWindow window = new MainWindow();
MainWindow = window;
window.Show();
}
/// <summary>
/// Copies the factory <c>strategy.json</c> beside the configuration the first time,
/// like the configuration itself: it is the operator's file from then on.
/// </summary>
public static void SeedStrategyFile(string path)
{
if (File.Exists(path))
{
return;
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
File.WriteAllText(path + ".tmp", Core.Baskets.BasketStrategyConfig.DefaultJson);
File.Move(path + ".tmp", path, overwrite: true);
SeedNote = (SeedNote is null ? string.Empty : SeedNote + " · ") + $"strategy.json di fabbrica creato in {path}";
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
SeedNote = $"impossibile creare {path}: {ex.Message}";
}
}
/// <summary>
/// Where the configuration lives, and how it gets there the first time.
/// <para>
/// <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 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);
MessageBox.Show(
$"Errore imprevisto:\n\n{e.Exception.Message}",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true;
}
protected override void OnExit(ExitEventArgs e)
{
Log.ShutdownAsync().GetAwaiter().GetResult();
base.OnExit(e);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

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;
}
}
}
@@ -0,0 +1,246 @@
using Encelado.Core.Baskets;
using Encelado.Etoro;
namespace Encelado.Bot.Configuration;
/// <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
{
/// <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>Execution mode and working folders.</summary>
public RunOptions Run { 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 BotConfig Validate()
{
Etoro.Validate();
Run.Validate();
Ui.Validate();
Logging.Validate();
return this;
}
}
/// <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>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>Required, together with the typed phrase <c>CONFERMO LIVE</c> at start, for <c>Live</c>.</summary>
public bool AllowLive { get; set; }
/// <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>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 string StrategyFile { get; set; } = "strategy.json";
public string DataDirectory { get; set; } = "data";
public string KnowledgeDirectory { get; set; } = "knowledge";
public string ReportsDirectory { get; set; } = "reports";
public double PaperStartingBalance { get; set; } = 10_000;
public double PaperSlippagePips { get; set; } = 0.3;
/// <summary>Set by the loader to the configuration file's folder.</summary>
public string BaseDirectory { get; set; } = AppContext.BaseDirectory;
public ExecutionMode Mode => ExecutionModeExtensions.TryParse(ExecutionMode, out ExecutionMode m) ? m : Core.Baskets.ExecutionMode.Demo;
public string Resolve(string relativeOrAbsolute) =>
Path.IsPathRooted(relativeOrAbsolute) ? relativeOrAbsolute : Path.Combine(BaseDirectory, relativeOrAbsolute);
public string StrategyPath => Resolve(StrategyFile);
public string DataPath => Resolve(DataDirectory);
public string KnowledgePath => Resolve(KnowledgeDirectory);
public string ReportsPath => Resolve(ReportsDirectory);
public void Validate()
{
if (!ExecutionModeExtensions.TryParse(ExecutionMode, out ExecutionMode mode))
{
throw new InvalidOperationException("run.executionMode deve essere Paper, Demo oppure Live.");
}
if (mode == Core.Baskets.ExecutionMode.Backtest)
{
throw new InvalidOperationException("run.executionMode 'Backtest' non è una modalità del bot: il backtest si lancia dallo strumento di ricerca.");
}
if (mode.IsLive() && !AllowLive)
{
throw new InvalidOperationException("la modalità Live richiede run.allowLive = true (e la frase CONFERMO LIVE all'avvio).");
}
if (PollSeconds is < 2 or > 30)
{
throw new InvalidOperationException("run.pollSeconds deve essere fra 2 e 30.");
}
if (StatusSeconds is < 10 or > 3600)
{
throw new InvalidOperationException("run.statusSeconds deve essere fra 10 e 3600.");
}
if (PaperStartingBalance is <= 0 or > 1e9)
{
throw new InvalidOperationException("run.paperStartingBalance deve essere positivo.");
}
if (PaperSlippagePips is < 0 or > 10)
{
throw new InvalidOperationException("run.paperSlippagePips deve essere fra 0 e 10.");
}
}
}
/// <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>
/// <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 TimeZone { get; set; } = ComputerZone;
/// <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;
}
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()
{
TimeZone ??= ComputerZone;
}
}
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>. 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 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>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>Lines kept in the activity strip of the dashboard.</summary>
public int StatusLines { get; set; } = 200;
/// <summary>Lines kept by the log page: the memory ceiling for the in-app log. The file on disk stays complete.</summary>
public int BufferedLines { get; set; } = 5_000;
/// <summary>Absolute path of the log directory, created on demand.</summary>
public string ResolveDirectory()
{
string directory = string.IsNullOrWhiteSpace(Directory) ? "logs" : Directory;
return Path.IsPathRooted(directory) ? directory : Path.Combine(BaseDirectory, directory);
}
/// <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);
public void Validate()
{
if (MaxFileSizeMb is < 0 or > 4096)
{
throw new InvalidOperationException("logging.maxFileSizeMb deve essere fra 0 e 4096.");
}
if (MaxFiles is < 1 or > 500)
{
throw new InvalidOperationException("logging.maxFiles deve essere fra 1 e 500.");
}
if (StatusLines is < 20 or > 5_000)
{
throw new InvalidOperationException("logging.statusLines deve essere fra 20 e 5000.");
}
if (BufferedLines is < 100 or > 200_000)
{
throw new InvalidOperationException("logging.bufferedLines deve essere fra 100 e 200000.");
}
if (BufferedLines < StatusLines)
{
throw new InvalidOperationException("logging.bufferedLines deve essere >= logging.statusLines.");
}
}
}
@@ -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
}
}
""";
}
@@ -0,0 +1,297 @@
using System.Globalization;
using System.Text.Json;
using Encelado.Core.Baskets;
namespace Encelado.Bot.Configuration;
/// <summary>
/// Reads <c>encelado.json</c> by hand with <see cref="JsonDocument"/>. No reflection
/// binder means no trimming surprises and no silent type coercion — an unknown key is
/// reported instead of ignored.
/// <para>
/// 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
{
private static readonly JsonDocumentOptions ParseOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
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);
using JsonDocument doc = JsonDocument.Parse(stream, ParseOptions);
ApplyJson(config, doc.RootElement, warnings);
}
else
{
warnings.Add($"configurazione '{path}' non trovata; uso i valori di fabbrica e le variabili d'ambiente");
ConfigDefaults.ApplyTo(config);
}
// A sibling *.local.json overlays machine-specific overrides.
string localPath = Path.ChangeExtension(path, null) + ".local.json";
if (File.Exists(localPath))
{
using FileStream stream = File.OpenRead(localPath);
using JsonDocument doc = JsonDocument.Parse(stream, ParseOptions);
ApplyJson(config, doc.RootElement, warnings);
}
ApplyEnvironment(config);
config.Ui.ResolveTimeZone(out string? zoneWarning);
if (zoneWarning is not null)
{
warnings.Add(zoneWarning);
}
return config;
}
private static void ApplyJson(BotConfig config, JsonElement root, List<string> warnings)
{
if (root.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException("La radice della configurazione deve essere un oggetto JSON.");
}
foreach (JsonProperty section in root.EnumerateObject())
{
if (section.Name.StartsWith('_'))
{
continue;
}
switch (section.Name.ToLowerInvariant())
{
case "etoro":
ReadEtoro(config, section.Value, warnings);
break;
case "run":
ReadRun(config, section.Value, warnings);
break;
case "ui":
ReadUi(config, section.Value, warnings);
break;
case "logging":
ReadLogging(config, section.Value, warnings);
break;
case "$schema":
break;
default:
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 ReadEtoro(BotConfig config, JsonElement e, List<string> warnings)
{
Etoro.EtoroOptions o = config.Etoro;
foreach (JsonProperty p in Properties(e, "etoro", warnings))
{
switch (p.Name.ToLowerInvariant())
{
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 ReadRun(BotConfig config, JsonElement e, List<string> warnings)
{
RunOptions o = config.Run;
foreach (JsonProperty p in Properties(e, "run", warnings))
{
switch (p.Name.ToLowerInvariant())
{
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 "closeonshutdown": o.CloseOnShutdown = Bool(p); 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 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 "timezone": o.TimeZone = Str(p); break;
default: warnings.Add($"chiave sconosciuta 'ui.{p.Name}'"); break;
}
}
}
private static void ReadLogging(BotConfig config, JsonElement e, List<string> warnings)
{
LoggingOptions o = config.Logging;
foreach (JsonProperty p in Properties(e, "logging", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "level": o.Level = Str(p); break;
case "console": o.Console = Bool(p); break;
case "directory": o.Directory = Str(p); break;
case "file": o.File = Str(p); break;
case "maxfilesizemb": o.MaxFileSizeMb = Int(p); break;
case "maxfiles": o.MaxFiles = Int(p); break;
case "statuslines": o.StatusLines = Int(p); break;
case "bufferedlines": o.BufferedLines = Int(p); break;
case "tradejournal" or "decisionlog" or "executionlog" or "logmarketdata":
warnings.Add($"la chiave 'logging.{p.Name}' appartiene a una versione precedente ed è stata ignorata: il ledger dei basket sta in data/ledger.");
break;
default: warnings.Add($"chiave sconosciuta 'logging.{p.Name}'"); break;
}
}
}
private static void ApplyEnvironment(BotConfig config)
{
// 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.Etoro.ApiKey = apiKey.Trim();
config.Etoro.UserKey = userKey.Trim();
}
string? etoroEnvironment = Environment.GetEnvironmentVariable("ETORO_ENVIRONMENT");
if (!string.IsNullOrWhiteSpace(etoroEnvironment))
{
config.Etoro.Environment = etoroEnvironment.Trim().ToLowerInvariant();
}
string? executionMode = Environment.GetEnvironmentVariable("ENCELADO_EXECUTION_MODE");
if (!string.IsNullOrWhiteSpace(executionMode))
{
config.Run.ExecutionMode = ExecutionModeExtensions.TryParse(executionMode, out ExecutionMode m) ? m.ToString() : executionMode.Trim();
}
string? level = Environment.GetEnvironmentVariable("ENCELADO_LOG_LEVEL");
if (!string.IsNullOrWhiteSpace(level))
{
config.Logging.Level = level.Trim();
}
string? zone = Environment.GetEnvironmentVariable("ENCELADO_TIME_ZONE");
if (!string.IsNullOrWhiteSpace(zone))
{
config.Ui.TimeZone = zone.Trim();
}
}
private static IEnumerable<JsonProperty> Properties(JsonElement e, string section, List<string> warnings)
{
if (e.ValueKind != JsonValueKind.Object)
{
warnings.Add($"'{section}' deve essere un oggetto; ignorata");
yield break;
}
foreach (JsonProperty p in e.EnumerateObject())
{
// Keys beginning with '_' are inline documentation. JSON has no comments,
// and a config full of trading assumptions badly needs them.
if (!p.Name.StartsWith('_'))
{
yield return p;
}
}
}
private static string Str(JsonProperty p) => p.Value.ValueKind switch
{
JsonValueKind.String => p.Value.GetString() ?? string.Empty,
JsonValueKind.Number => p.Value.GetDouble().ToString(CultureInfo.InvariantCulture),
_ => string.Empty,
};
private static double Num(JsonProperty p) => p.Value.ValueKind switch
{
JsonValueKind.Number => p.Value.GetDouble(),
JsonValueKind.String when double.TryParse(
p.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) => d,
JsonValueKind.True => 1,
JsonValueKind.False => 0,
_ => throw new InvalidOperationException($"'{p.Name}' deve essere un numero."),
};
private static int Int(JsonProperty p) => (int)Math.Round(Num(p));
private static bool Bool(JsonProperty p) => p.Value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => p.Value.GetDouble() != 0,
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."),
};
}
@@ -0,0 +1,206 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Encelado.Bot.Configuration;
/// <summary>
/// Targeted edits to <c>encelado.json</c> made from the settings screen.
/// <para>
/// The file is parsed into a <see cref="JsonNode"/> tree, one value is replaced, and
/// the tree is written back. Serialising a <see cref="BotConfig"/> instead would be
/// simpler and wrong: it would silently delete every key the loader does not model —
/// including the <c>_</c>-prefixed lines that document what each number is for and why
/// it has that value — and reorder everything else.
/// </para>
/// <para>
/// The write goes to a temporary file first and is then moved into place, so a failure
/// halfway through leaves the previous configuration intact rather than a truncated
/// file the application cannot start from.
/// </para>
/// </summary>
public static class ConfigWriter
{
private static readonly JsonWriterOptions WriteOptions = new() { Indented = true };
private static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Sets <c>logging.directory</c> and saves.</summary>
public static void SetLogDirectory(string configPath, string directory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configPath);
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
Apply(configPath, new Dictionary<string, JsonNode?> { ["logging.directory"] = directory });
}
/// <summary>
/// Writes a batch of values addressed by dotted path, in one atomic save.
/// <para>
/// Paths look like <c>risk.stakePct</c>, <c>engine.timeFrame</c> or
/// <c>symbols[0].parameters.period</c>. Missing intermediate objects are created;
/// missing array elements are an error, because inventing a symbol out of a typo
/// would be worse than refusing.
/// </para>
/// <para>
/// A batch is all-or-nothing on purpose. Applying half a settings screen would leave
/// a configuration that no one chose — for instance a stake raised without the
/// position cap that has to accompany it, which the validator would then reject at
/// the next start.
/// </para>
/// </summary>
public static void Apply(string configPath, IReadOnlyDictionary<string, JsonNode?> changes)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configPath);
ArgumentNullException.ThrowIfNull(changes);
if (changes.Count == 0)
{
return;
}
Update(configPath, root =>
{
foreach ((string path, JsonNode? value) in changes)
{
SetPath(root, path, value);
}
});
}
private static void SetPath(JsonObject root, string path, JsonNode? value)
{
string[] segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
throw new ArgumentException($"Percorso vuoto.", nameof(path));
}
// Un JsonNode può appartenere a un solo albero: assegnarne uno che ne ha già
// uno solleva «The node already has a parent». Succede sistematicamente qui,
// perché la pagina delle impostazioni applica lo stesso lotto di modifiche due
// volte — prima a una copia temporanea per validarlo, poi al file vero. Clonare
// rende l'insieme delle modifiche riutilizzabile, che è come un chiamante si
// aspetta che si comporti.
value = value?.DeepClone();
JsonNode current = root;
for (int i = 0; i < segments.Length - 1; i++)
{
current = Descend(current, segments[i], path);
}
(string name, int? index) = Parse(segments[^1]);
if (index is { } arrayIndex)
{
JsonArray array = Array(current, name, path);
if (arrayIndex >= array.Count)
{
throw new InvalidOperationException(
$"'{path}': l'elemento {arrayIndex} non esiste in '{name}'.");
}
array[arrayIndex] = value;
return;
}
if (current is not JsonObject target)
{
throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto.");
}
target[name] = value;
}
private static JsonNode Descend(JsonNode current, string segment, string path)
{
(string name, int? index) = Parse(segment);
if (index is { } arrayIndex)
{
JsonArray array = Array(current, name, path);
if (arrayIndex >= array.Count)
{
throw new InvalidOperationException(
$"'{path}': l'elemento {arrayIndex} non esiste in '{name}'.");
}
return array[arrayIndex]
?? throw new InvalidOperationException($"'{path}': '{name}[{arrayIndex}]' è null.");
}
if (current is not JsonObject parent)
{
throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto.");
}
if (parent[name] is not JsonObject child)
{
child = [];
parent[name] = child;
}
return child;
}
private static JsonArray Array(JsonNode current, string name, string path)
{
if (current is not JsonObject parent)
{
throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto.");
}
return parent[name] as JsonArray
?? throw new InvalidOperationException($"'{path}': '{name}' non è un array.");
}
/// <summary>Splits <c>symbols[0]</c> into its name and index.</summary>
private static (string Name, int? Index) Parse(string segment)
{
int bracket = segment.IndexOf('[', StringComparison.Ordinal);
if (bracket < 0)
{
return (segment, null);
}
if (!segment.EndsWith(']') ||
!int.TryParse(segment.AsSpan(bracket + 1, segment.Length - bracket - 2), out int index) ||
index < 0)
{
throw new ArgumentException($"Indice non valido in '{segment}'.");
}
return (segment[..bracket], index);
}
private static void Update(string path, Action<JsonObject> edit)
{
if (!File.Exists(path))
{
throw new FileNotFoundException($"Configurazione non trovata: {path}", path);
}
JsonNode? parsed = JsonNode.Parse(File.ReadAllText(path), documentOptions: ReadOptions);
if (parsed is not JsonObject root)
{
throw new InvalidOperationException($"{path} non contiene un oggetto JSON.");
}
edit(root);
string temporary = path + ".tmp";
using (FileStream stream = File.Create(temporary))
using (Utf8JsonWriter writer = new(stream, WriteOptions))
{
root.WriteTo(writer);
}
File.Move(temporary, path, overwrite: true);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- WPF needs the Windows-flavoured TFM; the engine libraries stay portable. -->
<TargetFramework>net10.0-windows</TargetFramework>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
<!-- Repeated here on purpose: the temporary project MSBuild generates to compile
XAML does not import Directory.Build.props, so without these the markup pass
fails on types the rest of the project takes for granted. -->
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<!-- Both are inherited from Directory.Build.props, where they make sense for a
trimmed console binary. They are fatal here: WPF's font cache needs real
culture data and dies at startup under invariant globalization, and stripped
resource keys turn every framework exception into an unreadable token.
The engine itself never depends on the ambient culture — all of its parsing
and wire formatting pins CultureInfo.InvariantCulture explicitly. -->
<InvariantGlobalization>false</InvariantGlobalization>
<UseSystemResourceKeys>false</UseSystemResourceKeys>
<RootNamespace>Encelado.Bot</RootNamespace>
<AssemblyName>Encelado</AssemblyName>
<ApplicationIcon>Assets\encelado.ico</ApplicationIcon>
<PublishReadyToRun>true</PublishReadyToRun>
<SelfContained>false</SelfContained>
<IsAotCompatible>false</IsAotCompatible>
<IsTrimmable>false</IsTrimmable>
<!-- A desktop app is the single entry point; no console window behind it. -->
<DisableWinExeOutputInference>true</DisableWinExeOutputInference>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Encelado.Core\Encelado.Core.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" />
<Resource Include="Assets\encelado.ico" />
</ItemGroup>
</Project>
@@ -0,0 +1,209 @@
using System.Globalization;
using Encelado.Bot.Ui;
namespace Encelado.Bot.Engine;
public enum BotState
{
Stopped = 0,
Starting,
Running,
Stopping,
Faulted,
}
/// <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,
int Adds,
bool IsOpen,
double HalfLife)
{
public string PnlDisplay => PnlUsd.ToString("+#,##0.00;-#,##0.00;0.00", CultureInfo.CurrentCulture);
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
{
double m = (TimeUtc - DateTime.UtcNow).TotalMinutes;
return m < 0 ? "passato" : m < 90 ? $"fra {m:0} min" : $"fra {m / 60:0.0} h";
}
}
}
/// <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>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; }
public string? Error { get; init; }
public DateTime? StartedAtUtc { get; init; }
public TimeSpan Uptime { get; init; }
/// <summary>PAPER, DEMO, LIVE — the badge.</summary>
public required string Mode { 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 string ExecutionMode { get; init; } = string.Empty;
public string Endpoint { get; init; } = string.Empty;
public string Preset { 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; }
public double Equity { get; init; }
public double Balance { get; init; }
public double AvailableBalance { get; init; }
public double PeakEquity { get; init; }
public double DrawdownPct { 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 DailyLossPct { get; init; }
public double TodayPnl { get; init; }
public double TodayPnlPct { get; init; }
public double OpenPnl { get; init; }
public double OpenPnlPct { get; init; }
public int OpenBaskets { get; init; }
public int MaxBaskets { get; init; }
public bool Halted { get; init; }
public string? HaltReason { get; init; }
public bool EquityStopped { get; init; }
public bool KillSwitched { 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; }
/// <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; } = [];
public IReadOnlyList<BasketRow> Baskets { get; init; } = [];
public IReadOnlyList<QuoteRow> Quotes { get; init; } = [];
public ContextRow? Context { get; init; }
}
public readonly record struct CommandResult(bool Ok, string Message);
@@ -0,0 +1,315 @@
using Encelado.Bot.Baskets;
using Encelado.Bot.Configuration;
using Encelado.Bot.Logging;
using Encelado.Bot.Ui;
namespace Encelado.Bot.Engine;
/// <summary>
/// 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> 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, Func<BotConfig, bool, IEngine>? factory = null) : IAsyncDisposable
{
private readonly int _eventCapacity = Math.Max(20, config.Logging.StatusLines);
private readonly Lock _gate = new();
private readonly Queue<EventRow> _events = new();
private readonly Func<BotConfig, bool, IEngine> _factory = factory ?? (static (c, confirmed) => new BasketEngine(c, confirmed));
private IEngine? _engine;
private CancellationTokenSource? _engineCts;
private Task? _engineTask;
private BotState _state = BotState.Stopped;
private string? _error;
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; } }
}
/// <summary>Mirrors the log into the activity feed shown in the window.</summary>
public void AttachLogSink() => Log.Sink = RecordEvent;
public void DetachLogSink() => Log.Sink = null;
// -----------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------
public async Task<CommandResult> StartAsync()
{
lock (_gate)
{
if (_state is BotState.Running or BotState.Starting)
{
return new CommandResult(false, "il bot è già in esecuzione");
}
if (_state == BotState.Stopping)
{
return new CommandResult(false, "l'esecuzione precedente si sta ancora fermando");
}
_state = BotState.Starting;
_error = null;
}
Log.Info("── avvio richiesto ──");
IEngine engine;
try
{
engine = _factory(config, StartConfirmed);
}
catch (Exception ex)
{
lock (_gate)
{
_state = BotState.Faulted;
_error = ex.Message;
}
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;
_startedUtc = DateTime.UtcNow;
}
// RunAsync blocks for the whole session, so it owns a background task and the
// caller gets control back immediately so the UI stays responsive.
Task task = Task.Run(async () =>
{
try
{
await engine.RunAsync(cts.Token).ConfigureAwait(false);
lock (_gate)
{
_state = BotState.Stopped;
}
}
catch (OperationCanceledException)
{
lock (_gate)
{
_state = BotState.Stopped;
}
}
catch (Exception ex)
{
Log.Error("il motore si è fermato con un errore", ex);
lock (_gate)
{
_state = BotState.Faulted;
_error = ex.Message;
}
}
});
lock (_gate)
{
_engineTask = task;
if (_state == BotState.Starting)
{
_state = BotState.Running;
}
}
// 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()
{
CancellationTokenSource? cts;
Task? task;
lock (_gate)
{
if (_state is BotState.Stopped or BotState.Stopping)
{
return new CommandResult(false, "il bot non è in esecuzione");
}
_state = BotState.Stopping;
cts = _engineCts;
task = _engineTask;
}
Log.Info("── arresto richiesto ──");
IEngine? engine;
lock (_gate)
{
engine = _engine;
}
if (engine is not null && config.Run.CloseOnShutdown)
{
try
{
using CancellationTokenSource closing = new(TimeSpan.FromSeconds(30));
await engine.CloseAllAsync("arresto del motore", closing.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Error("chiusura dei basket all'arresto non riuscita", ex);
}
}
if (cts is not null)
{
await cts.CancelAsync().ConfigureAwait(false);
}
if (task is not null)
{
try
{
await task.WaitAsync(TimeSpan.FromSeconds(45)).ConfigureAwait(false);
}
catch (TimeoutException)
{
Log.Warn("il motore non si è fermato entro 45 s");
}
}
lock (_gate)
{
_engine = null;
_engineTask = null;
_engineCts = null;
_state = BotState.Stopped;
}
if (engine is not null)
{
await engine.DisposeAsync().ConfigureAwait(false);
}
cts?.Dispose();
Log.Info("bot fermo");
return new CommandResult(true, "bot fermo");
}
/// <summary>Forwards a command to the running engine.</summary>
public async Task<CommandResult> ExecuteAsync(EngineCommand command, CancellationToken ct)
{
IEngine? engine;
lock (_gate)
{
engine = _state == BotState.Running ? _engine : null;
}
if (engine is null)
{
return new CommandResult(false, "il bot non è in esecuzione");
}
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()
{
IEngine? engine;
BotState state;
string? error;
DateTime? started;
lock (_gate)
{
engine = _engine;
state = _state;
error = _error;
started = _startedUtc;
}
EventRow[] events = SnapshotEvents();
if (engine is null || state is BotState.Stopped or BotState.Faulted)
{
return BasketEngine.IdleSnapshot(config, state, error, events);
}
return engine.Snapshot(state, error, started, events);
}
private EventRow[] SnapshotEvents()
{
lock (_gate)
{
return [.. _events];
}
}
private void RecordEvent(Logging.LogLevel level, DateTime timestamp, string message)
{
EventRow view = new(
UiClock.Format(timestamp.ToUniversalTime(), "HH:mm:ss"),
level.ToString().ToLowerInvariant(),
message);
lock (_gate)
{
if (_events.Count >= _eventCapacity)
{
_events.Dequeue();
}
_events.Enqueue(view);
}
// 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);
}
catch
{
// A subscriber that throws must not take down the logging path.
}
}
/// <summary>Raised for every log line, on the thread that logged it.</summary>
public event Action<EventRow>? EventLogged;
public async ValueTask DisposeAsync()
{
DetachLogSink();
await StopAsync().ConfigureAwait(false);
}
}
@@ -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);
}
+10
View File
@@ -0,0 +1,10 @@
// Declared as a real source file rather than <ImplicitUsings>. The temporary project
// MSBuild generates to compile XAML markup does not inherit that property, so the
// markup pass would otherwise fail on types the rest of the project takes for granted.
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;
+593
View File
@@ -0,0 +1,593 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Channels;
using Encelado.Bot.Configuration;
namespace Encelado.Bot.Logging;
public enum LogLevel : byte
{
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
None = 5,
}
/// <summary>
/// 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 = "";
/// <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 });
private static Task? _writerTask;
private static StreamWriter? _file;
private static LogLevel _minimum = LogLevel.Info;
private static bool _console = true;
private static bool _colors;
private static long _dropped;
private static long _enqueued;
private static long _processed;
private static string? _path;
private static long _maxBytes;
private static int _maxFiles = 10;
private static long _written;
public static LogLevel Minimum => _minimum;
public static bool IsEnabled(LogLevel level) => level >= _minimum;
/// <summary>
/// Optional secondary sink, used by the dashboard to mirror the log into its live
/// activity feed. Invoked synchronously on the calling thread, so implementations
/// must be cheap and must never throw.
/// </summary>
public static Action<LogLevel, DateTime, string>? Sink { get; set; }
public static void Initialize(LoggingOptions options)
{
ArgumentNullException.ThrowIfNull(options);
_minimum = ParseLevel(options.Level);
_console = options.Console;
_colors = _console && !Console.IsOutputRedirected;
_maxBytes = options.MaxFileSizeMb > 0 ? options.MaxFileSizeMb * 1024L * 1024L : 0;
_maxFiles = options.MaxFiles;
_path = options.ResolvePath(options.File);
OpenFile();
_writerTask ??= Task.Run(WriteLoopAsync);
}
/// <summary>Absolute path of the active log file, for the UI to show and open.</summary>
public static string? FilePath => _path;
private static void OpenFile()
{
if (string.IsNullOrWhiteSpace(_path))
{
return;
}
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, 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)
{
Console.Error.WriteLine($"[log] cannot open {_path}: {ex.Message}");
_file = null;
}
}
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. Both the size and the count are settings.
/// </summary>
private static void RotateIfNeeded()
{
if (_file is null || _maxBytes <= 0 || _written < _maxBytes || string.IsNullOrWhiteSpace(_path))
{
return;
}
try
{
_file.Flush();
_file.Dispose();
_file = null;
string directory = Path.GetDirectoryName(Path.GetFullPath(_path))!;
string name = Path.GetFileNameWithoutExtension(_path);
string extension = Path.GetExtension(_path);
string Slot(int i) => Path.Combine(directory, $"{name}.{i}{extension}");
string oldest = Slot(_maxFiles);
if (File.Exists(oldest))
{
File.Delete(oldest);
}
for (int i = _maxFiles - 1; i >= 1; i--)
{
if (File.Exists(Slot(i)))
{
File.Move(Slot(i), Slot(i + 1), overwrite: true);
}
}
File.Move(_path, Slot(1), overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Console.Error.WriteLine($"[log] rotation failed: {ex.Message}");
}
finally
{
OpenFile();
}
}
// -----------------------------------------------------------------------
// Call sites
// -----------------------------------------------------------------------
public static void Trace(string message, [CallerFilePath] string caller = "") =>
Write(LogLevel.Trace, message, null, null, caller);
public static void Debug(string message, [CallerFilePath] string caller = "") =>
Write(LogLevel.Debug, message, null, null, caller);
public static void Info(string message, [CallerFilePath] string caller = "") =>
Write(LogLevel.Info, message, null, null, caller);
public static void Warn(string message, [CallerFilePath] string caller = "") =>
Write(LogLevel.Warn, message, null, null, caller);
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)
{
return;
}
DateTime now = DateTime.Now;
if (Queue.Writer.TryWrite(new Entry(now, level, message, exception, eventCode, SourceOf(caller))))
{
Interlocked.Increment(ref _enqueued);
}
else
{
Interlocked.Increment(ref _dropped);
}
Action<LogLevel, DateTime, string>? sink = Sink;
if (sink is not null)
{
try
{
sink(level, now, exception is null ? message : $"{message} | {exception.Message}");
}
catch
{
// A misbehaving sink must never break the caller's control flow.
}
}
}
/// <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
/// interleaved with asynchronous log lines.
/// </summary>
public static async Task FlushAsync(TimeSpan timeout)
{
long deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency);
while (Interlocked.Read(ref _processed) < Interlocked.Read(ref _enqueued))
{
if (Stopwatch.GetTimestamp() >= deadline)
{
return;
}
await Task.Delay(5).ConfigureAwait(false);
}
if (_file is not null)
{
try
{
await _file.FlushAsync().ConfigureAwait(false);
}
catch (IOException)
{
// Best effort.
}
}
}
// -----------------------------------------------------------------------
// Writer
// -----------------------------------------------------------------------
private static async Task WriteLoopAsync()
{
StringBuilder sb = new(512);
long lastFlush = Stopwatch.GetTimestamp();
await foreach (Entry entry in Queue.Reader.ReadAllAsync().ConfigureAwait(false))
{
// The writer must never take the process down: a broken console handle or a
// full disk should cost log lines, not the trading session.
try
{
if (_console)
{
WriteConsole(entry, sb);
}
if (_file is not null)
{
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.
if (entry.Level >= LogLevel.Warn ||
Stopwatch.GetElapsedTime(lastFlush) >= TimeSpan.FromMilliseconds(500))
{
await _file.FlushAsync().ConfigureAwait(false);
lastFlush = Stopwatch.GetTimestamp();
RotateIfNeeded();
}
}
}
catch (Exception ex)
{
Interlocked.Increment(ref _dropped);
try
{
Console.Error.WriteLine($"[log] writer failure: {ex.Message}");
}
catch (IOException)
{
// Nothing left to write to.
}
}
finally
{
Interlocked.Increment(ref _processed);
}
}
}
/// <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()
{
Queue.Writer.TryComplete();
if (_writerTask is not null)
{
try
{
await _writerTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
{
// Give up rather than hang the shutdown path.
}
_writerTask = null;
}
if (_file is not null)
{
try
{
await _file.FlushAsync().ConfigureAwait(false);
await _file.DisposeAsync().ConfigureAwait(false);
}
catch (IOException)
{
// Best effort.
}
_file = null;
}
long dropped = Interlocked.Read(ref _dropped);
if (dropped > 0)
{
Console.Error.WriteLine($"[log] {dropped} entries were dropped.");
}
}
public static LogLevel ParseLevel(string? text) => text?.Trim().ToLowerInvariant() switch
{
"trace" or "verbose" => LogLevel.Trace,
"debug" => LogLevel.Debug,
"info" or "information" => LogLevel.Info,
"warn" or "warning" => LogLevel.Warn,
"error" => LogLevel.Error,
"none" or "off" => LogLevel.None,
_ => LogLevel.Info,
};
private static string Tag(LogLevel level) => level switch
{
LogLevel.Trace => "TRC",
LogLevel.Debug => "DBG",
LogLevel.Info => "INF",
LogLevel.Warn => "WRN",
LogLevel.Error => "ERR",
_ => " ",
};
private static string Color(LogLevel level) => level switch
{
LogLevel.Trace => "",
LogLevel.Debug => "",
LogLevel.Info => AnsiReset,
LogLevel.Warn => "",
LogLevel.Error => "",
_ => AnsiReset,
};
internal readonly record struct Entry(
DateTime Timestamp,
LogLevel Level,
string Message,
Exception? Exception,
string? EventCode,
string Source);
}
+78
View File
@@ -0,0 +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="860" Width="1320"
MinHeight="620" MinWidth="980"
WindowStartupLocation="CenterScreen"
Background="{StaticResource Bg}"
UseLayoutRounding="True"
TextOptions.TextRenderingMode="ClearType">
<!--
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.
-->
<DockPanel>
<!-- ==================== 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>
<!-- 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>
<!-- schede -->
<ListBox x:Name="Nav" Grid.Column="1" Style="{StaticResource TabList}"
SelectionChanged="OnNavigated" VerticalAlignment="Center"/>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- ==================== pagina ==================== -->
<ContentControl x:Name="PageHost" Margin="20,16,16,16"/>
</DockPanel>
</Window>
@@ -0,0 +1,684 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Encelado.Bot.Configuration;
using Encelado.Bot.Engine;
using Encelado.Bot.Logging;
using Encelado.Bot.Ui;
using Encelado.Bot.Ui.Pages;
using Encelado.Core.Baskets;
namespace Encelado.Bot;
/// <summary>
/// The shell: 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
{
private readonly BotConfig _config = App.Config;
private readonly MainViewModel _vm;
private readonly BotSupervisor _supervisor;
private readonly DispatcherTimer _timer;
private readonly DashboardPage _dashboard = new();
private readonly LogPage _log = new();
private readonly SettingsPage _settings = new();
private readonly UserControl[] _pages;
private bool _busy;
private bool _closing;
private bool _closed;
public MainWindow()
{
InitializeComponent();
_vm = new MainViewModel
{
StatusLines = _config.Logging.StatusLines,
Log = new LogViewModel(_config.Logging.BufferedLines),
};
_supervisor = new BotSupervisor(_config);
_supervisor.AttachLogSink();
_supervisor.EventLogged += _vm.Log.Enqueue;
DataContext = _vm;
_pages = [_dashboard, _log, _settings];
foreach (UserControl page in _pages)
{
page.DataContext = _vm;
}
_dashboard.Actions = this;
_log.Actions = this;
_settings.Actions = this;
Nav.ItemsSource = new[] { "Dashboard", "Log", "Impostazioni" };
Nav.SelectedIndex = 0;
VersionText.Text = $"v{Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}";
LoadLogo();
RefreshSettings();
// One snapshot per second: fast enough to feel live, cheap enough that the UI
// never competes with the trading loop for CPU.
_timer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1),
};
_timer.Tick += (_, _) => Refresh();
_timer.Start();
Loaded += OnLoaded;
Closing += OnClosing;
}
private void OnNavigated(object sender, SelectionChangedEventArgs e)
{
if (Nav.SelectedIndex >= 0 && Nav.SelectedIndex < _pages.Length)
{
PageHost.Content = _pages[Nav.SelectedIndex];
}
}
// -----------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------
private void OnLoaded(object sender, RoutedEventArgs e)
{
ApplyNativeDarkTitleBar();
Refresh();
foreach (string warning in App.ConfigWarnings)
{
Log.Warn($"configurazione: {warning}");
}
Log.Info($"Encelado avviato — configurazione {App.ConfigPath}");
Log.Info($"log in {_config.Logging.ResolveDirectory()}; orari mostrati nel fuso {UiClock.ZoneName}");
if (App.SeedNote is { } seeded)
{
Log.Warn(seeded);
}
if (EtoroKeyStore.Resolve(_config, out string origin))
{
Log.Info($"chiavi eToro: {origin}");
}
else
{
Log.Info("nessuna chiave eToro trovata: apro la finestra di accesso (le quotazioni richiedono le chiavi anche in Paper)");
PromptForCredentials();
}
RefreshSettings();
WarnIfConfigurationIsStale();
}
/// <summary>
/// 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 WarnIfConfigurationIsStale()
{
bool stale = App.ConfigWarnings.Any(static w => w.Contains("versione precedente", StringComparison.Ordinal));
if (!stale)
{
return;
}
Log.Warn("la configurazione contiene chiavi di una versione precedente");
MessageBox.Show(
this,
"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);
}
private void LoadLogo()
{
try
{
LogoImage.Source = new BitmapImage(
new Uri("pack://application:,,,/Assets/encelado.ico", UriKind.Absolute));
}
catch (Exception ex)
{
Log.Debug($"logo non caricato: {ex.Message}");
}
}
/// <summary>Asks the desktop window manager to draw <b>its own</b> title bar dark.</summary>
private void ApplyNativeDarkTitleBar()
{
const int DwmwaUseImmersiveDarkMode = 20;
try
{
nint handle = new WindowInteropHelper(this).Handle;
int enabled = 1;
_ = DwmSetWindowAttribute(handle, DwmwaUseImmersiveDarkMode, ref enabled, sizeof(int));
}
catch (DllNotFoundException)
{
// Not Windows, or a stripped image. Nothing to do.
}
}
[DllImport("dwmapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int value, int size);
// -----------------------------------------------------------------------
// Live refresh
// -----------------------------------------------------------------------
private void Refresh()
{
_vm.Apply(_supervisor.Snapshot());
_vm.Log.Flush();
_vm.Clock = UiClock.Format(DateTime.UtcNow, "HH:mm:ss");
_vm.ClockZone = UiClock.Label;
}
// -----------------------------------------------------------------------
// Bot control
// -----------------------------------------------------------------------
private async void OnTogglePower(object sender, RoutedEventArgs e)
{
if (_busy)
{
return;
}
if (!_vm.IsRunning && !EnsureCredentials())
{
return;
}
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. The view model owns the whole thing.
_busy = true;
_vm.IsBusy = true;
try
{
CommandResult result = _vm.IsRunning
? await _supervisor.StopAsync().ConfigureAwait(true)
: await _supervisor.StartAsync().ConfigureAwait(true);
if (!result.Ok)
{
MessageBox.Show(this, result.Message, "Encelado",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
finally
{
_busy = false;
_vm.IsBusy = false;
Refresh();
}
}
/// <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;
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()
{
EtoroLoginWindow dialog = new(_config) { Owner = this };
bool ok = dialog.ShowDialog() == true;
RefreshSettings();
return ok;
}
// -----------------------------------------------------------------------
// IUiActions — everything the pages can ask the shell to do
// -----------------------------------------------------------------------
public async Task CloseBasketAsync(string basket)
{
if (string.IsNullOrWhiteSpace(basket))
{
return;
}
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;
}
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,
"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.Warning,
MessageBoxResult.No) != MessageBoxResult.Yes)
{
return;
}
string? backup;
try
{
backup = ConfigDefaults.Restore(App.ConfigPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
MessageBox.Show(this,
$"Non sono riuscito a riscrivere la configurazione:\n\n{ex.Message}",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
Log.Info(backup is null
? "configurazione ripristinata ai valori predefiniti"
: $"configurazione ripristinata; la precedente è in {backup}");
MessageBox.Show(this,
"Configurazione ripristinata.\n\n" +
(backup is null ? string.Empty : $"La precedente è stata salvata in:\n{backup}\n\n") +
"Riavvia l'applicazione perché i nuovi valori vengano caricati.",
"Ripristino completato", MessageBoxButton.OK, MessageBoxImage.Information);
}
public void OpenConfigFile() => OpenInShell(App.ConfigPath);
public void 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();
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 OpenLogFile()
{
string? path = Log.FilePath ?? _config.Logging.ResolvePath(_config.Logging.File);
if (path is null || !File.Exists(path))
{
MessageBox.Show(this,
"Il file di log non esiste ancora.\n\nViene creato alla prima riga scritta su disco.",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
OpenInShell(path);
}
public void ChangeLogDirectory()
{
Microsoft.Win32.OpenFolderDialog dialog = new()
{
Title = "Dove salvare i log di Encelado",
InitialDirectory = SafeInitialDirectory(),
Multiselect = false,
};
if (dialog.ShowDialog(this) != true)
{
return;
}
string chosen = dialog.FolderName;
// Refuse before writing rather than after: a directory we cannot write to would
// leave the bot logging nowhere, and the logger fails quietly by design.
if (!IsWritable(chosen, out string problem))
{
MessageBox.Show(this,
$"Non posso scrivere in questa cartella:\n\n{chosen}\n\n{problem}",
"Cartella non utilizzabile", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
try
{
ConfigWriter.SetLogDirectory(App.ConfigPath, chosen);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or InvalidOperationException or FileNotFoundException)
{
MessageBox.Show(this,
$"Non sono riuscito a salvare la configurazione:\n\n{ex.Message}",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
_config.Logging.Directory = chosen;
Log.Info($"cartella dei log impostata su {chosen} — attiva al prossimo avvio");
RefreshSettings();
MessageBox.Show(this,
$"I log verranno salvati in:\n\n{chosen}\n\n" +
"I file attualmente aperti restano dove sono fino al prossimo avvio dell'applicazione.",
"Impostazione salvata", MessageBoxButton.OK, MessageBoxImage.Information);
}
private string SafeInitialDirectory()
{
try
{
string current = _config.Logging.ResolveDirectory();
return Directory.Exists(current) ? current : AppContext.BaseDirectory;
}
catch (ArgumentException)
{
return AppContext.BaseDirectory;
}
}
private static bool IsWritable(string directory, out string problem)
{
problem = string.Empty;
try
{
Directory.CreateDirectory(directory);
string probe = Path.Combine(directory, $".encelado-{Guid.NewGuid():N}");
File.WriteAllText(probe, string.Empty);
File.Delete(probe);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException)
{
problem = ex.Message;
return false;
}
}
private static void OpenInShell(string path)
{
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
Log.Warn($"impossibile aprire {path}: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// Settings
// -----------------------------------------------------------------------
private void RefreshSettings()
{
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 — Correlation Baskets su eToro (CFD forex).\n" +
$"Versione {Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}\n" +
$"Configurazione: {App.ConfigPath}\n" +
$"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);
}
// -----------------------------------------------------------------------
// Shutdown
// -----------------------------------------------------------------------
/// <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.
/// 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)
{
if (!_closed)
{
e.Cancel = true;
}
return;
}
if (_vm.IsRunning &&
MessageBox.Show(
this,
"Il bot è in esecuzione. Chiudere l'applicazione lo ferma.\n\n" +
(_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,
MessageBoxResult.No) != MessageBoxResult.Yes)
{
e.Cancel = true;
return;
}
e.Cancel = true;
_closing = true;
_timer.Stop();
_supervisor.EventLogged -= _vm.Log.Enqueue;
try
{
await _supervisor.DisposeAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error("errore durante lo spegnimento", ex);
}
// 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);
}
private void ChiudiDavvero()
{
if (_closed)
{
return;
}
_closed = true;
Close();
}
}
+108
View File
@@ -0,0 +1,108 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace Encelado.Bot.Ui;
/// <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(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)
{
SolidColorBrush brush = new(c);
brush.Freeze();
return brush;
}
}
/// <summary>Green above zero, red below, grey at zero.</summary>
public sealed class PnlBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
double v = ToDouble(value);
return Math.Abs(v) < 1e-9 || !double.IsFinite(v) ? Palette.Dim : v > 0 ? Palette.Up : Palette.Down;
}
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
throw new NotSupportedException();
internal static double ToDouble(object? value) => value switch
{
double d => d,
float f => f,
decimal m => (double)m,
int i => i,
long l => l,
_ => 0,
};
}
public sealed class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
bool flag = value is true;
if (parameter is string s && s.Equals("invert", StringComparison.OrdinalIgnoreCase))
{
flag = !flag;
}
return flag ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
throw new NotSupportedException();
}
public sealed class InverseBoolConverter : IValueConverter
{
public object Convert(object? value, Type t, object? p, CultureInfo c) => value is not true;
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) => value is not true;
}
/// <summary>Colours a log line by severity.</summary>
public sealed class LevelBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
(value as string)?.ToLowerInvariant() switch
{
"error" => Palette.Down,
"warn" => Palette.Warn,
"info" => Palette.Dim,
_ => Palette.Faint,
};
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
throw new NotSupportedException();
}
/// <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) =>
value is DateTime utc ? UiClock.Smart(utc) : "—";
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
throw new NotSupportedException();
}
/// <summary>Booleans as words, because "True" in an Italian UI reads as a bug.</summary>
public sealed class YesNoConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is true ? "sì" : "no";
public object ConvertBack(object? value, Type t, object? p, CultureInfo c) =>
throw new NotSupportedException();
}
@@ -0,0 +1,51 @@
<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="Chiavi eToro"
Width="600" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
Background="{StaticResource Bg}"
UseLayoutRounding="True">
<Border Padding="24">
<StackPanel>
<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="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="CHIAVE DELL'APPLICAZIONE (x-api-key)" Style="{StaticResource Label}" Margin="0,18,0,6"/>
<PasswordBox x:Name="ApiKeyBox"/>
<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 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">
<TextBlock x:Name="StatusText" TextWrapping="Wrap" FontSize="12.5"/>
</Border>
<ProgressBar x:Name="Busy" IsIndeterminate="True" Margin="0,14,0,0" Visibility="Collapsed"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
<Button x:Name="CancelButton" Content="Annulla" Click="OnCancel" Width="104"/>
<Button x:Name="OkButton" Content="Verifica e salva" Click="OnConfirm"
Style="{StaticResource Primary}" Width="156" Margin="10,0,0,0" IsDefault="True"/>
</StackPanel>
</StackPanel>
</Border>
</Window>
@@ -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();
}
@@ -0,0 +1,199 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Data;
using Encelado.Bot.Engine;
namespace Encelado.Bot.Ui;
/// <summary>
/// Backs the log page: a bounded, filterable, colour-coded view of everything the bot
/// has logged since it started.
/// <para>
/// Lines arrive on whatever thread logged them and are parked in a lock-free queue;
/// the UI drains that queue once a second on the same tick that refreshes the rest of
/// the window. Dispatching each line individually would put a dispatcher hop on the
/// logging path, which at <c>trace</c> verbosity means thousands per second.
/// </para>
/// </summary>
public sealed class LogViewModel : INotifyPropertyChanged
{
private readonly ConcurrentQueue<EventRow> _pending = new();
private readonly int _capacity;
private string _levelFilter = "tutti";
private string _search = string.Empty;
private bool _autoScroll = true;
private bool _paused;
private long _dropped;
public LogViewModel(int capacity)
{
_capacity = Math.Max(100, capacity);
View = (CollectionView)CollectionViewSource.GetDefaultView(Lines);
View.Filter = Passes;
}
/// <summary>Every buffered line, oldest first. Bound through <see cref="View"/>.</summary>
public ObservableCollection<EventRow> Lines { get; } = [];
public CollectionView View { get; }
public static IReadOnlyList<string> LevelFilters { get; } =
["tutti", "debug", "info", "warn", "error"];
/// <summary>Minimum severity to show. "tutti" shows everything including trace.</summary>
public string LevelFilter
{
get => _levelFilter;
set
{
if (Set(ref _levelFilter, value))
{
View.Refresh();
}
}
}
/// <summary>Free-text filter on the message body.</summary>
public string Search
{
get => _search;
set
{
if (Set(ref _search, value))
{
View.Refresh();
}
}
}
public bool AutoScroll
{
get => _autoScroll;
set => Set(ref _autoScroll, value);
}
/// <summary>
/// Stops draining while the operator is reading. Incoming lines still queue up, so
/// nothing is lost — they appear when the pause ends.
/// </summary>
public bool Paused
{
get => _paused;
set => Set(ref _paused, value);
}
public string Status => _dropped > 0
? $"{Lines.Count:N0} righe in memoria (limite {_capacity:N0}) — {_dropped:N0} più vecchie scartate, il file su disco è completo"
: $"{Lines.Count:N0} righe in memoria (limite {_capacity:N0})";
/// <summary>Called from the logging thread. Must stay cheap and allocation light.</summary>
public void Enqueue(EventRow row) => _pending.Enqueue(row);
/// <summary>Drains pending lines into the bound collection. UI thread only.</summary>
public void Flush()
{
if (Paused || _pending.IsEmpty)
{
return;
}
bool changed = false;
while (_pending.TryDequeue(out EventRow? row))
{
Lines.Add(row);
changed = true;
}
if (Lines.Count > _capacity)
{
// Trimmed in one batch rather than one line at a time: every RemoveAt(0)
// shifts the whole backing array, so dropping 10% once beats dropping one
// element on each of the next few hundred lines.
int excess = Lines.Count - _capacity + (_capacity / 10);
for (int i = 0; i < excess && Lines.Count > 0; i++)
{
Lines.RemoveAt(0);
}
_dropped += excess;
}
if (changed)
{
Raise(nameof(Status));
}
}
public void Clear()
{
while (_pending.TryDequeue(out _))
{
// Drop anything already queued too, otherwise it reappears a second later
// and "clear" looks broken.
}
Lines.Clear();
_dropped = 0;
Raise(nameof(Status));
}
private bool Passes(object item)
{
if (item is not EventRow row)
{
return false;
}
if (!Rank(row.Level, out int rank) || rank < MinimumRank)
{
return false;
}
return _search.Length == 0 ||
row.Message.Contains(_search, StringComparison.OrdinalIgnoreCase);
}
private int MinimumRank => _levelFilter switch
{
"debug" => 1,
"info" => 2,
"warn" => 3,
"error" => 4,
_ => 0,
};
private static bool Rank(string level, out int rank)
{
rank = level switch
{
"trace" => 0,
"debug" => 1,
"info" => 2,
"warn" => 3,
"error" => 4,
_ => 2,
};
return true;
}
public event PropertyChangedEventHandler? PropertyChanged;
private bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
Raise(name);
return true;
}
private void Raise(string? name) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
@@ -0,0 +1,378 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using Encelado.Bot.Engine;
namespace Encelado.Bot.Ui;
/// <summary>
/// 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 = "DEMO";
private string _modeKind = "demo";
private string _executionMode = string.Empty;
private string _stateText = "fermo";
private string _stateKind = "stopped";
private bool _isRunning;
private bool _isBusy;
private string _powerText = "AVVIA";
private string _banner = string.Empty;
private bool _hasBanner;
private bool _bannerIsWarning;
private double _equity;
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 _counters = string.Empty;
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<BasketRow> Baskets { get; } = [];
public ObservableCollection<QuoteRow> Quotes { get; } = [];
public ObservableCollection<SentimentRow> Sentiment { get; } = [];
public ObservableCollection<CalendarRow> NextEvents { get; } = [];
public IReadOnlyList<string> Presets { get; } = ["CONSERVATIVE", "MODERATE", "AGGRESSIVE"];
/// <summary>How many activity lines the dashboard keeps.</summary>
public int StatusLines { get; init; } = 200;
public required LogViewModel Log { get; init; }
public string Mode { get => _mode; private set => Set(ref _mode, value); }
/// <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); }
public bool IsRunning { get => _isRunning; private set => Set(ref _isRunning, value); }
/// <summary>
/// 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");
public bool IsBusy
{
get => _isBusy;
set
{
if (_isBusy == value)
{
return;
}
_isBusy = value;
Raise(nameof(IsBusy));
Raise(nameof(CanToggle));
}
}
public string PowerText { get => _powerText; private set => Set(ref _powerText, value); }
public string Banner { get => _banner; private set => Set(ref _banner, value); }
public bool HasBanner { get => _hasBanner; private set => Set(ref _hasBanner, value); }
public bool BannerIsWarning { get => _bannerIsWarning; private set => Set(ref _bannerIsWarning, value); }
public double Equity { get => _equity; private set => Set(ref _equity, value); }
public double Balance { get => _balance; private set => Set(ref _balance, value); }
public double AvailableBalance { get => _availableBalance; private set => Set(ref _availableBalance, value); }
public double PeakEquity { get => _peakEquity; private set => Set(ref _peakEquity, value); }
public double DrawdownPct { get => _drawdownPct; private set => Set(ref _drawdownPct, value); }
public double EquityStopPct { get => _equityStopPct; private set => Set(ref _equityStopPct, 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 TodayPnl { get => _todayPnl; private set => Set(ref _todayPnl, value); }
public double TodayPnlPct { get => _todayPnlPct; private set => Set(ref _todayPnlPct, value); }
public double OpenPnl { get => _openPnl; private set => Set(ref _openPnl, value); }
public double OpenPnlPct { get => _openPnlPct; private set => Set(ref _openPnlPct, value); }
public int OpenBaskets { get => _openBaskets; private set => Set(ref _openBaskets, value); }
public int MaxBaskets { get => _maxBaskets; private set => Set(ref _maxBaskets, value); }
public string BasketsDisplay => _maxBaskets > 0 ? $"{_openBaskets} / {_maxBaskets}" : _openBaskets.ToString(CultureInfo.CurrentCulture);
public bool EquityStopped { get => _equityStopped; private set => Set(ref _equityStopped, value); }
public bool KillSwitched { get => _killSwitched; private set => Set(ref _killSwitched, value); }
/// <summary>The active preset label. Set by the engine; the page changes it through a command, never directly.</summary>
public string Preset { get => _preset; private set => Set(ref _preset, value); }
public string StrategyVersion { get => _strategyVersion; private set => Set(ref _strategyVersion, value); }
public string ApiState { get => _apiState; private set => Set(ref _apiState, value); }
public string ApiLatency { get => _apiLatency; private set => Set(ref _apiLatency, value); }
/// <summary>Wall clock in the window's time zone, refreshed by the window's timer.</summary>
public string Clock { get => _clock; set => Set(ref _clock, value); }
public string ClockZone { get => _clockZone; set => Set(ref _clockZone, value); }
public double ClockSkew { get => _clockSkew; private set => Set(ref _clockSkew, value); }
public string Uptime { get => _uptime; private set => Set(ref _uptime, value); }
public string Counters { get => _counters; private set => Set(ref _counters, 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.EnvironmentKind;
ExecutionMode = s.ExecutionMode;
IsRunning = s.State is BotState.Running or BotState.Starting;
PowerText = IsRunning ? "FERMA" : "AVVIA";
// CanToggle derives from StateKind, so it has to be raised after it changes.
StateKind = s.State.ToString().ToLowerInvariant();
Raise(nameof(CanToggle));
StateText = s.State switch
{
BotState.Running => "in esecuzione",
BotState.Starting => "avvio…",
BotState.Stopping => "arresto…",
BotState.Faulted => "errore",
_ => "fermo",
};
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)
{
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;
}
if (s.Halted)
{
Banner = $"OPERATIVITÀ SOSPESA — {s.HaltReason}";
HasBanner = true;
BannerIsWarning = false;
return;
}
if (!string.IsNullOrEmpty(s.Error))
{
Banner = s.Error;
HasBanner = true;
BannerIsWarning = false;
return;
}
if (IsRunning && s.EntriesBlockedReason is { Length: > 0 } blocked)
{
Banner = $"Nuove entrate bloccate: {blocked}. Le uscite restano attive.";
HasBanner = true;
BannerIsWarning = true;
return;
}
if (IsRunning && s.ApiState is "caduta" or "disconnesso")
{
Banner = "Collegamento a eToro caduto: il motore prova a riconnettersi da solo.";
HasBanner = true;
BannerIsWarning = true;
return;
}
if (s.EnvironmentKind == "live" && IsRunning)
{
Banner = "Conto REALE: gli ordini impegnano denaro vero.";
HasBanner = true;
BannerIsWarning = true;
return;
}
HasBanner = false;
}
/// <summary>
/// Replaces the contents only where they actually differ. Clearing and refilling
/// 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)
{
for (int i = 0; i < source.Count; i++)
{
if (i < target.Count)
{
if (!sameKey(target[i], source[i]) || !Equals(target[i], source[i]))
{
target[i] = source[i];
}
}
else
{
target.Add(source[i]);
}
}
while (target.Count > source.Count)
{
target.RemoveAt(target.Count - 1);
}
}
/// <summary>The feed only ever grows at the tail, so append the new lines.</summary>
private void SyncEvents(IReadOnlyList<EventRow> source)
{
if (source.Count == Events.Count)
{
return;
}
if (source.Count < Events.Count)
{
Events.Clear();
}
for (int i = Events.Count; i < source.Count; i++)
{
Events.Add(source[i]);
}
while (Events.Count > StatusLines)
{
Events.RemoveAt(0);
}
}
private static string FormatUptime(TimeSpan t) =>
t.TotalHours >= 1 ? $"{(int)t.TotalHours}h {t.Minutes}m"
: t.TotalMinutes >= 1 ? $"{t.Minutes}m {t.Seconds}s"
: $"{t.Seconds}s";
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return;
}
field = value;
Raise(name);
}
private void Raise(string? name) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
@@ -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;
}
}
}
@@ -0,0 +1,111 @@
<UserControl x:Class="Encelado.Bot.Ui.Pages.LogPage"
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="Log" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Hint}"
ToolTip="Tutto quello che il bot ha registrato da quando è partito, colorato per livello. Il buffer in memoria è limitato per non crescere senza fine; il file su disco è completo e si apre da qui. Pausa sospende l'aggiornamento senza perdere righe: ricompaiono alla ripresa."/>
</StackPanel>
<!-- ==================== toolbar ==================== -->
<Border DockPanel.Dock="Top" Style="{StaticResource Card}" Margin="0,0,0,10" Padding="14,11">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" 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"
Margin="0,0,8,0"/>
<TextBox Width="230" Padding="8,5"
Text="{Binding Log.Search, UpdateSourceTrigger=PropertyChanged, Delay=250}"/>
</StackPanel>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<CheckBox Content="Segui" IsChecked="{Binding Log.AutoScroll}" VerticalAlignment="Center"
ToolTip="Resta agganciato all'ultima riga"/>
<CheckBox Content="Pausa" IsChecked="{Binding Log.Paused}" VerticalAlignment="Center"
Margin="14,0,0,0"
ToolTip="Sospende l'aggiornamento. Le righe continuano ad accumularsi e compaiono alla ripresa."/>
<Button Content="Svuota" Click="OnClear" Margin="14,0,0,0" Padding="11,5" FontSize="11.5"/>
<Button Content="Apri il file" Click="OnOpenFile" Margin="8,0,0,0" Padding="11,5" FontSize="11.5"/>
<Button Content="Cartella" Click="OnOpenFolder" Margin="8,0,0,0" Padding="11,5" FontSize="11.5"/>
</StackPanel>
</Grid>
</Border>
<TextBlock DockPanel.Dock="Bottom" Style="{StaticResource Sub}" Margin="2,8,0,0"
Text="{Binding Log.Status}"/>
<!-- ==================== the lines ==================== -->
<Border Style="{StaticResource Card}" Padding="0,10,0,10">
<ListBox x:Name="Lines" ItemsSource="{Binding Log.View}"
Background="Transparent" BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
SelectionMode="Extended">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="b" Background="Transparent" Padding="14,1">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="b" Property="Background" Value="#10FFFFFF"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="b" Property="Background" Value="#205B8CFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="66"/>
<ColumnDefinition Width="52"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Time}" Foreground="{StaticResource Faint}"
FontFamily="{StaticResource Mono}" FontSize="11"/>
<TextBlock Grid.Column="1" Text="{Binding Level}"
FontFamily="{StaticResource Mono}" FontSize="10" FontWeight="SemiBold"
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
<TextBlock Grid.Column="2" Text="{Binding Message}" TextWrapping="Wrap"
FontFamily="{StaticResource Mono}" FontSize="11.5"
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</DockPanel>
</UserControl>
@@ -0,0 +1,62 @@
using System.Collections.Specialized;
using System.Windows;
using System.Windows.Controls;
namespace Encelado.Bot.Ui.Pages;
/// <summary>
/// The full in-memory log. The status page shows the tail; this shows everything the
/// buffer holds, filterable and searchable.
/// </summary>
public partial class LogPage : UserControl
{
private LogViewModel? _log;
public LogPage()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
public IUiActions? Actions { get; set; }
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (_log is not null)
{
((INotifyCollectionChanged)_log.Lines).CollectionChanged -= OnLinesChanged;
}
_log = (DataContext as MainViewModel)?.Log;
if (_log is not null)
{
((INotifyCollectionChanged)_log.Lines).CollectionChanged += OnLinesChanged;
}
}
/// <summary>
/// Follows the tail when asked to. Scrolling the newest item into view rather than
/// scrolling to the end keeps it correct under virtualization, where the extent is
/// an estimate until the containers are realised.
/// </summary>
private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (_log is not { AutoScroll: true } || e.Action != NotifyCollectionChangedAction.Add)
{
return;
}
int count = Lines.Items.Count;
if (count > 0)
{
Lines.ScrollIntoView(Lines.Items[count - 1]);
}
}
private void OnClear(object sender, RoutedEventArgs e) => _log?.Clear();
private void OnOpenFile(object sender, RoutedEventArgs e) => Actions?.OpenLogFile();
private void OnOpenFolder(object sender, RoutedEventArgs e) => Actions?.OpenLogFolder();
}
@@ -0,0 +1,209 @@
<UserControl x:Class="Encelado.Bot.Ui.Pages.SettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources>
<!--
Una riga del form. I campi non modificabili restano campi: stessa etichetta, stessa
cornice, stessa posizione. Cambia solo che non accettano il fuoco e lo dicono.
Nasconderli in un paragrafo è ciò che faceva sembrare questa pagina un documento.
-->
<DataTemplate x:Key="FieldTemplate">
<Grid Margin="0,0,0,11">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="230"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="260"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Label}" VerticalAlignment="Center"
Foreground="{StaticResource Dim}" FontSize="12.5" TextWrapping="Wrap"
Margin="0,0,10,0" ToolTip="{Binding FullTooltip}"/>
<TextBlock Grid.Column="1" Style="{StaticResource Hint}" Margin="0,0,10,0"
ToolTip="{Binding FullTooltip}"/>
<Grid Grid.Column="2">
<!--
Due controlli nella stessa cella, uno solo visibile. I campi con un insieme
di valori ammessi si scelgono da un elenco e non si scrivono: da lì non può
uscire un nome inventato, e quando un aggiornamento toglie una voce l'elenco
smette semplicemente di proporla.
-->
<TextBox Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="{Binding IsReadOnly}"
ToolTip="{Binding FullTooltip}"
Visibility="{Binding IsFreeText, Converter={StaticResource BoolVis}}">
<TextBox.Style>
<Style TargetType="TextBox" BasedOn="{StaticResource {x:Type TextBox}}">
<Style.Triggers>
<!-- Read-only: dimmed and not focusable, but still a field. -->
<DataTrigger Binding="{Binding IsReadOnly}" Value="True">
<Setter Property="Background" Value="{StaticResource Bg}"/>
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Cursor" Value="Arrow"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasError}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsDirty}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<ComboBox ItemsSource="{Binding Options}"
SelectedItem="{Binding Value, Mode=TwoWay}"
IsEnabled="{Binding IsEditable}"
ToolTip="{Binding FullTooltip}"
Visibility="{Binding IsList, Converter={StaticResource BoolVis}}">
<ComboBox.Style>
<Style TargetType="ComboBox" BasedOn="{StaticResource {x:Type ComboBox}}">
<Style.Triggers>
<DataTrigger Binding="{Binding HasError}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsDirty}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ComboBox.Style>
</ComboBox>
<!-- Lucchetto sui campi fissati dalla strategia. -->
<TextBlock Text="&#xE72E;" FontFamily="Segoe MDL2 Assets" FontSize="11"
HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
Foreground="{StaticResource Faint}" IsHitTestVisible="False"
Visibility="{Binding IsReadOnly, Converter={StaticResource BoolVis}}"/>
</Grid>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center"
Margin="10,0,0,0">
<TextBlock Text="{Binding Suffix}" Foreground="{StaticResource Faint}" FontSize="11.5"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Error}" Foreground="{StaticResource Down}" FontSize="11.5"
VerticalAlignment="Center" Margin="10,0,0,0"
Visibility="{Binding HasError, Converter={StaticResource BoolVis}}"/>
</StackPanel>
</Grid>
</DataTemplate>
</UserControl.Resources>
<DockPanel>
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
<TextBlock Text="Impostazioni" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Hint}"
ToolTip="Ogni campo ha una spiegazione: passa il puntatore sopra l'etichetta o sul pallino. Le chiavi si applicano subito; tutto il resto ha effetto al prossimo avvio. I parametri della strategia si modificano in strategy.json."/>
</StackPanel>
<!-- ==================== barra di salvataggio ==================== -->
<Border DockPanel.Dock="Bottom" Style="{StaticResource Card}" Padding="14,11" Margin="0,10,0,0">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="SaveStatus" Style="{StaticResource Sub}" Margin="0"
VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Annulla modifiche" Click="OnRevert" Margin="0,0,10,0"/>
<Button x:Name="SaveButton" Content="Salva" Click="OnSave" Style="{StaticResource Primary}"
MinWidth="120"/>
</StackPanel>
</Grid>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
<StackPanel MaxWidth="1000" HorizontalAlignment="Left">
<!-- ==================== credenziali ==================== -->
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="Chiavi eToro" Style="{StaticResource Head}"/>
<TextBlock x:Name="CredStatus" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
<TextBlock x:Name="CredPath" Style="{StaticResource Sub}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
<Button Content="Inserisci / sostituisci le chiavi" Click="OnLogin" Style="{StaticResource Primary}"/>
<Button Content="Rimuovi le chiavi salvate" Click="OnLogout" Margin="10,0,0,0" Style="{StaticResource Danger}"/>
</StackPanel>
</StackPanel>
</Border>
<!-- ==================== cartella dei log ==================== -->
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="Cartella dei log" Style="{StaticResource Head}" Margin="0"/>
<TextBlock x:Name="LogHint" Style="{StaticResource Hint}" FontSize="12"/>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="LogPathBox" Grid.Column="0" IsReadOnly="True"/>
<Button Grid.Column="1" Content="Cambia…" Click="OnChangeLogDirectory" Margin="8,0,0,0"/>
<Button Grid.Column="2" Content="Apri" Click="OnOpenLogFolder" Margin="8,0,0,0"/>
</Grid>
</StackPanel>
</Border>
<!-- ==================== i gruppi di campi ==================== -->
<ItemsControl x:Name="Groups" Margin="0,10,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="{Binding Title}" Style="{StaticResource Head}" Margin="0,0,0,4"/>
<TextBlock Text="{Binding Description}" Style="{StaticResource Sub}"
TextWrapping="Wrap" Margin="0,0,0,14"/>
<ItemsControl ItemsSource="{Binding Fields}"
ItemTemplate="{StaticResource FieldTemplate}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="File" Style="{StaticResource Head}" Margin="0"/>
<TextBlock Style="{StaticResource Hint}" FontSize="12"
ToolTip="Il salvataggio riscrive solo i valori cambiati e conserva tutto il resto del file, commenti compresi. Per modifiche che questa pagina non copre, apri il file a mano."/>
</StackPanel>
<TextBlock x:Name="ConfigSummary" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
<Button Content="Apri il file di configurazione" Click="OnOpenConfig"/>
<Button Content="Apri strategy.json" Click="OnOpenStrategy" Margin="10,0,0,0"
ToolTip="I parametri della strategia dei basket: preset, soglie, rischio, calendario, sizing. Ogni chiave è documentata nel file."/>
<Button Content="Apri la cartella dei dati" Click="OnOpenData" Margin="10,0,0,0"
ToolTip="Ledger (data/ledger), barre di mercato, calendario, notizie e modelli."/>
<Button Content="Ripristina i valori predefiniti" Click="OnRestoreDefaults"
Style="{StaticResource Danger}" Margin="10,0,0,0"/>
</StackPanel>
<TextBlock Style="{StaticResource Sub}" Margin="0,10,0,0" TextWrapping="Wrap"
Text="Il ripristino riscrive l&apos;intero file con la configurazione di fabbrica, commenti compresi, dopo averne salvato una copia con la data accanto all&apos;originale. Le chiavi eToro e strategy.json non vengono toccati."/>
</StackPanel>
</Border>
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="Informazioni" Style="{StaticResource Head}"/>
<TextBlock x:Name="AboutText" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -0,0 +1,224 @@
using System.ComponentModel;
using System.Text.Json.Nodes;
using System.Windows;
using System.Windows.Controls;
using Encelado.Bot.Configuration;
namespace Encelado.Bot.Ui.Pages;
/// <summary>
/// The configuration, as a form. Every value the bot runs on is a field here — including
/// the ones the strategy fixes, which are shown read-only rather than hidden in prose.
/// </summary>
public partial class SettingsPage : UserControl
{
private IReadOnlyList<SettingGroup> _groups = [];
private BotConfig? _config;
public SettingsPage() => InitializeComponent();
public IUiActions? Actions { get; set; }
/// <summary>Rebuilds the form from the configuration on disk.</summary>
public void Refresh(BotConfig config, string credentialStatus, string credentialPath, string about)
{
ArgumentNullException.ThrowIfNull(config);
_config = config;
CredStatus.Text = credentialStatus;
CredPath.Text = credentialPath;
AboutText.Text = about;
LogPathBox.Text = config.Logging.ResolveDirectory();
LogHint.ToolTip = DescribeLogFiles(config.Logging);
ConfigSummary.Text = $"File: {App.ConfigPath}";
foreach (SettingGroup group in _groups)
{
foreach (SettingField field in group.Fields)
{
field.PropertyChanged -= OnFieldChanged;
}
}
_groups = SettingsCatalogue.Build(config);
foreach (SettingGroup group in _groups)
{
foreach (SettingField field in group.Fields)
{
field.PropertyChanged += OnFieldChanged;
}
}
Groups.ItemsSource = _groups;
UpdateSaveState();
}
private void OnFieldChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(SettingField.Value) or nameof(SettingField.Error))
{
UpdateSaveState();
}
}
private IEnumerable<SettingField> AllFields =>
_groups.SelectMany(static g => g.Fields);
private void UpdateSaveState()
{
int dirty = AllFields.Count(static f => f.IsDirty);
int broken = AllFields.Count(static f => f.HasError);
SaveButton.IsEnabled = dirty > 0 && broken == 0;
SaveStatus.Text = broken > 0
? $"{broken} campo/i da correggere"
: dirty == 0
? "Nessuna modifica da salvare"
: $"{dirty} modifica/e non salvate — hanno effetto al prossimo avvio";
}
private void OnRevert(object sender, RoutedEventArgs e)
{
foreach (SettingField field in AllFields)
{
field.Revert();
}
UpdateSaveState();
}
private void OnSave(object sender, RoutedEventArgs e)
{
if (_config is null)
{
return;
}
List<SettingField> changed = [.. AllFields.Where(static f => f.IsDirty)];
if (changed.Count == 0)
{
return;
}
Dictionary<string, JsonNode?> changes = [];
try
{
foreach (SettingField field in changed)
{
changes[field.Path] = field.ToJson();
}
}
catch (Exception ex) when (ex is FormatException or OverflowException or ArgumentException)
{
Warn($"Un valore non è interpretabile:\n\n{ex.Message}");
return;
}
// Validated as a whole before anything is written. Individual fields can each be
// reasonable while the combination is not — a stake above the position cap, for
// instance — and finding that out at the next start, from a file the operator
// already closed, is the worst moment to find it out.
if (!Validates(changes, out string problem))
{
Warn($"La combinazione di valori non è valida:\n\n{problem}\n\nNulla è stato salvato.");
return;
}
try
{
ConfigWriter.Apply(App.ConfigPath, changes);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or InvalidOperationException or FileNotFoundException
or ArgumentException)
{
Warn($"Non sono riuscito a salvare:\n\n{ex.Message}");
return;
}
foreach (SettingField field in changed)
{
field.Load(field.Value);
}
UpdateSaveState();
MessageBox.Show(
Window.GetWindow(this),
$"{changed.Count} valore/i salvati in:\n{App.ConfigPath}\n\n" +
"Le modifiche hanno effetto al prossimo avvio dell'applicazione.",
"Impostazioni salvate", MessageBoxButton.OK, MessageBoxImage.Information);
}
/// <summary>
/// Applies the pending changes to a throwaway copy of the configuration and runs the
/// real validators over it.
/// </summary>
private bool Validates(Dictionary<string, JsonNode?> changes, out string problem)
{
problem = string.Empty;
string temporary = Path.Combine(
Path.GetTempPath(), $"encelado-check-{Guid.NewGuid():N}.json");
try
{
File.Copy(App.ConfigPath, temporary, overwrite: true);
ConfigWriter.Apply(temporary, changes);
BotConfig candidate = ConfigLoader.Load(temporary, out _);
// The whole validator, not a subset. Individual fields can each be
// reasonable while the combination is not — an automatic mode without its
// flag, a stake that no longer fits inside the exposure cap — and finding
// that out at the next start, from a file the operator has already closed,
// is the worst moment to find it out.
candidate.Validate();
return true;
}
catch (Exception ex) when (ex is InvalidOperationException or IOException
or ArgumentException or UnauthorizedAccessException)
{
problem = ex.Message;
return false;
}
finally
{
try
{
File.Delete(temporary);
}
catch (IOException)
{
// A leftover in the temp folder is not worth failing the save over.
}
}
}
private void Warn(string message) => MessageBox.Show(
Window.GetWindow(this), message, "Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
private static string DescribeLogFiles(LoggingOptions logging) =>
$"In questa cartella: {(string.IsNullOrWhiteSpace(logging.File) ? "nessun file (log su file disattivato)" : logging.File + " il log dell'applicazione, una tabella con ; e intestazione")}. " +
"Il ledger delle decisioni e dei basket sta in data/ledger e non si sposta.";
private void OnRestoreDefaults(object sender, RoutedEventArgs e) => Actions?.RestoreDefaults();
private void OnLogin(object sender, RoutedEventArgs e) => Actions?.ShowLogin();
private void OnLogout(object sender, RoutedEventArgs e) => Actions?.ForgetCredentials();
private void OnOpenConfig(object sender, RoutedEventArgs e) => Actions?.OpenConfigFile();
private void OnOpenStrategy(object sender, RoutedEventArgs e) => Actions?.OpenStrategyFile();
private void OnOpenData(object sender, RoutedEventArgs e) => Actions?.OpenDataFolder();
private void OnOpenLogFolder(object sender, RoutedEventArgs e) => Actions?.OpenLogFolder();
private void OnChangeLogDirectory(object sender, RoutedEventArgs e) => Actions?.ChangeLogDirectory();
}
@@ -0,0 +1,24 @@
<Window x:Class="Encelado.Bot.Ui.PromptWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Encelado"
Width="560" SizeToContent="Height"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
Background="{StaticResource Bg}"
UseLayoutRounding="True">
<Border Padding="24">
<StackPanel>
<TextBlock x:Name="TitleText" FontSize="17" FontWeight="SemiBold"/>
<TextBlock x:Name="BodyText" Style="{StaticResource Sub}" FontSize="12.5" Margin="0,10,0,0" TextWrapping="Wrap"/>
<TextBlock x:Name="LabelText" Style="{StaticResource Label}" Margin="0,16,0,6"/>
<TextBox x:Name="InputBox" AcceptsReturn="False"/>
<TextBlock x:Name="ErrorText" Foreground="{StaticResource Down}" FontSize="12" Margin="0,8,0,0" Visibility="Collapsed"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
<Button Content="Annulla" Click="OnCancel" Width="104"/>
<Button x:Name="OkButton" Content="Conferma" Click="OnOk" Style="{StaticResource Primary}" Width="130" Margin="10,0,0,0" IsDefault="True"/>
</StackPanel>
</StackPanel>
</Border>
</Window>
@@ -0,0 +1,44 @@
using System.Windows;
namespace Encelado.Bot.Ui;
/// <summary>
/// One line of typed input with a validator: the phrase that unlocks a live or an
/// automatic mode, or the written reason that lifts an equity stop. A checkbox would
/// not do for either — the point is that the operator writes the words.
/// </summary>
public partial class PromptWindow : Window
{
private readonly Func<string, string?> _validate;
public PromptWindow(string title, string body, string label, Func<string, string?> validate, string okText = "Conferma")
{
InitializeComponent();
_validate = validate ?? (static _ => null);
TitleText.Text = title;
BodyText.Text = body;
LabelText.Text = label;
OkButton.Content = okText;
Loaded += (_, _) => InputBox.Focus();
}
public string Value => InputBox.Text.Trim();
private void OnOk(object sender, RoutedEventArgs e)
{
string? problem = _validate(Value);
if (problem is not null)
{
ErrorText.Text = problem;
ErrorText.Visibility = Visibility.Visible;
return;
}
DialogResult = true;
}
private void OnCancel(object sender, RoutedEventArgs e) => DialogResult = false;
/// <summary>The exact phrase the specification requires before anything live starts.</summary>
public const string LivePhrase = "CONFERMO LIVE";
}
@@ -0,0 +1,284 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
namespace Encelado.Bot.Ui;
/// <summary>What a field accepts, which decides how it is parsed and validated.</summary>
public enum SettingKind
{
Text = 0,
Integer,
Number,
/// <summary>Shown as a percentage, stored as a fraction. 20 on screen is 0.2 in the file.</summary>
Percent,
Boolean,
Choice,
}
/// <summary>
/// One editable line on the settings page.
/// <para>
/// Read-only fields are rendered as fields, not as prose. A value that the strategy
/// fixes is still a value the operator should be able to see, find and understand in the
/// same place as everything else — hiding it in a paragraph makes the page read like
/// documentation, and documentation is what people stop reading.
/// </para>
/// </summary>
public sealed class SettingField : INotifyPropertyChanged
{
private string _value = string.Empty;
private string? _error;
public required string Path { get; init; }
public required string Label { get; init; }
/// <summary>
/// The value as loaded from the configuration. Setting it establishes both the
/// current text and the baseline that <see cref="IsDirty"/> compares against, so a
/// freshly built field is never reported as edited.
/// </summary>
public required string Initial
{
init
{
_value = value;
Original = value;
}
}
/// <summary>The detailed explanation, including what changing it does.</summary>
public required string Tooltip { get; init; }
public SettingKind Kind { get; init; } = SettingKind.Text;
/// <summary>Fixed by the strategy. Visible and copyable, but not editable.</summary>
public bool IsReadOnly { get; init; }
/// <summary>Why it cannot be edited. Appended to the tooltip.</summary>
public string? ReadOnlyReason { get; init; }
public IReadOnlyList<string> Choices { get; init; } = [];
/// <summary>
/// I valori fra cui si può scegliere, o vuoto se il campo è a testo libero.
/// <para>
/// I booleani entrano qui da sé: "sì" e "no" scritti a mano sono due modi per
/// sbagliare, e nessuno dei due aggiunge niente rispetto a sceglierli.
/// </para>
/// </summary>
public IReadOnlyList<string> Options => Kind switch
{
SettingKind.Choice => Choices,
SettingKind.Boolean => ["sì", "no"],
_ => [],
};
/// <summary>Vero se il campo si compila da un elenco. Vedi <see cref="Options"/>.</summary>
public bool IsList => Options.Count > 0;
/// <summary>Vero se il campo si scrive. È l'opposto di <see cref="IsList"/>.</summary>
public bool IsFreeText => !IsList;
/// <summary>
/// Vero quando il valore salvato non è più fra quelli ammessi — tipicamente dopo un
/// aggiornamento che ha tolto una strategia. Il campo resta visibile con il suo
/// errore, ma l'elenco non lo ripropone: da lì si esce solo scegliendo un valore
/// che esiste.
/// </summary>
public bool IsObsolete => IsList && !IsReadOnly &&
!Options.Contains(_value, StringComparer.OrdinalIgnoreCase);
/// <summary>Unit suffix shown after the box, e.g. "%" or "secondi".</summary>
public string Suffix { get; init; } = string.Empty;
/// <summary>A text field that means "use the default" when left blank.</summary>
public bool AllowEmpty { get; init; }
public double Minimum { get; init; } = double.NegativeInfinity;
public double Maximum { get; init; } = double.PositiveInfinity;
/// <summary>The value as first loaded, so edits can be detected and reverted.</summary>
public string Original { get; private set; } = string.Empty;
public string Value
{
get => _value;
set
{
if (_value == value)
{
return;
}
_value = value;
Raise();
Raise(nameof(IsDirty));
Raise(nameof(IsObsolete));
Validate();
}
}
public string? Error
{
get => _error;
private set
{
if (_error == value)
{
return;
}
_error = value;
Raise();
Raise(nameof(HasError));
}
}
public bool HasError => _error is not null;
public bool IsDirty => !IsReadOnly && _value != Original;
public bool IsEditable => !IsReadOnly;
public string FullTooltip => ReadOnlyReason is null
? Tooltip
: $"{Tooltip}\n\nNON MODIFICABILE — {ReadOnlyReason}";
public void Load(string value)
{
_value = value;
Original = value;
Raise(nameof(Value));
Raise(nameof(IsDirty));
// Validato subito, non solo quando qualcuno lo tocca: un valore diventato non
// valido perché l'aggiornamento ha tolto una strategia deve segnalarsi da sé
// all'apertura della pagina, non restare lì con l'aria di andare bene.
Validate();
}
public void Revert() => Load(Original);
/// <summary>Checks the text in isolation. Cross-field rules are the config's own job.</summary>
public void Validate()
{
Error = null;
if (IsReadOnly)
{
return;
}
switch (Kind)
{
case SettingKind.Integer:
if (!int.TryParse(_value, NumberStyles.Integer, CultureInfo.CurrentCulture, out int i))
{
Error = "serve un numero intero";
}
else
{
Range(i);
}
break;
case SettingKind.Number:
case SettingKind.Percent:
if (!double.TryParse(_value, NumberStyles.Float, CultureInfo.CurrentCulture, out double d))
{
Error = "serve un numero";
}
else
{
Range(d);
}
break;
case SettingKind.Boolean:
if (!bool.TryParse(_value, out _) && _value is not ("sì" or "no" or "si"))
{
Error = "serve sì o no";
}
break;
case SettingKind.Choice:
if (Choices.Count > 0 && !Choices.Contains(_value, StringComparer.OrdinalIgnoreCase))
{
Error = $"'{_value}' non è più disponibile — scegli fra: {string.Join(", ", Choices)}";
}
break;
case SettingKind.Text:
default:
if (_value.Length == 0 && !AllowEmpty)
{
Error = "non può essere vuoto";
}
break;
}
void Range(double v)
{
if (v < Minimum)
{
Error = $"non può essere sotto {Minimum.ToString("0.####", CultureInfo.CurrentCulture)}";
}
else if (v > Maximum)
{
Error = $"non può essere sopra {Maximum.ToString("0.####", CultureInfo.CurrentCulture)}";
}
}
}
/// <summary>The value as it must be written to the JSON file.</summary>
public JsonNode? ToJson() => Kind switch
{
SettingKind.Integer => JsonValue.Create(int.Parse(_value, CultureInfo.CurrentCulture)),
SettingKind.Number => JsonValue.Create(double.Parse(_value, NumberStyles.Float, CultureInfo.CurrentCulture)),
// Shown as 20, stored as 0.2. Rounded because 20/100 in binary floating point is
// 0.200000000000000011, and writing that into a hand-edited file is unkind.
SettingKind.Percent => JsonValue.Create(
Math.Round(double.Parse(_value, NumberStyles.Float, CultureInfo.CurrentCulture) / 100.0, 10)),
// "no" is a valid answer and bool.Parse throws on it, so the Italian words are
// resolved first and anything unrecognised falls through to false.
SettingKind.Boolean => JsonValue.Create(
_value is "sì" or "si" || (bool.TryParse(_value, out bool flag) && flag)),
_ => JsonValue.Create(_value),
};
/// <summary>Formats a stored value for display, inverting <see cref="ToJson"/>.</summary>
public static string Format(double value, SettingKind kind) => kind switch
{
SettingKind.Percent => Math.Round(value * 100, 6).ToString("0.####", CultureInfo.CurrentCulture),
SettingKind.Integer => ((int)Math.Round(value)).ToString(CultureInfo.CurrentCulture),
_ => value.ToString("0.######", CultureInfo.CurrentCulture),
};
public event PropertyChangedEventHandler? PropertyChanged;
private void Raise([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
/// <summary>A titled block of fields on the settings page.</summary>
public sealed class SettingGroup(string title, string description)
{
public string Title { get; } = title;
public string Description { get; } = description;
public ObservableCollection<SettingField> Fields { get; } = [];
}
@@ -0,0 +1,248 @@
using System.Globalization;
using Encelado.Bot.Configuration;
namespace Encelado.Bot.Ui;
/// <summary>
/// The settings form, as data: which fields exist, where each one lives in the JSON,
/// what it means and what values it may take. The page renders this; it never knows a
/// path by name. Every tooltip says why the value is what it is, not only what it is.
/// The strategy's own numbers live in <c>strategy.json</c>, which the page opens as a
/// file: every value there is documented in place and a change is a decision the forward
/// test judges.
/// </summary>
public static class SettingsCatalogue
{
public static IReadOnlyList<SettingGroup> Build(BotConfig config)
{
ArgumentNullException.ThrowIfNull(config);
List<SettingGroup> groups =
[
Execution(config),
Etoro(config),
Window(config),
Logging(config),
];
// Validated immediately, not on the first edit: a value that became invalid
// because an update changed the rules has to announce itself when the page opens.
foreach (SettingField field in groups.SelectMany(static g => g.Fields))
{
field.Validate();
}
return groups;
}
private static SettingGroup Execution(BotConfig config)
{
SettingGroup g = new(
"Esecuzione",
"Dove vanno gli ordini. In ogni modalità il bot apre e chiude i basket da solo, senza chiedere conferma " +
"per il singolo ordine. I parametri della strategia (preset, soglie, rischio) stanno in strategy.json.");
g.Fields.Add(new SettingField
{
Path = "run.executionMode",
Label = "Modalità",
Initial = config.Run.Mode.ToString(),
Kind = SettingKind.Choice,
Choices = ["Paper", "Demo", "Live"],
Tooltip = "Paper: simulatore locale sopra le quotazioni reali, nessun ordine sul conto. Demo: conto demo di eToro, " +
"ordini veri e denaro virtuale. Live: conto reale, richiede il flag qui sotto e la frase CONFERMO LIVE a ogni avvio.",
});
g.Fields.Add(new SettingField
{
Path = "run.allowLive",
Label = "Consenti il conto reale",
Initial = config.Run.AllowLive ? "sì" : "no",
Kind = SettingKind.Boolean,
Tooltip = "Senza questo flag la modalità Live viene rifiutata all'avvio. Anche con il flag, l'avvio chiede di scrivere CONFERMO LIVE.",
});
g.Fields.Add(new SettingField
{
Path = "run.closeOnShutdown",
Label = "Chiudi i basket all'arresto",
Initial = config.Run.CloseOnShutdown ? "sì" : "no",
Kind = SettingKind.Boolean,
Tooltip = "Con 'sì' fermare il bot chiude i basket aperti a mercato. Con 'no' restano sul conto con gli stop nativi sul " +
"server, ma senza nessuno che applichi il take-profit o lo stop di basket finché il bot non riparte.",
});
g.Fields.Add(new SettingField
{
Path = "run.pollSeconds",
Label = "Lettura quotazioni ogni",
Initial = config.Run.PollSeconds.ToString(CultureInfo.CurrentCulture),
Kind = SettingKind.Integer,
Suffix = "secondi",
Minimum = 2,
Maximum = 30,
Tooltip = "Una richiesta per tutti gli strumenti. 3 s = 20 richieste al minuto su una quota condivisa di 120: " +
"resta spazio per candele e costi. TP e stop di basket vengono controllati a ogni lettura.",
});
g.Fields.Add(new SettingField
{
Path = "run.statusSeconds",
Label = "Riga di stato ogni",
Initial = config.Run.StatusSeconds.ToString(CultureInfo.CurrentCulture),
Kind = SettingKind.Integer,
Suffix = "secondi",
Minimum = 10,
Maximum = 3600,
Tooltip = "Ogni quanto il bot scrive nel log una riga con equity, drawdown e lo stato di ogni basket.",
});
g.Fields.Add(new SettingField
{
Path = "run.paperStartingBalance",
Label = "Saldo iniziale paper",
Initial = SettingField.Format(config.Run.PaperStartingBalance, SettingKind.Number),
Kind = SettingKind.Number,
Suffix = "USD",
Minimum = 1,
Tooltip = "Il conto virtuale del simulatore locale (solo in modalità Paper).",
});
g.Fields.Add(new SettingField
{
Path = "run.paperSlippagePips",
Label = "Slippage paper",
Initial = SettingField.Format(config.Run.PaperSlippagePips, SettingKind.Number),
Kind = SettingKind.Number,
Suffix = "pip per gamba",
Minimum = 0,
Maximum = 10,
Tooltip = "Quanto il simulatore peggiora ogni esecuzione oltre lo spread reale del momento.",
});
return g;
}
private static SettingGroup Etoro(BotConfig config)
{
SettingGroup g = new(
"eToro",
"Il collegamento a eToro Public API. Le chiavi non sono qui: si inseriscono dalla finestra di accesso e vivono " +
"cifrate nei dati dell'utente, una coppia per ambiente.");
g.Fields.Add(new SettingField
{
Path = "etoro.environment",
Label = "Ambiente",
Initial = config.Etoro.IsDemo ? "demo" : "real",
Kind = SettingKind.Choice,
Choices = ["demo", "real"],
Tooltip = "demo: il conto virtuale di eToro, ordini veri e soldi finti. real: il conto reale, che richiede " +
"la modalità Live, run.allowLive e la frase CONFERMO LIVE a ogni avvio. Cambiare ambiente richiede le chiavi di quell'ambiente.",
});
g.Fields.Add(new SettingField
{
Path = "etoro.requestTimeoutSeconds",
Label = "Timeout richieste",
Initial = config.Etoro.RequestTimeoutSeconds.ToString(CultureInfo.CurrentCulture),
Kind = SettingKind.Integer,
Suffix = "secondi",
Minimum = 3,
Maximum = 120,
Tooltip = "Oltre questo tempo una richiesta HTTP viene abbandonata (e, se era una lettura, ritentata).",
});
g.Fields.Add(new SettingField
{
Path = "etoro.fillTimeoutSeconds",
Label = "Attesa esito ordine",
Initial = config.Etoro.FillTimeoutSeconds.ToString(CultureInfo.CurrentCulture),
Kind = SettingKind.Integer,
Suffix = "secondi",
Minimum = 1,
Maximum = 60,
Tooltip = "eToro lavora gli ordini in modo asincrono: il bot interroga l'esito per questo tempo, poi tratta " +
"l'ordine come non confermato e riconcilia con le posizioni sul conto. È anche il timeout della seconda gamba (leg-risk).",
});
return g;
}
private static SettingGroup Window(BotConfig config)
{
SettingGroup g = new(
"Finestra",
"Come la finestra mostra le cose. Niente qui cambia quello che il bot fa.");
g.Fields.Add(new SettingField
{
Path = "ui.timeZone",
Label = "Fuso orario",
Initial = config.Ui.TimeZone,
Kind = SettingKind.Choice,
Choices = UiClock.Choices(),
Tooltip = "Il fuso con cui la finestra mostra ogni orario (orologio in alto, eventi, righe di attività). " +
"'computer' usa quello impostato in Windows. Il file di log porta l'offset e il ledger è in UTC: " +
"cambiare questo valore non tocca nessun file. Ha effetto al prossimo avvio.",
});
return g;
}
private static SettingGroup Logging(BotConfig config)
{
SettingGroup g = new(
"Registrazione",
"Il log dell'applicazione. Il ledger delle decisioni e dei basket sta in data/ledger e non si configura.");
g.Fields.Add(new SettingField
{
Path = "logging.level",
Label = "Dettaglio del log",
Initial = config.Logging.Level,
Kind = SettingKind.Choice,
Choices = ["trace", "debug", "info", "warn", "error", "none"],
Tooltip = "'info' basta: ogni rifiuto che impedisce un ordine viene scritto a questo livello o sopra, " +
"con il basket e il motivo esatto.",
});
g.Fields.Add(new SettingField
{
Path = "logging.maxFileSizeMb",
Label = "Dimensione massima del log",
Initial = config.Logging.MaxFileSizeMb.ToString(CultureInfo.CurrentCulture),
Kind = SettingKind.Integer,
Suffix = "MB (0 = nessuna rotazione)",
Minimum = 0,
Maximum = 4096,
Tooltip = "Superata questa dimensione il file viene ruotato: encelado.1.log, encelado.2.log e così via.",
});
g.Fields.Add(new SettingField
{
Path = "logging.maxFiles",
Label = "File di log conservati",
Initial = config.Logging.MaxFiles.ToString(CultureInfo.CurrentCulture),
Kind = SettingKind.Integer,
Suffix = "file ruotati",
Minimum = 1,
Maximum = 500,
Tooltip = "Quanti file ruotati tenere prima di cancellare il più vecchio.",
});
g.Fields.Add(new SettingField
{
Path = "logging.bufferedLines",
Label = "Righe tenute in memoria",
Initial = config.Logging.BufferedLines.ToString(CultureInfo.CurrentCulture),
Kind = SettingKind.Integer,
Suffix = "righe",
Minimum = 100,
Maximum = 200_000,
Tooltip = "Quante righe tiene la scheda Log. Il file su disco resta completo comunque.",
});
return g;
}
}
+563
View File
@@ -0,0 +1,563 @@
<!--
Il tema dell'applicazione: tavolozza, tipografia, superfici e i template dei
controlli che WPF disegnerebbe con la sua veste chiara.
Sta qui e non dentro App.xaml perché App.xaml dichiara x:Class: caricarlo come
dizionario costruisce l'oggetto Application, e in un AppDomain ne può esistere uno
solo. I test che istanziano le pagine per verificarne i binding hanno bisogno degli
stili senza far partire l'applicazione.
Scelte: una sola famiglia di caratteri per il testo (Segoe UI Variable, quella di
Windows 11), una monospaziata solo per i numeri; superfici con angoli morbidi e
senza ombre; contrasto minimo 4,5:1 per ogni testo (WCAG AA).
-->
<ResourceDictionary 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">
<!-- ================= palette ================= -->
<Color x:Key="BgColor">#FF0F1319</Color>
<SolidColorBrush x:Key="Bg" Color="#FF0F1319"/>
<SolidColorBrush x:Key="Panel" Color="#FF171C25"/>
<SolidColorBrush x:Key="Panel2" Color="#FF1F2531"/>
<SolidColorBrush x:Key="Line" Color="#FF2A3140"/>
<SolidColorBrush x:Key="Txt" Color="#FFECEFF6"/>
<SolidColorBrush x:Key="Dim" Color="#FFA8B3C7"/>
<SolidColorBrush x:Key="Faint" Color="#FF7B879E"/>
<SolidColorBrush x:Key="Up" Color="#FF34D399"/>
<SolidColorBrush x:Key="Down" Color="#FFF87171"/>
<SolidColorBrush x:Key="Accent" Color="#FF6C9CFF"/>
<SolidColorBrush x:Key="Warn" Color="#FFF5B74F"/>
<FontFamily x:Key="Sans">Segoe UI Variable Text, Segoe UI, Arial</FontFamily>
<FontFamily x:Key="Mono">Cascadia Mono, Consolas, Courier New</FontFamily>
<ui:PnlBrushConverter x:Key="PnlBrush"/>
<ui:BoolToVisibilityConverter x:Key="BoolVis"/>
<ui:InverseBoolConverter x:Key="NotBool"/>
<ui:LevelBrushConverter x:Key="LevelBrush"/>
<ui:LocalTimeConverter x:Key="LocalTime"/>
<ui:YesNoConverter x:Key="YesNo"/>
<!-- ================= text ================= -->
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
</Style>
<!-- Etichetta piccola in maiuscoletto sopra un numero. -->
<Style x:Key="Label" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
</Style>
<!-- Il numero grande di un indicatore. -->
<Style x:Key="Value" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="24"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="0,6,0,0"/>
</Style>
<Style x:Key="Sub" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Margin" Value="0,4,0,0"/>
</Style>
<!-- Titolo di una sezione. -->
<Style x:Key="Head" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<Style x:Key="PageTitle" TargetType="TextBlock">
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="PageHeader" TargetType="StackPanel">
<Setter Property="Orientation" Value="Horizontal"/>
<Setter Property="Margin" Value="0,0,0,14"/>
</Style>
<!-- ================= surfaces ================= -->
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Panel}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="16"/>
</Style>
<Style x:Key="Kpi" TargetType="Border" BasedOn="{StaticResource Card}">
<Setter Property="Padding" Value="16,14"/>
<Setter Property="Margin" Value="0,0,12,0"/>
</Style>
<Style x:Key="Chip" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="999"/>
<Setter Property="Padding" Value="9,3"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- Il badge dell'ambiente: PAPER azzurro, DEMO ambra, LIVE rosso. -->
<Style x:Key="ModeBadge" TargetType="Border" BasedOn="{StaticResource Chip}">
<Setter Property="Padding" Value="10,3"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ModeKind}" Value="live">
<Setter Property="Background" Value="#26F87171"/>
<Setter Property="BorderBrush" Value="#80F87171"/>
</DataTrigger>
<DataTrigger Binding="{Binding ModeKind}" Value="demo">
<Setter Property="Background" Value="#26F5B74F"/>
<Setter Property="BorderBrush" Value="#80F5B74F"/>
</DataTrigger>
<DataTrigger Binding="{Binding ModeKind}" Value="paper">
<Setter Property="Background" Value="#266C9CFF"/>
<Setter Property="BorderBrush" Value="#806C9CFF"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- Il pallino dello stato del motore. -->
<Style x:Key="Dot" TargetType="Ellipse">
<Setter Property="Width" Value="9"/>
<Setter Property="Height" Value="9"/>
<Setter Property="Fill" Value="{StaticResource Faint}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding StateKind}" Value="running">
<Setter Property="Fill" Value="{StaticResource Up}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateKind}" Value="faulted">
<Setter Property="Fill" Value="{StaticResource Down}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateKind}" Value="starting">
<Setter Property="Fill" Value="{StaticResource Warn}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateKind}" Value="stopping">
<Setter Property="Fill" Value="{StaticResource Warn}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- ================= buttons ================= -->
<Style TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="b" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="b" Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
<Setter Property="Cursor" Value="Arrow"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Primary" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{StaticResource Accent}"/>
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
<Setter Property="Foreground" Value="#FF0B1020"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="Danger" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="{StaticResource Down}"/>
<Setter Property="BorderBrush" Value="#66F87171"/>
</Style>
<!-- AVVIA verde, FERMA rossa. -->
<Style x:Key="PowerButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Padding" Value="22,8"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="#FF06170F"/>
<Setter Property="Background" Value="{StaticResource Up}"/>
<Setter Property="BorderBrush" Value="{StaticResource Up}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsRunning}" Value="True">
<Setter Property="Background" Value="{StaticResource Down}"/>
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
<Setter Property="Foreground" Value="White"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- ================= inputs ================= -->
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="CaretBrush" Value="{StaticResource Accent}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="9,7"/>
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="PasswordBox">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="CaretBrush" Value="{StaticResource Accent}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="9,7"/>
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="PasswordBox">
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12.5"/>
</Style>
<!--
Fully templated. Setting Background on the stock ComboBox does almost nothing:
its default template wraps a system-themed ToggleButton that paints its own chrome.
-->
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="b" Background="Transparent" Padding="{TemplateBinding Padding}" CornerRadius="6">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="b" Property="Background" Value="#266C9CFF"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="b" Property="Background" Value="#336C9CFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="MaxDropDownHeight" Value="360"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="Toggle" Focusable="False" ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="bg" Background="{StaticResource Panel2}"
BorderBrush="{Binding BorderBrush, RelativeSource={RelativeSource AncestorType=ComboBox}}"
BorderThickness="1" CornerRadius="8">
<Path x:Name="arrow" HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,11,0" Data="M 0 0 L 4 4 L 8 0"
Stroke="{StaticResource Dim}" StrokeThickness="1.4"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bg" Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Left" VerticalAlignment="Center"
IsHitTestVisible="False"/>
<Popup IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom"
AllowsTransparency="True" Focusable="False" PopupAnimation="Fade">
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Line}"
BorderThickness="1" CornerRadius="8" Margin="0,3,0,0" Padding="4"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<ScrollViewer>
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="Height" Value="4"/>
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<!-- ================= top navigation ================= -->
<!--
Le pagine sono schede orizzontali nella barra in alto: una ListBox, così la
selezione, le frecce e la voce selezionata arrivano gratis.
-->
<Style x:Key="TabList" TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Margin" Value="0,0,4,0"/>
<Setter Property="AutomationProperties.Name" Value="{Binding}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="b" CornerRadius="8" Padding="14,7" Background="Transparent">
<TextBlock Text="{Binding}" FontSize="13" FontWeight="SemiBold"
Foreground="{TemplateBinding Foreground}" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="b" Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="False"/>
<Condition Property="IsMouseOver" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="b" Property="Background" Value="#14FFFFFF"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
</Style>
<!-- ================= tooltip ================= -->
<Style TargetType="ToolTip">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,10"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="MaxWidth" Value="440"/>
<Setter Property="HasDropShadow" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToolTip">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter>
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="LineHeight" Value="17"/>
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Il pallino informativo accanto a un titolo. -->
<Style x:Key="Hint" TargetType="TextBlock">
<Setter Property="Text" Value="&#xE9CE;"/>
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="7,0,0,0"/>
<Setter Property="Cursor" Value="Help"/>
<Setter Property="ToolTipService.ShowDuration" Value="60000"/>
<Setter Property="ToolTipService.InitialShowDelay" Value="250"/>
<Setter Property="ToolTipService.Placement" Value="Bottom"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- ================= data grid ================= -->
<Style TargetType="DataGrid">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="RowBackground" Value="Transparent"/>
<Setter Property="AlternatingRowBackground" Value="Transparent"/>
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
<Setter Property="HorizontalGridLinesBrush" Value="{StaticResource Line}"/>
<Setter Property="HeadersVisibility" Value="Column"/>
<Setter Property="AutoGenerateColumns" Value="False"/>
<Setter Property="IsReadOnly" Value="True"/>
<Setter Property="CanUserResizeRows" Value="False"/>
<Setter Property="CanUserSortColumns" Value="False"/>
<Setter Property="CanUserReorderColumns" Value="False"/>
<Setter Property="SelectionMode" Value="Single"/>
<Setter Property="RowHeight" Value="38"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Padding" Value="10,0"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#146C9CFF"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#206C9CFF"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- ================= scrollbar ================= -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="8"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="{StaticResource Line}" CornerRadius="4" Margin="2,0"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageDownCommand" Opacity="0" Focusable="False"/>
</Track.IncreaseRepeatButton>
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageUpCommand" Opacity="0" Focusable="False"/>
</Track.DecreaseRepeatButton>
</Track>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
+72
View File
@@ -0,0 +1,72 @@
using System.Globalization;
namespace Encelado.Bot.Ui;
/// <summary>
/// The one place that turns a UTC instant into the time the operator wants to read.
/// Everything inside the engine is UTC on purpose; the screen is the only thing that
/// differs, and it differs by exactly this zone — the computer's unless the
/// configuration says otherwise.
/// </summary>
public static class UiClock
{
private static TimeZoneInfo _zone = TimeZoneInfo.Local;
/// <summary>The zone in use. Set once at startup from <c>ui.timeZone</c>.</summary>
public static TimeZoneInfo Zone
{
get => _zone;
set => _zone = value ?? TimeZoneInfo.Local;
}
/// <summary>A short label for the top bar, e.g. <c>UTC+02:00</c>.</summary>
public static string Label
{
get
{
TimeSpan offset = _zone.GetUtcOffset(DateTime.UtcNow);
return offset == TimeSpan.Zero ? "UTC" : string.Create(CultureInfo.InvariantCulture, $"UTC{(offset < TimeSpan.Zero ? "-" : "+")}{offset:hh\\:mm}");
}
}
/// <summary>The zone's own name (the Windows display name, or the id).</summary>
public static string ZoneName => _zone == TimeZoneInfo.Local ? $"{_zone.Id} (fuso del computer)" : _zone.Id;
public static DateTime ToZone(DateTime utc)
{
DateTime u = utc.Kind == DateTimeKind.Utc ? utc : DateTime.SpecifyKind(utc, DateTimeKind.Utc);
return TimeZoneInfo.ConvertTimeFromUtc(u, _zone);
}
public static string Format(DateTime utc, string format) =>
utc == default ? "—" : ToZone(utc).ToString(format, CultureInfo.CurrentCulture);
/// <summary>Time of day for today's instants, date and time otherwise.</summary>
public static string Smart(DateTime utc)
{
if (utc == default)
{
return "—";
}
DateTime local = ToZone(utc);
DateTime today = ToZone(DateTime.UtcNow).Date;
return local.Date == today ? local.ToString("HH:mm:ss", CultureInfo.CurrentCulture) : local.ToString("dd/MM HH:mm", CultureInfo.CurrentCulture);
}
/// <summary>The zones offered in the settings page: the computer's, UTC, then every zone Windows knows.</summary>
public static IReadOnlyList<string> Choices()
{
List<string> ids = ["computer", "UTC"];
try
{
ids.AddRange(TimeZoneInfo.GetSystemTimeZones().Select(static z => z.Id).Where(static id => !id.Equals("UTC", StringComparison.OrdinalIgnoreCase)));
}
catch (Exception ex) when (ex is InvalidTimeZoneException or TimeZoneNotFoundException or IOException)
{
// The list is a convenience; the two fixed entries always work.
}
return ids;
}
}
@@ -0,0 +1,247 @@
using Encelado.Core.Baskets.Data;
using Encelado.Core.Broker;
namespace Encelado.Core.Baskets.Backtest;
/// <summary>
/// The replay venue. Fills at the current quote plus a configured slippage, keeps the
/// account and the positions, charges overnight fees at 21:00 UTC. The clock is the
/// backtest's, advanced bar by bar; every call answers synchronously.
/// </summary>
public sealed class BacktestBroker : IBroker
{
private readonly Dictionary<long, Instrument> _instruments = [];
private readonly Dictionary<long, QuoteSnapshot> _quotes = [];
private readonly Dictionary<long, Position> _positions = [];
private readonly Dictionary<string, OrderOutcome> _orders = new(StringComparer.Ordinal);
private readonly List<ClosedTrade> _closed = [];
private readonly double _slippagePips;
private readonly double _overnightPipsPerDay;
private double _balance;
private long _nextId = 1;
private DateTime _now;
private DateTime _lastOvernight = DateTime.MinValue;
private sealed class Position
{
public long Id;
public Instrument Instrument = null!;
public bool IsBuy;
public double Units;
public double Open;
public DateTime OpenedUtc;
public double Fees;
public int Leverage;
}
public BacktestBroker(IEnumerable<Instrument> instruments, double startingBalance, double slippagePips, double overnightPipsPerDay)
{
ArgumentNullException.ThrowIfNull(instruments);
foreach (Instrument i in instruments)
{
_instruments[i.Id] = i;
}
_balance = startingBalance;
StartingBalance = startingBalance;
_slippagePips = slippagePips;
_overnightPipsPerDay = overnightPipsPerDay;
}
public double StartingBalance { get; }
public BrokerEnvironment Environment => BrokerEnvironment.Backtest;
public string Name => "backtest";
public bool SupportsTrading => true;
public TimeSpan ClockSkew => TimeSpan.Zero;
public DateTime Now => _now;
public IReadOnlyList<ClosedTrade> Closed => _closed;
public int OpenCount => _positions.Count;
/// <summary>Advances the clock and the quotes; charges overnight once per day at 21:00 UTC.</summary>
public void Advance(DateTime nowUtc, IReadOnlyList<QuoteSnapshot> quotes)
{
ArgumentNullException.ThrowIfNull(quotes);
_now = nowUtc;
foreach (QuoteSnapshot q in quotes)
{
if (q.IsValid)
{
_quotes[q.InstrumentId] = q;
}
}
DateTime today21 = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, 21, 0, 0, DateTimeKind.Utc);
if (nowUtc >= today21 && _lastOvernight < today21 && _positions.Count > 0)
{
int nights = nowUtc.DayOfWeek == DayOfWeek.Friday ? 3 : 1;
foreach (Position p in _positions.Values)
{
if (p.OpenedUtc < today21)
{
double pipUsd = PipMath.PipValueUsd(p.Instrument.Symbol, p.Units, MidOf);
if (!double.IsNaN(pipUsd))
{
p.Fees += _overnightPipsPerDay * nights * pipUsd;
}
}
}
_lastOvernight = today21;
}
}
public double? MidOf(string symbol)
{
foreach ((long id, Instrument i) in _instruments)
{
if (i.Symbol == symbol && _quotes.TryGetValue(id, out QuoteSnapshot q) && q.IsValid)
{
return q.Mid;
}
}
return null;
}
public Task<IReadOnlyList<Instrument>> GetInstrumentsAsync(IReadOnlyList<string> symbols, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<Instrument>>([.. symbols.Select(s => _instruments.Values.First(i => i.Symbol == s))]);
public Task<IReadOnlyList<QuoteSnapshot>> GetQuotesAsync(IReadOnlyList<long> instrumentIds, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<QuoteSnapshot>>([.. instrumentIds.Where(_quotes.ContainsKey).Select(id => _quotes[id])]);
public Task<IReadOnlyList<BidAskBar>> GetCandlesAsync(long instrumentId, TimeSpan interval, int count, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<BidAskBar>>([]);
public Task<AccountSnapshot> GetAccountAsync(CancellationToken ct)
{
double unrealized = 0, margin = 0;
foreach (Position p in _positions.Values)
{
unrealized += Unrealized(p);
margin += Margin(p);
}
double equity = _balance + unrealized;
return Task.FromResult(new AccountSnapshot(_now, "USD", _balance, equity, Math.Max(0, equity - margin), margin, unrealized));
}
public double Equity
{
get
{
double unrealized = 0;
foreach (Position p in _positions.Values)
{
unrealized += Unrealized(p);
}
return _balance + unrealized;
}
}
public double Balance => _balance;
public Task<IReadOnlyList<BrokerPosition>> GetPositionsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<BrokerPosition>>([.. _positions.Values.Select(p =>
new BrokerPosition(p.Id, p.Instrument.Id, p.IsBuy, p.Units, p.Open, p.OpenedUtc, 0, 0, p.Leverage, Margin(p), Unrealized(p), p.Fees, Exit(p)))]);
public Task<OrderOutcome> OpenAsync(OrderRequest request, CancellationToken ct)
{
if (_orders.TryGetValue(request.ClientRef, out OrderOutcome? done))
{
return Task.FromResult(done);
}
if (!_instruments.TryGetValue(request.InstrumentId, out Instrument? instrument) || !_quotes.TryGetValue(request.InstrumentId, out QuoteSnapshot q) || !q.IsValid)
{
return Task.FromResult(Reject(request, "strumento o quotazione mancante"));
}
double slip = _slippagePips * instrument.Pip;
double fill = instrument.RoundPrice(request.IsBuy ? q.Ask + slip : q.Bid - slip);
double notional = PipMath.NotionalUsd(instrument.Symbol, request.Units, fill, MidOf);
if (double.IsNaN(notional) || notional < instrument.MinExposure)
{
return Task.FromResult(Reject(request, $"esposizione {notional:F0} USD sotto il minimo {instrument.MinExposure:F0}"));
}
Position p = new()
{
Id = _nextId++,
Instrument = instrument,
IsBuy = request.IsBuy,
Units = request.Units,
Open = fill,
OpenedUtc = _now,
Leverage = Math.Max(1, request.Leverage),
};
_positions[p.Id] = p;
OrderOutcome o = new(true, false, p.Id, p.Id, fill, request.Units, _now, 0, "Filled", string.Empty);
_orders[request.ClientRef] = o;
return Task.FromResult(o);
}
private OrderOutcome Reject(OrderRequest request, string why)
{
OrderOutcome o = new(false, true, 0, 0, 0, request.Units, _now, 0, "Rejected", why);
_orders[request.ClientRef] = o;
return o;
}
public Task<OrderOutcome?> LookupOrderAsync(string clientRef, CancellationToken ct) =>
Task.FromResult(_orders.TryGetValue(clientRef, out OrderOutcome? o) ? o : null);
public Task<CloseOutcome> CloseAsync(long positionId, long instrumentId, CancellationToken ct)
{
if (!_positions.Remove(positionId, out Position? p))
{
return Task.FromResult(new CloseOutcome(false, true, 0, 0, 0, _now, 0, "posizione inesistente"));
}
double exit = Exit(p, withSlippage: true);
double pnl = PipMath.LegPnlUsd(p.Instrument.Symbol, p.IsBuy, p.Units, p.Open, exit, MidOf);
pnl = (double.IsNaN(pnl) ? 0 : pnl) - p.Fees;
_balance += pnl;
_closed.Add(new ClosedTrade(p.Id, p.Instrument.Id, p.IsBuy, p.Units, p.Open, exit, p.OpenedUtc, _now, pnl, p.Fees));
return Task.FromResult(new CloseOutcome(true, false, _nextId++, exit, p.Units, _now, pnl, string.Empty));
}
public Task<bool> UpdateStopsAsync(long positionId, double? stopLoss, double? takeProfit, CancellationToken ct) => Task.FromResult(true);
public Task<CostEstimate?> GetCostAsync(OrderRequest request, CancellationToken ct) => Task.FromResult<CostEstimate?>(null);
public Task<IReadOnlyList<ClosedTrade>> GetClosedTradesAsync(DateTime fromUtc, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<ClosedTrade>>([.. _closed.Where(c => c.ClosedUtc >= fromUtc)]);
private double Exit(Position p, bool withSlippage = false)
{
if (!_quotes.TryGetValue(p.Instrument.Id, out QuoteSnapshot q) || !q.IsValid)
{
return p.Open;
}
double slip = withSlippage ? _slippagePips * p.Instrument.Pip : 0;
return p.Instrument.RoundPrice(p.IsBuy ? q.Bid - slip : q.Ask + slip);
}
private double Unrealized(Position p)
{
double pnl = PipMath.LegPnlUsd(p.Instrument.Symbol, p.IsBuy, p.Units, p.Open, Exit(p), MidOf);
return (double.IsNaN(pnl) ? 0 : pnl) - p.Fees;
}
private double Margin(Position p)
{
double n = PipMath.NotionalUsd(p.Instrument.Symbol, p.Units, p.Open, MidOf);
return (double.IsNaN(n) ? 0 : n) / p.Leverage;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -0,0 +1,508 @@
using System.Diagnostics;
using System.Globalization;
using Encelado.Core.Baskets.Data;
using Encelado.Core.Broker;
using Encelado.Core.Statistics;
namespace Encelado.Core.Baskets.Backtest;
/// <summary>The M15 bid/ask history of every symbol, loaded once and shared by every trial.</summary>
public sealed class MarketData
{
public Dictionary<string, List<BidAskBar>> Bars { get; } = new(StringComparer.OrdinalIgnoreCase);
public DateTime From { get; private set; } = DateTime.MaxValue;
public DateTime To { get; private set; } = DateTime.MinValue;
public static MarketData Load(string directory, IEnumerable<string> symbols, DateTime? from = null, DateTime? to = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
MarketData data = new();
foreach (string symbol in symbols)
{
string path = Path.Combine(directory, BidAskBarCsv.FileName(symbol));
List<BidAskBar> bars = BidAskBarCsv.Read(path, from, to);
data.Bars[symbol.ToUpperInvariant()] = bars;
if (bars.Count > 0)
{
data.From = bars[0].TimeUtc < data.From ? bars[0].TimeUtc : data.From;
data.To = bars[^1].TimeUtc > data.To ? bars[^1].TimeUtc : data.To;
}
}
return data;
}
/// <summary>Every bar time across every symbol, sorted.</summary>
public DateTime[] Timeline()
{
SortedSet<DateTime> times = [];
foreach (List<BidAskBar> bars in Bars.Values)
{
foreach (BidAskBar b in bars)
{
times.Add(b.TimeUtc);
}
}
return [.. times];
}
}
/// <summary>Knobs of a backtest run: costs and the period.</summary>
public sealed record BacktestSettings
{
public double StartingBalance { get; init; } = 10_000;
public double SlippagePips { get; init; } = 0.3;
public double OvernightPipsPerDay { get; init; } = 0.3;
/// <summary>Per-symbol spread floor in pips: the venue's typical spread, applied when the tick spread is narrower.</summary>
public IReadOnlyDictionary<string, double> SpreadFloorPips { get; init; } = DefaultEtoroSpreads();
/// <summary>Markup in pips the venue adds on top of the market spread (0 when the floor already represents the full spread).</summary>
public double MarkupPips { get; init; }
public DateTime? From { get; init; }
public DateTime? To { get; init; }
/// <summary>Set to false only for the falsification test that documents why the basket stop is mandatory.</summary>
public bool UseBasketStop { get; init; } = true;
/// <summary>
/// eToro's typical (advertised) spreads for the pairs in scope, in pips. Used as a
/// floor on the tick spreads of the MT5 export, which came from a different broker.
/// Verified against the live quotes only once keys are available; until then this
/// is the conservative assumption written in <c>docs/STRATEGY.md</c>.
/// </summary>
public static Dictionary<string, double> DefaultEtoroSpreads() => new(StringComparer.OrdinalIgnoreCase)
{
["EURUSD"] = 1.0,
["USDCHF"] = 1.5,
["AUDUSD"] = 1.0,
["USDCAD"] = 1.5,
["NZDUSD"] = 2.5,
["EURNZD"] = 5.0,
["EURAUD"] = 3.0,
["AUDCAD"] = 3.0,
["EURCHF"] = 2.0,
["EURCAD"] = 3.0,
};
}
/// <summary>One basket, opened and closed, as the backtest saw it.</summary>
public sealed record BacktestTrade(
string Basket,
DateTime OpenedUtc,
DateTime ClosedUtc,
bool BuyCross,
double EntryZ,
double ExitZ,
double PnlUsd,
double Pips,
double CostPips,
int Adds,
int BarsHeld,
string ExitReason,
double EquityAtEntry)
{
public int Label => PnlUsd > 0 ? 1 : 0;
}
/// <summary>Everything one run produced, with the daily return series every later analysis needs.</summary>
public sealed record BacktestResult(
string TrialId,
PresetName Preset,
IReadOnlyList<BacktestTrade> Trades,
DateTime[] DailyDates,
double[] DailyReturns,
double[] DailyEquity,
double FinalEquity,
double MaxDrawdown,
double SharpeAnnual,
double WinRate,
double PnlNet,
double BreakEvenCostPips,
double Psr,
double Skewness,
double Kurtosis,
int Bars,
TimeSpan Elapsed,
IReadOnlyDictionary<string, int> SkipReasons,
int EquityStops)
{
public int Count => Trades.Count;
/// <summary>The skip reasons, most frequent first, as one line.</summary>
public string DescribeSkips(int top = 6) =>
string.Join(", ", SkipReasons.OrderByDescending(static k => k.Value).Take(top).Select(static k => $"{k.Key} {k.Value}"));
public double AverageCostPips => Trades.Count > 0 ? Trades.Average(static t => t.CostPips) : double.NaN;
/// <summary>Percentile of the per-basket P&amp;L (1 % and 5 % show the tail the win rate hides).</summary>
public double PnlPercentile(double p)
{
if (Trades.Count == 0)
{
return double.NaN;
}
double[] sorted = [.. Trades.Select(static t => t.PnlUsd).Order()];
int i = Math.Clamp((int)Math.Floor(p * (sorted.Length - 1)), 0, sorted.Length - 1);
return sorted[i];
}
}
/// <summary>
/// The event-driven replay: bar by bar, the same <see cref="BasketDecider"/> and
/// <see cref="BasketExecutor"/> the live engine uses, over a <see cref="BacktestBroker"/>.
/// Decisions are taken on a bar's close and filled at the next bar's open, at that bar's
/// bid/ask plus slippage; overnight fees accrue daily; the leg-risk protocol runs for
/// real (the replay venue never rejects, so it is exercised by a dedicated test).
/// <para>
/// What the replay cannot do, and says so: the economic-calendar blackout and the
/// sentiment features need history the free feeds do not provide, so in the backtest
/// they are absent (no blackout applied). The live bot applies them; the comparison
/// backtest → paper → forward is where their effect shows.
/// </para>
/// </summary>
public static class BasketBacktest
{
public static BacktestResult Run(MarketData data, BasketStrategyConfig config, PresetName preset, BacktestSettings settings, string trialId)
{
ArgumentNullException.ThrowIfNull(data);
ArgumentNullException.ThrowIfNull(config);
ArgumentNullException.ThrowIfNull(settings);
Stopwatch sw = Stopwatch.StartNew();
// Instruments as the venue would describe them (ids are local to the replay).
List<string> symbols = config.Symbols(includeDirectCrosses: false);
Dictionary<string, Instrument> instruments = new(StringComparer.OrdinalIgnoreCase);
long nextId = 1;
foreach (string s in symbols)
{
instruments[s] = new Instrument(nextId++, s, s, "Forex", PipMath.Pip(s), PipMath.Digits(s), 0.01, 2_000_000, 1000, [1, 2, 5, 10, 20], true, true, 0, 50, "replay");
}
BacktestBroker broker = new(instruments.Values, settings.StartingBalance, settings.SlippagePips, settings.OvernightPipsPerDay);
Dictionary<string, SymbolSeries> series = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, int> cursor = new(StringComparer.OrdinalIgnoreCase);
foreach (string s in symbols)
{
series[s] = new SymbolSeries(instruments[s], TimeSpan.FromMinutes(15), 2000);
cursor[s] = 0;
}
BasketDecider decider = new(config, preset);
BasketExecutor executor = new(broker, config, broker.MidOf, static _ => { });
BasketPreset effective = decider.Preset;
List<Slot> slots = [];
foreach (BasketDefinition d in config.Baskets.Where(static b => b.Enabled))
{
if (!SyntheticCross.TryDerive(d.A, d.B, out SyntheticCross? cross) || !series.ContainsKey(d.A) || !series.ContainsKey(d.B))
{
continue;
}
slots.Add(new Slot(d.Name, cross!, series[d.A], series[d.B]));
}
List<BacktestTrade> trades = [];
Dictionary<string, int> skips = new(StringComparer.Ordinal);
int equityStops = 0;
List<(DateOnly Day, double Equity)> daily = [];
DateOnly currentDay = default;
double dayStartEquity = settings.StartingBalance;
double peak = settings.StartingBalance;
double lastEquity = settings.StartingBalance;
int bars = 0;
DateTime[] timeline = data.Timeline();
DateTime from = settings.From ?? DateTime.MinValue;
DateTime to = settings.To ?? DateTime.MaxValue;
foreach (DateTime t in timeline)
{
if (t < from || t > to)
{
continue;
}
// Phase 1: this bar's open is where yesterday's decisions get filled.
List<QuoteSnapshot> opens = [];
Dictionary<string, BidAskBar> current = new(StringComparer.OrdinalIgnoreCase);
foreach (string s in symbols)
{
List<BidAskBar> list = data.Bars[s];
int i = cursor[s];
if (i < list.Count && list[i].TimeUtc == t)
{
BidAskBar b = Floor(list[i], settings, instruments[s]);
current[s] = b;
cursor[s] = i + 1;
opens.Add(new QuoteSnapshot(instruments[s].Id, t, b.BidOpen, b.AskOpen, true));
}
}
if (current.Count == 0)
{
continue;
}
broker.Advance(t, opens);
foreach ((string s, BidAskBar b) in current)
{
series[s].OnQuote(new QuoteSnapshot(instruments[s].Id, t, b.BidOpen, b.AskOpen, true), t);
}
double equityNow = broker.Equity;
foreach (Slot slot in slots)
{
if (slot.Pending is null || !current.ContainsKey(slot.A.Symbol) || !current.ContainsKey(slot.B.Symbol))
{
continue;
}
BasketContext ctx = Context(slot, t, broker, equityNow, peak, slots, isBarClose: false);
Fill(slot, ctx, executor, decider.Preset, trades, t);
}
// Phase 2: the bar closes; append it, decide.
List<QuoteSnapshot> closes = [];
foreach ((string s, BidAskBar b) in current)
{
closes.Add(new QuoteSnapshot(instruments[s].Id, t.AddMinutes(15).AddMilliseconds(-1), b.BidClose, b.AskClose, true));
}
broker.Advance(t, closes);
foreach ((string s, BidAskBar b) in current)
{
series[s].Append(b);
series[s].OnQuote(new QuoteSnapshot(instruments[s].Id, t.AddMinutes(15).AddMilliseconds(-1), b.BidClose, b.AskClose, true), t);
}
bars++;
equityNow = broker.Equity;
if (equityNow > peak)
{
peak = equityNow;
}
// The equity stop closes everything, as live; but a backtest has no operator to
// lift it, so the peak restarts from here and the event is counted. The number
// of equity stops is reported: it is a result, not a detail.
bool equityStopped = peak > 0 && (peak - equityNow) / peak >= config.EquityStopPct / 100.0;
if (equityStopped)
{
equityStops++;
peak = equityNow;
}
DateOnly day = DateOnly.FromDateTime(t);
if (day != currentDay)
{
if (currentDay != default)
{
daily.Add((currentDay, lastEquity));
}
currentDay = day;
dayStartEquity = equityNow;
}
bool dailyLoss = dayStartEquity > 0 && (dayStartEquity - equityNow) / dayStartEquity >= config.DailyLossPct / 100.0;
foreach (Slot slot in slots)
{
if (!current.ContainsKey(slot.A.Symbol) || !current.ContainsKey(slot.B.Symbol))
{
continue;
}
if (slot.Position is { } p)
{
p.BarsHeld++;
}
BasketContext ctx = Context(slot, t, broker, equityNow, peak, slots, isBarClose: true) with
{
DailyLossHit = dailyLoss,
EquityStopped = equityStopped,
};
BasketDecision d = decider.Evaluate(ctx);
if (d.Kind == DecisionKind.Skip)
{
foreach (string code in d.ReasonCodes)
{
skips[code] = skips.GetValueOrDefault(code) + 1;
}
}
if (!settings.UseBasketStop && d.Kind == DecisionKind.Exit && d.ReasonCodes.Any(static c => c is "stop_z" or "stop_max_loss" or "rho_break"))
{
continue; // the "no stop" arm of the falsification test
}
if (d.Kind is DecisionKind.Enter or DecisionKind.Exit or DecisionKind.Add)
{
slot.Pending = d;
slot.PendingZ = d.Evaluation.Z;
}
}
lastEquity = equityNow;
}
if (currentDay != default)
{
daily.Add((currentDay, lastEquity));
}
// Close what is still open at the end, so the trade list is complete.
foreach (Slot slot in slots)
{
if (slot.Position is { } p && slot.A.HasQuote && slot.B.HasQuote)
{
BasketContext ctx = Context(slot, broker.Now, broker, broker.Equity, peak, slots, isBarClose: false);
ExitOutcome x = executor.CloseAsync(ctx, p, "fine dei dati", CancellationToken.None).GetAwaiter().GetResult();
trades.Add(new BacktestTrade(slot.Name, p.OpenedUtc, broker.Now, p.BuyCross, p.EntryZ, slot.PendingZ, x.RealizedPnlUsd, x.PipsTotal, p.EntryCostPips, p.Adds, p.BarsHeld, "end_of_data", p.EquityAtEntry));
slot.Position = null;
}
}
// Daily returns, Sharpe, drawdown, PSR.
DateTime[] dates = new DateTime[daily.Count];
double[] equity = new double[daily.Count];
for (int i = 0; i < daily.Count; i++)
{
dates[i] = daily[i].Day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
equity[i] = daily[i].Equity;
}
double[] returns = new double[Math.Max(0, daily.Count - 1)];
for (int i = 1; i < daily.Count; i++)
{
returns[i - 1] = equity[i - 1] > 0 ? (equity[i] / equity[i - 1]) - 1 : 0;
}
double sharpeDaily = Performance.Sharpe(returns);
double sharpe = Performance.Annualise(sharpeDaily, 260);
double maxDd = Performance.MaxDrawdown(equity);
double skew = returns.Length > 2 ? Performance.Skewness(returns) : 0;
double kurt = returns.Length > 3 ? Performance.Kurtosis(returns) : 3;
double psr = Performance.ProbabilisticSharpe(sharpeDaily, 0, returns.Length, skew, kurt);
double winRate = trades.Count > 0 ? trades.Count(static x => x.PnlUsd > 0) / (double)trades.Count : double.NaN;
double pnlNet = trades.Sum(static x => x.PnlUsd);
double breakEven = trades.Count > 0 ? trades.Average(static x => x.Pips + x.CostPips) : double.NaN;
return new BacktestResult(trialId, preset, trades, dates, returns, equity, broker.Equity, maxDd, sharpe, winRate, pnlNet, breakEven, psr, skew, kurt, bars, sw.Elapsed, skips, equityStops);
}
private sealed class Slot(string name, SyntheticCross cross, SymbolSeries a, SymbolSeries b)
{
public string Name { get; } = name;
public SyntheticCross Cross { get; } = cross;
public SymbolSeries A { get; } = a;
public SymbolSeries B { get; } = b;
public BasketPosition? Position { get; set; }
public BasketDecision? Pending { get; set; }
public double PendingZ { get; set; }
}
/// <summary>Applies the venue's typical spread as a floor around the mid, and the markup.</summary>
private static BidAskBar Floor(in BidAskBar b, BacktestSettings s, Instrument instrument)
{
double floor = (s.SpreadFloorPips.TryGetValue(instrument.Symbol, out double f) ? f : 0) + s.MarkupPips;
double target = floor * instrument.Pip;
if (target <= 0)
{
return b;
}
return new BidAskBar(b.TimeUtc,
Widen(b.BidOpen, b.AskOpen, target, true), Widen(b.BidHigh, b.AskHigh, target, true), Widen(b.BidLow, b.AskLow, target, true), Widen(b.BidClose, b.AskClose, target, true),
Widen(b.BidOpen, b.AskOpen, target, false), Widen(b.BidHigh, b.AskHigh, target, false), Widen(b.BidLow, b.AskLow, target, false), Widen(b.BidClose, b.AskClose, target, false),
Math.Max(b.SpreadMean, target), b.Ticks, b.Source);
static double Widen(double bid, double ask, double target, bool wantBid)
{
double spread = ask - bid;
if (spread >= target)
{
return wantBid ? bid : ask;
}
double mid = (bid + ask) / 2;
return wantBid ? mid - (target / 2) : mid + (target / 2);
}
}
private static BasketContext Context(Slot slot, DateTime t, BacktestBroker broker, double equity, double peak, List<Slot> slots, bool isBarClose)
{
bool sameCrossOpen = slots.Any(o => o != slot && o.Cross.Symbol == slot.Cross.Symbol && (o.Position is not null || o.Pending?.Kind == DecisionKind.Enter));
int open = slots.Count(static o => o.Position is not null);
double pipA = PipMath.PipValueUsd(slot.A.Symbol, 1, broker.MidOf);
double pipB = PipMath.PipValueUsd(slot.B.Symbol, 1, broker.MidOf);
double usdA = PipMath.QuoteToUsd(PipMath.QuoteCurrency(slot.A.Symbol), broker.MidOf) ?? double.NaN;
double usdB = PipMath.QuoteToUsd(PipMath.QuoteCurrency(slot.B.Symbol), broker.MidOf) ?? double.NaN;
return new BasketContext
{
TimeUtc = t,
BasketId = slot.Name,
Name = slot.Name,
Cross = slot.Cross,
A = slot.A,
B = slot.B,
Equity = equity,
PeakEquity = peak,
OpenBaskets = open,
SameCrossOpen = sameCrossOpen,
IsBarClose = isBarClose,
PipValueUsdA = double.IsNaN(pipA) ? 0 : pipA,
PipValueUsdB = double.IsNaN(pipB) ? 0 : pipB,
UsdPerQuoteA = double.IsNaN(usdA) ? 0 : usdA,
UsdPerQuoteB = double.IsNaN(usdB) ? 0 : usdB,
Mid = broker.MidOf,
Position = slot.Position,
};
}
private static void Fill(Slot slot, BasketContext ctx, BasketExecutor executor, BasketPreset preset, List<BacktestTrade> trades, DateTime t)
{
BasketDecision d = slot.Pending!;
slot.Pending = null;
switch (d.Kind)
{
case DecisionKind.Enter when slot.Position is null:
EntryOutcome entry = executor.OpenAsync(ctx, d, preset, CancellationToken.None).GetAwaiter().GetResult();
if (entry.Ok)
{
slot.Position = entry.Position;
}
break;
case DecisionKind.Add when slot.Position is not null:
executor.AddAsync(ctx, d, CancellationToken.None).GetAwaiter().GetResult();
break;
case DecisionKind.Exit when slot.Position is { } p:
ExitOutcome x = executor.CloseAsync(ctx, p, d.Motivazione, CancellationToken.None).GetAwaiter().GetResult();
trades.Add(new BacktestTrade(slot.Name, p.OpenedUtc, t, p.BuyCross, p.EntryZ, slot.PendingZ, x.RealizedPnlUsd, x.PipsTotal, p.EntryCostPips, p.Adds, p.BarsHeld, d.ReasonCodes.FirstOrDefault() ?? "exit", p.EquityAtEntry));
slot.Position = null;
break;
}
}
public static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,301 @@
using System.Globalization;
using System.Text;
using Encelado.Core.Ml;
using Encelado.Core.Statistics;
namespace Encelado.Core.Baskets.Backtest;
/// <summary>One row of <c>results/trials.csv</c>: a configuration that was tried and what it produced.</summary>
public sealed record TrialRecord(
string TrialId,
PresetName Preset,
SignalMode SignalMode,
ExitMode ExitMode,
AveragingMode Averaging,
double LotMultiplier,
double ZIn,
double ZOut,
double ZStop,
double TpPips,
int Window,
double RhoMin,
double CostMultiple,
bool BasketStop,
BacktestResult Result)
{
public const string Header =
"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";
public string ToCsv(double dsr, string motivazione)
{
BacktestResult r = Result;
return string.Join(';',
[
TrialId, Preset.ToString(), SignalMode.ToString(), ExitMode.ToString(), Averaging.ToString(), N(LotMultiplier),
N(ZIn), N(ZOut), N(ZStop), N(TpPips), Window.ToString(CultureInfo.InvariantCulture), N(RhoMin), N(CostMultiple), BasketStop ? "1" : "0",
r.Count.ToString(CultureInfo.InvariantCulture), N(r.WinRate), N(r.PnlNet), N(r.SharpeAnnual), N(r.MaxDrawdown), N(r.BreakEvenCostPips), N(r.AverageCostPips),
N(r.PnlPercentile(0.01)), N(r.PnlPercentile(0.05)), N(r.Psr), N(dsr), motivazione.Replace(';', ',').Replace('\n', ' '),
]);
}
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.####", CultureInfo.InvariantCulture) : string.Empty;
}
/// <summary>Walk-forward selection: which trial was chosen for each test month, and the stitched returns.</summary>
public sealed record WalkForwardResult(
IReadOnlyList<(DateTime Month, string TrialId, double TrainSharpe, double TestSharpe)> Choices,
double[] DailyReturns,
double SharpeAnnual,
double MaxDrawdown,
double Psr);
/// <summary>
/// Runs a population of configurations on the same data and reports them honestly:
/// the deflated Sharpe counts every row, the PBO comes from combinatorially symmetric
/// cross-validation over the daily returns of every trial, and the walk-forward result
/// is what the <i>procedure</i> (choose the best of the last six months, run it for one
/// month) would have earned — not the best row of the table.
/// </summary>
public static class BasketTrials
{
/// <summary>The grid of §9.1 around the three presets (the default when no explicit grid is given).</summary>
public static List<(BasketStrategyConfig Config, PresetName Preset, string Id)> DefaultGrid(BasketStrategyConfig baseline)
{
ArgumentNullException.ThrowIfNull(baseline);
List<(BasketStrategyConfig, PresetName, string)> grid = [];
int n = 0;
foreach (PresetName preset in new[] { PresetName.Conservative, PresetName.Moderate, PresetName.Aggressive })
{
foreach (int window in new[] { 60, 100, 150 })
{
foreach (double rho in new[] { 0.5, 0.6, 0.7 })
{
foreach (double zOut in new[] { 0.25, 0.5 })
{
BasketStrategyConfig c = Clone(baseline);
c.Preset = preset;
c.Window = window;
c.RhoMin = rho;
c.ZOut = zOut;
grid.Add((c, preset, $"T{++n:000}"));
}
}
}
}
return grid;
}
public static BasketStrategyConfig Clone(BasketStrategyConfig c)
{
ArgumentNullException.ThrowIfNull(c);
BasketStrategyConfig copy = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _);
copy.Preset = c.Preset;
copy.SignalMode = c.SignalMode;
copy.ExitMode = c.ExitMode;
copy.AveragingMode = c.AveragingMode;
copy.TpMode = c.TpMode;
copy.SameCrossPolicy = c.SameCrossPolicy;
copy.PreferDirectCross = c.PreferDirectCross;
copy.InvertSignal = c.InvertSignal;
copy.Window = c.Window;
copy.WindowShort = c.WindowShort;
copy.RhoMin = c.RhoMin;
copy.RhoShortMin = c.RhoShortMin;
copy.HalfLifeMinBars = c.HalfLifeMinBars;
copy.HalfLifeMaxBars = c.HalfLifeMaxBars;
copy.HalfLifeRecalcHours = c.HalfLifeRecalcHours;
copy.AtrPeriod = c.AtrPeriod;
copy.EwmaSpan = c.EwmaSpan;
copy.TrendPeriod = c.TrendPeriod;
copy.ZOut = c.ZOut;
copy.DIn = c.DIn;
copy.AnchorBars = c.AnchorBars;
copy.GridStepZ = c.GridStepZ;
copy.LotMultiplier = c.LotMultiplier;
copy.MaxLossPerBasketPct = c.MaxLossPerBasketPct;
copy.RhoBreak = c.RhoBreak;
copy.RhoBreakBars = c.RhoBreakBars;
copy.MaxHoldingBars = c.MaxHoldingBars;
copy.TpAtrMultiple = c.TpAtrMultiple;
copy.CostMultiple = c.CostMultiple;
copy.SpreadMedianMultiple = c.SpreadMedianMultiple;
copy.SpreadAnomalyMultiple = c.SpreadAnomalyMultiple;
copy.SlippagePipsPerLeg = c.SlippagePipsPerLeg;
copy.OvernightPipsPerDay = c.OvernightPipsPerDay;
copy.BlackoutBeforeMin = c.BlackoutBeforeMin;
copy.BlackoutAfterMin = c.BlackoutAfterMin;
copy.FridayCutoffUtcHour = c.FridayCutoffUtcHour;
copy.OpenDelayMinutes = c.OpenDelayMinutes;
copy.Sessions = [.. c.Sessions];
copy.MaxEffectiveLeverage = c.MaxEffectiveLeverage;
copy.OrderLeverage = c.OrderLeverage;
copy.VolScaleMin = c.VolScaleMin;
copy.VolScaleMax = c.VolScaleMax;
copy.VolAverageDays = c.VolAverageDays;
copy.MlMinProbability = c.MlMinProbability;
copy.EquityStopPct = c.EquityStopPct;
copy.DailyLossPct = c.DailyLossPct;
copy.LegTimeoutSec = c.LegTimeoutSec;
copy.ClockSkewMaxSeconds = c.ClockSkewMaxSeconds;
copy.ZInOverride = c.ZInOverride;
copy.RiskPerBasketPctOverride = c.RiskPerBasketPctOverride;
copy.MaxBasketsOverride = c.MaxBasketsOverride;
copy.TpPipsOverride = c.TpPipsOverride;
copy.MaxAddsOverride = c.MaxAddsOverride;
copy.ZStopOverride = c.ZStopOverride;
copy.Baskets = [.. c.Baskets.Select(static b => new BasketDefinition { A = b.A, B = b.B, Enabled = b.Enabled, Note = b.Note })];
return copy;
}
public static TrialRecord Record(string id, BasketStrategyConfig c, PresetName preset, BacktestSettings s, BacktestResult r)
{
BasketPreset e = c.Effective(preset);
return new TrialRecord(id, preset, c.SignalMode, c.ExitMode, c.AveragingMode, c.LotMultiplier, e.ZIn, c.ZOut, e.ZStop, e.TpPips, c.Window, c.RhoMin, c.CostMultiple, s.UseBasketStop, r);
}
/// <summary>Deflated Sharpe of one trial against the whole population (N = every row, variance of the annual Sharpes).</summary>
public static double Dsr(BacktestResult r, IReadOnlyList<TrialRecord> population)
{
ArgumentNullException.ThrowIfNull(r);
ArgumentNullException.ThrowIfNull(population);
if (r.DailyReturns.Length < 3)
{
return double.NaN;
}
double[] sharpes = [.. population.Select(static t => t.Result.SharpeAnnual / Math.Sqrt(260)).Where(double.IsFinite)];
double variance = sharpes.Length > 1 ? Math.Pow(Performance.StandardDeviation(sharpes), 2) : 0;
double sharpeDaily = r.SharpeAnnual / Math.Sqrt(260);
return Performance.DeflatedSharpe(sharpeDaily, r.DailyReturns.Length, r.Skewness, r.Kurtosis, population.Count, variance);
}
/// <summary>PBO by CSCV with S blocks over the aligned daily returns of every trial.</summary>
public static PboResult Pbo(IReadOnlyList<TrialRecord> population, int blocks = 16)
{
ArgumentNullException.ThrowIfNull(population);
if (population.Count < 2)
{
return new PboResult(double.NaN, double.NaN, 0);
}
int length = population.Min(static t => t.Result.DailyReturns.Length);
List<double[]> aligned = [.. population.Select(t => t.Result.DailyReturns[^length..])];
return Ml.Pbo.Compute(aligned, blocks, 2000, 42);
}
/// <summary>
/// Walk-forward: for every test month, the trial with the best Sharpe over the six
/// preceding months is applied to that month. Uses the cached daily returns, which is
/// legitimate because a trial's returns do not depend on whether it gets chosen.
/// </summary>
public static WalkForwardResult WalkForward(IReadOnlyList<TrialRecord> population, int trainMonths = 6)
{
ArgumentNullException.ThrowIfNull(population);
if (population.Count == 0)
{
return new WalkForwardResult([], [], double.NaN, double.NaN, double.NaN);
}
// Align on the shortest series; dates come from the first trial (all trials share the data).
int length = population.Min(static t => t.Result.DailyReturns.Length);
DateTime[] dates = population[0].Result.DailyDates[^length..];
double[][] returns = [.. population.Select(t => t.Result.DailyReturns[^length..])];
List<(DateTime, string, double, double)> choices = [];
List<double> stitched = [];
DateTime firstMonth = new(dates[0].Year, dates[0].Month, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime month = firstMonth.AddMonths(trainMonths);
DateTime end = dates[^1];
while (month <= end)
{
DateTime trainFrom = month.AddMonths(-trainMonths);
int best = -1;
double bestSharpe = double.NegativeInfinity;
for (int t = 0; t < returns.Length; t++)
{
List<double> train = [];
for (int i = 0; i < length; i++)
{
if (dates[i] >= trainFrom && dates[i] < month)
{
train.Add(returns[t][i]);
}
}
double s = train.Count > 20 ? Performance.Sharpe(train) : double.NegativeInfinity;
if (s > bestSharpe)
{
bestSharpe = s;
best = t;
}
}
DateTime next = month.AddMonths(1);
if (best >= 0)
{
List<double> test = [];
for (int i = 0; i < length; i++)
{
if (dates[i] >= month && dates[i] < next)
{
test.Add(returns[best][i]);
}
}
stitched.AddRange(test);
choices.Add((month, population[best].TrialId, Performance.Annualise(bestSharpe, 260), Performance.Annualise(Performance.Sharpe(test), 260)));
}
month = next;
}
double[] series = [.. stitched];
double[] equity = new double[series.Length + 1];
equity[0] = 1;
for (int i = 0; i < series.Length; i++)
{
equity[i + 1] = equity[i] * (1 + series[i]);
}
double daily = Performance.Sharpe(series);
double psr = series.Length > 3 ? Performance.ProbabilisticSharpe(daily, 0, series.Length, Performance.Skewness(series), Performance.Kurtosis(series)) : double.NaN;
return new WalkForwardResult(choices, series, Performance.Annualise(daily, 260), Performance.MaxDrawdown(equity), psr);
}
/// <summary>Writes <c>trials.csv</c> atomically.</summary>
public static void WriteTrials(string path, IReadOnlyList<TrialRecord> population)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(population);
StringBuilder sb = new();
sb.AppendLine(TrialRecord.Header);
foreach (TrialRecord t in population)
{
double dsr = Dsr(t.Result, population);
sb.AppendLine(t.ToCsv(dsr, Describe(t, dsr, population.Count)));
}
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
File.WriteAllText(path + ".tmp", sb.ToString(), new UTF8Encoding(false));
File.Move(path + ".tmp", path, overwrite: true);
}
public static string Describe(TrialRecord t, double dsr, int population)
{
BacktestResult r = t.Result;
if (r.Count == 0)
{
return "nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme";
}
string verdict = r.PnlNet <= 0
? "perde al netto dei costi"
: dsr >= 0.95 ? "regge la deflazione per il numero di prove"
: r.SharpeAnnual > 0 ? $"positivo ma non distinguibile dalla selezione fra {population} prove (DSR {dsr:F2})"
: "Sharpe negativo";
return string.Create(CultureInfo.InvariantCulture,
$"{r.Count} basket, win rate {r.WinRate:P0}, netto {r.PnlNet:F0} USD, Sharpe {r.SharpeAnnual:F2}, DD {r.MaxDrawdown:P1}, costo medio {r.AverageCostPips:F1} pip, break-even {r.BreakEvenCostPips:F1} pip: {verdict}");
}
}
@@ -0,0 +1,743 @@
using System.Globalization;
namespace Encelado.Core.Baskets;
/// <summary>Everything one evaluation of one basket sees. Built by the engine or the backtest, read by the decider.</summary>
public sealed record BasketContext
{
public required DateTime TimeUtc { get; init; }
public required string BasketId { get; init; }
public required string Name { get; init; }
public required SyntheticCross Cross { get; init; }
public required SymbolSeries A { get; init; }
public required SymbolSeries B { get; init; }
public required double Equity { get; init; }
public double PeakEquity { get; init; }
public double DailyPnlUsd { get; init; }
public int OpenBaskets { get; init; }
/// <summary>Another basket with the same synthetic cross is open (or opening).</summary>
public bool SameCrossOpen { get; init; }
public bool DailyLossHit { get; init; }
public bool EquityStopped { get; init; }
public bool KillSwitched { get; init; }
/// <summary>Clock skew, persistent API errors, data-quality issue: no new entries, exits still allowed.</summary>
public string? EntriesBlockedReason { get; init; }
/// <summary>Whether this evaluation happens on a bar close (signals) or on a quote (exit monitoring only).</summary>
public bool IsBarClose { get; init; } = true;
// ---- calendar (int.MaxValue / NaN when unknown) ----
public int MinutesToNextHigh { get; init; } = int.MaxValue;
public int MinutesSinceLastHigh { get; init; } = int.MaxValue;
public double SurpriseLast { get; init; } = double.NaN;
/// <summary>Whether the weekly market opening happened less than OpenDelayMinutes ago.</summary>
public bool JustOpened { get; init; }
// ---- sentiment differences (long currency minus short currency of the cross) ----
public double NetSentimentDiff1h { get; init; } = double.NaN;
public double NetSentimentDiff4h { get; init; } = double.NaN;
public double NetSentimentDiff24h { get; init; } = double.NaN;
public double HawkishDiff { get; init; } = double.NaN;
public double RiskOff { get; init; } = double.NaN;
public int NewsCount { get; init; }
// ---- volatility forecast ----
public double SigmaForecast { get; init; } = double.NaN;
public double SigmaAverage30d { get; init; } = double.NaN;
// ---- learning ----
public double PMl { get; init; } = double.NaN;
public bool MlActive { get; init; }
public double LastOutcomes { get; init; } = double.NaN;
// ---- costs from the venue (0 when unknown) ----
public double MarkupPipsA { get; init; }
public double MarkupPipsB { get; init; }
public double CommissionPipsA { get; init; }
public double OvernightPipsPerDay { get; init; } = double.NaN;
// ---- conversions ----
public required double PipValueUsdA { get; init; }
public required double PipValueUsdB { get; init; }
public required double UsdPerQuoteA { get; init; }
public required double UsdPerQuoteB { get; init; }
public required Func<string, double?> Mid { get; init; }
public BasketPosition? Position { get; init; }
}
/// <summary>The features of one evaluation — the row of the decisions ledger, computed before the decision.</summary>
public sealed record BasketEvaluation
{
public double Z { get; init; } = double.NaN;
public double ZInEffective { get; init; } = double.NaN;
public double DPips { get; init; } = double.NaN;
public double RhoW { get; init; } = double.NaN;
public double RhoShort { get; init; } = double.NaN;
public double HalfLife { get; init; } = double.NaN;
public double AtrPipsA { get; init; } = double.NaN;
public double AtrPipsB { get; init; } = double.NaN;
public double SigmaX { get; init; } = double.NaN;
public double EwmaVolX { get; init; } = double.NaN;
public double TrendStrength { get; init; } = double.NaN;
public double SpreadPipsA { get; init; } = double.NaN;
public double SpreadPipsB { get; init; } = double.NaN;
public double CostPips { get; init; } = double.NaN;
public double BreakEvenWinRate { get; init; } = double.NaN;
public double HourSin { get; init; }
public double HourCos { get; init; }
public int DayOfWeek { get; init; }
public double PriceA { get; init; }
public double PriceB { get; init; }
public double PipsOpen { get; init; } = double.NaN;
public double PnlOpenUsd { get; init; } = double.NaN;
public int BarsHeld { get; init; }
}
public enum DecisionKind
{
Skip = 0,
Enter,
Add,
Exit,
Hold,
}
/// <summary>One decision: what to do, why, and the numbers behind it.</summary>
public sealed record BasketDecision(
DecisionKind Kind,
bool BuyCross,
SizingResult? Sizing,
IReadOnlyList<string> ReasonCodes,
string Motivazione,
BasketEvaluation Evaluation,
CostGateResult? Cost)
{
public bool IsExit => Kind == DecisionKind.Exit;
}
/// <summary>
/// The strategy's brain, free of I/O: it turns a <see cref="BasketContext"/> into a
/// <see cref="BasketDecision"/> by the rules of §5. The live engine and the backtest
/// call the same code, which is the only way a backtest can say anything about the
/// bot that will trade.
/// </summary>
public sealed class BasketDecider
{
private readonly BasketStrategyConfig _cfg;
private readonly Dictionary<string, Anchor> _anchors = new(StringComparer.Ordinal);
private readonly Dictionary<string, (DateTime At, double Value)> _halfLives = new(StringComparer.Ordinal);
private BasketPreset _preset;
private sealed class Anchor
{
public double PriceA;
public double PriceB;
public int BarsSince;
}
public BasketDecider(BasketStrategyConfig config, PresetName? preset = null)
{
ArgumentNullException.ThrowIfNull(config);
_cfg = config;
_preset = config.Effective(preset);
}
public BasketStrategyConfig Config => _cfg;
public BasketPreset Preset => _preset;
/// <summary>Hot swap of the preset: open baskets keep the numbers they were opened with.</summary>
public void SetPreset(PresetName name) => _preset = _cfg.Effective(name);
/// <summary>Bars of the timeframe in one day (M15 → 96).</summary>
public const int BarsPerDay = 96;
public BasketDecision Evaluate(BasketContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
BasketEvaluation eval = Compute(ctx);
return ctx.Position is null ? DecideEntry(ctx, eval) : DecideOpen(ctx, eval);
}
// -----------------------------------------------------------------------
// Features
// -----------------------------------------------------------------------
public BasketEvaluation Compute(BasketContext ctx)
{
SymbolSeries a = ctx.A, b = ctx.B;
int w = _cfg.Window;
int n = Math.Min(a.Count, b.Count);
(double hs, double hc) = BasketMath.HourFeatures(ctx.TimeUtc);
double priceA = a.Mid, priceB = b.Mid;
BasketEvaluation partial = new()
{
HourSin = hs,
HourCos = hc,
DayOfWeek = (int)ctx.TimeUtc.DayOfWeek,
PriceA = priceA,
PriceB = priceB,
SpreadPipsA = a.SpreadPips,
SpreadPipsB = b.SpreadPips,
BarsHeld = ctx.Position?.BarsHeld ?? 0,
};
if (n < w + 2)
{
return partial;
}
// Returns of the two legs, aligned on the last w bars (the series are appended in lockstep).
ReadOnlySpan<double> ra = a.Returns(w);
ReadOnlySpan<double> rb = b.Returns(w);
double rho = BasketMath.Correlation(ra, rb);
int ws = _cfg.WindowShort;
double rhoShort = BasketMath.Correlation(ra[^ws..], rb[^ws..]);
// Synthetic cross level over the window and its z-score.
ReadOnlySpan<double> la = a.LogCloses(w);
ReadOnlySpan<double> lb = b.LogCloses(w);
double[] x = new double[w];
for (int i = 0; i < w; i++)
{
x[i] = la[i] + (ctx.Cross.SignB * lb[i]);
}
double sigmaX = BasketMath.StdDev(x);
double z = BasketMath.ZScore(x);
// Live value of the cross from the quotes, so the z the exit sees is the current one.
if (!ctx.IsBarClose && priceA > 0 && priceB > 0 && sigmaX > 0)
{
double live = Math.Log(priceA) + (ctx.Cross.SignB * Math.Log(priceB));
z = (live - BasketMath.Mean(x)) / sigmaX;
}
// Half-life over a longer window, refreshed every few hours.
double hl = HalfLifeFor(ctx, a, b);
double atrA = a.AtrPips(_cfg.AtrPeriod);
double atrB = b.AtrPips(_cfg.AtrPeriod);
// EWMA vol of the cross's returns.
int span = _cfg.EwmaSpan;
ReadOnlySpan<double> ra4 = a.Returns(4 * span);
ReadOnlySpan<double> rb4 = b.Returns(4 * span);
int m = Math.Min(ra4.Length, rb4.Length);
double[] rx = new double[m];
for (int i = 0; i < m; i++)
{
rx[i] = ra4[^m..][i] + (ctx.Cross.SignB * rb4[^m..][i]);
}
double ewma = BasketMath.EwmaVolatility(rx, span);
double trend = TrendOfCross(ctx, a, b);
double zIn = EffectiveZIn(ctx);
double dPips = double.NaN;
if (_cfg.SignalMode == SignalMode.PipDivergence)
{
dPips = Divergence(ctx, priceA, priceB);
}
double pipsOpen = double.NaN, pnlOpen = double.NaN;
if (ctx.Position is { } p && priceA > 0 && priceB > 0)
{
(double exitA, double exitB) = ExitPrices(ctx, p);
pipsOpen = p.PipsTotal(exitA, exitB, a.Instrument.Pip, b.Instrument.Pip);
pnlOpen = p.NetPnlUsd(exitA, exitB, ctx.Mid);
}
return partial with
{
Z = z,
ZInEffective = zIn,
DPips = dPips,
RhoW = rho,
RhoShort = rhoShort,
HalfLife = hl,
AtrPipsA = atrA,
AtrPipsB = atrB,
SigmaX = sigmaX,
EwmaVolX = ewma,
TrendStrength = trend,
PipsOpen = pipsOpen,
PnlOpenUsd = pnlOpen,
};
}
private double HalfLifeFor(BasketContext ctx, SymbolSeries a, SymbolSeries b)
{
if (_halfLives.TryGetValue(ctx.BasketId, out (DateTime At, double Value) cached) &&
ctx.TimeUtc - cached.At < TimeSpan.FromHours(_cfg.HalfLifeRecalcHours))
{
return cached.Value;
}
int window = Math.Min(4 * _cfg.Window, Math.Min(a.Count, b.Count));
if (window < 2 * _cfg.Window)
{
return double.NaN;
}
ReadOnlySpan<double> la = a.LogCloses(window);
ReadOnlySpan<double> lb = b.LogCloses(window);
double[] x = new double[window];
for (int i = 0; i < window; i++)
{
x[i] = la[i] + (ctx.Cross.SignB * lb[i]);
}
double hl = BasketMath.HalfLife(x);
_halfLives[ctx.BasketId] = (ctx.TimeUtc, hl);
return hl;
}
private double TrendOfCross(BasketContext ctx, SymbolSeries a, SymbolSeries b)
{
int period = _cfg.TrendPeriod;
int count = 6 * period + 1;
ReadOnlySpan<Data.BidAskBar> ba = a.LastBars(count);
ReadOnlySpan<Data.BidAskBar> bb = b.LastBars(count);
int n = Math.Min(ba.Length, bb.Length);
if (n < (2 * period) + 1)
{
return double.NaN;
}
// A synthetic bar of the cross: exp(ln A ± ln B) on open/high/low/close of the mids.
Data.BidAskBar[] x = new Data.BidAskBar[n];
for (int i = 0; i < n; i++)
{
Data.BidAskBar p = ba[^n..][i];
Data.BidAskBar q = bb[^n..][i];
double o = Cross(p.MidOpen, q.MidOpen), c = Cross(p.MidClose, q.MidClose);
double h = ctx.Cross.SignB > 0 ? Cross(p.MidHigh, q.MidHigh) : Cross(p.MidHigh, q.MidLow);
double l = ctx.Cross.SignB > 0 ? Cross(p.MidLow, q.MidLow) : Cross(p.MidLow, q.MidHigh);
x[i] = new Data.BidAskBar(p.TimeUtc, o, Math.Max(h, l), Math.Min(h, l), c, o, Math.Max(h, l), Math.Min(h, l), c, 0, 0, "cross");
}
return BasketMath.TrendStrength(x, period);
double Cross(double pa, double pb) => Math.Exp(Math.Log(pa) + (ctx.Cross.SignB * Math.Log(pb)));
}
/// <summary>§5.8: zIn scaled by the volatility forecast relative to its 30-day average, within [0.8, 1.5].</summary>
public double EffectiveZIn(BasketContext ctx)
{
double zIn = _preset.ZIn;
if (double.IsFinite(ctx.SigmaForecast) && double.IsFinite(ctx.SigmaAverage30d) && ctx.SigmaAverage30d > 0)
{
zIn *= Math.Clamp(ctx.SigmaForecast / ctx.SigmaAverage30d, _cfg.VolScaleMin, _cfg.VolScaleMax);
}
return zIn;
}
private double Divergence(BasketContext ctx, double priceA, double priceB)
{
if (!_anchors.TryGetValue(ctx.BasketId, out Anchor? anchor) || anchor.BarsSince >= _cfg.AnchorBars)
{
anchor = new Anchor { PriceA = priceA, PriceB = priceB, BarsSince = 0 };
_anchors[ctx.BasketId] = anchor;
}
else if (ctx.IsBarClose)
{
anchor.BarsSince++;
}
double pipsA = (priceA - anchor.PriceA) / ctx.A.Instrument.Pip;
double pipsB = (priceB - anchor.PriceB) / ctx.B.Instrument.Pip;
return pipsA + (ctx.Cross.SignB * pipsB);
}
private static (double ExitA, double ExitB) ExitPrices(BasketContext ctx, BasketPosition p)
{
double exitA = p.A.IsBuy ? (ctx.A.HasQuote ? ctx.A.Quote.Bid : ctx.A.Last.BidClose) : (ctx.A.HasQuote ? ctx.A.Quote.Ask : ctx.A.Last.AskClose);
double exitB = p.B.IsBuy ? (ctx.B.HasQuote ? ctx.B.Quote.Bid : ctx.B.Last.BidClose) : (ctx.B.HasQuote ? ctx.B.Quote.Ask : ctx.B.Last.AskClose);
return (exitA, exitB);
}
// -----------------------------------------------------------------------
// Entry (§5.3)
// -----------------------------------------------------------------------
private BasketDecision DecideEntry(BasketContext ctx, BasketEvaluation e)
{
List<string> codes = [];
if (!ctx.IsBarClose)
{
return Skip(e, ["not_bar_close"], "in attesa della chiusura della barra", null);
}
// Hard blocks first: they are the reasons nothing else matters, and the ledger
// must say "halted" rather than "warming up" while a halt is active.
if (ctx.KillSwitched) { codes.Add("kill_switch"); }
if (ctx.EquityStopped) { codes.Add("equity_stop"); }
if (ctx.DailyLossHit) { codes.Add("daily_loss"); }
if (ctx.EntriesBlockedReason is not null) { codes.Add("entries_blocked"); }
if (ctx.A.QualityIssue is not null || ctx.B.QualityIssue is not null) { codes.Add("data_quality"); }
if (codes.Count > 0)
{
string blocked = codes[0] switch
{
"kill_switch" => "kill-switch attivo: nessuna nuova entrata",
"equity_stop" => "equity stop attivo: serve un reset manuale",
"daily_loss" => "perdita giornaliera massima raggiunta: niente entrate fino a domani",
"entries_blocked" => $"entrate bloccate: {ctx.EntriesBlockedReason}",
_ => $"qualità dati: {ctx.A.QualityIssue ?? ctx.B.QualityIssue}",
};
return Skip(e, codes, blocked, null);
}
if (double.IsNaN(e.Z))
{
return Skip(e, ["warmup"], $"riscaldamento: servono {_cfg.Window + 2} barre su entrambe le gambe", null);
}
// Signal (§5.2) and its direction.
bool signal;
bool buyCross;
double zIn = e.ZInEffective;
if (_cfg.SignalMode == SignalMode.PipDivergence)
{
signal = double.IsFinite(e.DPips) && Math.Abs(e.DPips) >= _cfg.DIn;
buyCross = e.DPips < 0;
}
else
{
signal = Math.Abs(e.Z) >= zIn;
buyCross = e.Z < 0;
}
// Correlation (1), half-life (2), regime.
double expectedSign = ctx.Cross.ExpectedCorrelationSign;
bool rhoOk = double.IsFinite(e.RhoW) && Math.Abs(e.RhoW) >= _cfg.RhoMin && Math.Sign(e.RhoW) == Math.Sign(expectedSign);
bool rhoShortOk = double.IsFinite(e.RhoShort) && Math.Abs(e.RhoShort) >= _cfg.RhoShortMin;
bool hlOk = double.IsFinite(e.HalfLife) && e.HalfLife >= _cfg.HalfLifeMinBars && e.HalfLife <= _cfg.HalfLifeMaxBars;
if (!signal)
{
string what = _cfg.SignalMode == SignalMode.PipDivergence
? F($"|D| {Math.Abs(e.DPips):F1} pip sotto dIn {_cfg.DIn:F0}")
: F($"|z| {Math.Abs(e.Z):F2} sotto zIn {zIn:F2}");
return Skip(e, ["no_signal"], F($"{what}; ρ {e.RhoW:+0.00;-0.00}, HL {Fmt(e.HalfLife)}"), null);
}
if (!rhoOk) { codes.Add("rho_low"); }
if (!rhoShortOk) { codes.Add("rho_short_low"); }
if (!hlOk) { codes.Add("half_life"); }
// Calendar (5) and time (6).
if (ctx.MinutesToNextHigh != int.MaxValue && ctx.MinutesToNextHigh >= 0 && ctx.MinutesToNextHigh <= _cfg.BlackoutBeforeMin) { codes.Add("blackout_before"); }
if (ctx.MinutesSinceLastHigh != int.MaxValue && ctx.MinutesSinceLastHigh >= 0 && ctx.MinutesSinceLastHigh <= _cfg.BlackoutAfterMin) { codes.Add("blackout_after"); }
if (IsWeekendWindow(ctx.TimeUtc)) { codes.Add("weekend"); }
if (ctx.JustOpened) { codes.Add("just_opened"); }
if (!InSession(ctx.TimeUtc)) { codes.Add("session"); }
// Risk limits (7).
if (ctx.OpenBaskets >= _preset.MaxBaskets) { codes.Add("max_baskets"); }
if (ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Exclusive) { codes.Add("same_cross"); }
// Meta-model (8).
if (ctx.MlActive && double.IsFinite(ctx.PMl) && ctx.PMl < _cfg.MlMinProbability) { codes.Add("ml_gate"); }
// Cost gate (4).
double stopDistancePips = double.IsFinite(e.SigmaX) && e.AtrPipsA > 0
? (_preset.ZStop - Math.Abs(e.Z)) * e.SigmaX / ctx.A.Instrument.Pip
: 24;
double overnight = double.IsFinite(ctx.OvernightPipsPerDay) ? ctx.OvernightPipsPerDay : _cfg.OvernightPipsPerDay;
CostGateResult cost = CostGate.Evaluate(
e.SpreadPipsA, e.SpreadPipsB, ctx.PipValueUsdA, ctx.PipValueUsdB,
ctx.MarkupPipsA, ctx.MarkupPipsB, ctx.CommissionPipsA,
overnight, (double)_cfg.MaxHoldingBars / BarsPerDay,
TpPips(e), stopDistancePips, _cfg.CostMultiple,
ctx.A.SpreadMedianPips24h(), ctx.B.SpreadMedianPips24h(), _cfg.SpreadMedianMultiple);
if (!cost.Passed) { codes.Add("cost_gate"); }
BasketEvaluation withCost = e with { CostPips = cost.CostPips, BreakEvenWinRate = cost.BreakEvenWinRate };
if (codes.Count > 0)
{
return Skip(withCost, codes, Explain(codes, ctx, e, cost), cost);
}
// Sizing (§4.3).
double scale = ctx.MlActive && double.IsFinite(ctx.PMl) ? Math.Clamp((2 * ctx.PMl) - 1, 0.25, 1) : 1;
double costUsd = cost.CostPips * ctx.PipValueUsdA; // per unit of A; scaled below once units are known — first pass uses a small placeholder
SizingResult sizing = VolParitySizing.Compute(
ctx.Equity, _preset.RiskPerBasketPct, e.Z, _preset.ZStop, e.SigmaX, e.AtrPipsA, e.AtrPipsB,
ctx.PipValueUsdA, ctx.PipValueUsdB, e.PriceA, e.PriceB, ctx.UsdPerQuoteA, ctx.UsdPerQuoteB,
0, ctx.A.Instrument.MinExposure, ctx.A.Instrument.MaxUnitsPerOrder, ctx.B.Instrument.MaxUnitsPerOrder,
_cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale);
if (sizing.Ok)
{
// Second pass with the cost in USD now that the units are known.
costUsd *= sizing.UnitsA;
sizing = VolParitySizing.Compute(
ctx.Equity, _preset.RiskPerBasketPct, e.Z, _preset.ZStop, e.SigmaX, e.AtrPipsA, e.AtrPipsB,
ctx.PipValueUsdA, ctx.PipValueUsdB, e.PriceA, e.PriceB, ctx.UsdPerQuoteA, ctx.UsdPerQuoteB,
costUsd, ctx.A.Instrument.MinExposure, ctx.A.Instrument.MaxUnitsPerOrder, ctx.B.Instrument.MaxUnitsPerOrder,
_cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale);
}
if (!sizing.Ok)
{
return Skip(withCost, ["sizing"], $"size non calcolabile: {sizing.Reason}", cost);
}
if (_cfg.InvertSignal)
{
buyCross = !buyCross;
}
(bool buyA, bool buyB) = ctx.Cross.Legs(buyCross);
string why = F($"{(buyCross ? "COMPRO" : "VENDO")} il cross {ctx.Cross.Symbol}: z {e.Z:+0.00;-0.00} oltre ±{zIn:0.00}, ρ_W {e.RhoW:+0.00;-0.00}, ρ_20 {e.RhoShort:+0.00;-0.00}, HL {Fmt(e.HalfLife)} barre; ") +
$"{cost.Reason}; {sizing.Reason}; gambe {(buyA ? "long" : "short")} {ctx.A.Symbol} + {(buyB ? "long" : "short")} {ctx.B.Symbol}" +
(ctx.MlActive ? F($"; p_ML {ctx.PMl:0.00} × scala {scale:0.00}") : double.IsFinite(ctx.PMl) ? F($"; p_ML {ctx.PMl:0.00} (ombra)") : string.Empty);
return new BasketDecision(DecisionKind.Enter, buyCross, sizing, ["enter"], why, withCost, cost);
}
private double TpPips(BasketEvaluation e) =>
_cfg.TpMode == TpMode.AtrMultiple && double.IsFinite(e.AtrPipsA) ? Math.Max(1, _cfg.TpAtrMultiple * e.AtrPipsA) : _preset.TpPips;
/// <summary>No entries from Friday's cutoff to the Sunday reopen.</summary>
private bool IsWeekendWindow(DateTime utc) =>
utc.DayOfWeek == DayOfWeek.Saturday ||
(utc.DayOfWeek == DayOfWeek.Friday && utc.Hour >= _cfg.FridayCutoffUtcHour) ||
(utc.DayOfWeek == DayOfWeek.Sunday && utc.Hour < 22);
private bool InSession(DateTime utc)
{
if (_cfg.Sessions.Count == 0)
{
return true;
}
foreach ((int from, int to) in _cfg.Sessions)
{
if (from <= to ? utc.Hour >= from && utc.Hour < to : utc.Hour >= from || utc.Hour < to)
{
return true;
}
}
return false;
}
private string Explain(List<string> codes, BasketContext ctx, BasketEvaluation e, CostGateResult cost)
{
List<string> parts = [];
foreach (string c in codes)
{
parts.Add(c switch
{
"rho_low" => F($"ρ_W {e.RhoW:+0.00;-0.00} non è {(ctx.Cross.ExpectedCorrelationSign < 0 ? " " : " +")}{_cfg.RhoMin:0.00}"),
"rho_short_low" => F($"|ρ_20| {Math.Abs(e.RhoShort):0.00} sotto {_cfg.RhoShortMin:0.00}: correlazione rotta di recente"),
"half_life" => F($"half-life {Fmt(e.HalfLife)} fuori da [{_cfg.HalfLifeMinBars:0}, {_cfg.HalfLifeMaxBars:0}] barre"),
"blackout_before" => F($"evento ad alto impatto fra {ctx.MinutesToNextHigh} min (blackout {_cfg.BlackoutBeforeMin})"),
"blackout_after" => F($"evento ad alto impatto {ctx.MinutesSinceLastHigh} min fa (blackout {_cfg.BlackoutAfterMin})"),
"weekend" => F($"finestra del fine settimana (dal venerdì {_cfg.FridayCutoffUtcHour}:00 UTC alla riapertura)"),
"just_opened" => F($"primi {_cfg.OpenDelayMinutes} minuti dopo l'apertura settimanale"),
"session" => "fuori dalle sessioni consentite",
"max_baskets" => F($"{ctx.OpenBaskets} basket aperti su {_preset.MaxBaskets}"),
"same_cross" => $"un altro basket sullo stesso cross sintetico {ctx.Cross.Symbol} è aperto",
"ml_gate" => F($"p_ML {ctx.PMl:0.00} sotto {_cfg.MlMinProbability:0.00}"),
"cost_gate" => cost.Reason,
_ => c,
});
}
string signal = _cfg.SignalMode == SignalMode.PipDivergence ? F($"D {e.DPips:+0.0;-0.0} pip") : F($"z {e.Z:+0.00;-0.00}");
return $"segnale ({signal}) ma NON ENTRO: " + string.Join("; ", parts);
}
// -----------------------------------------------------------------------
// Open basket: exits (§5.4) and adds (§5.5)
// -----------------------------------------------------------------------
private BasketDecision DecideOpen(BasketContext ctx, BasketEvaluation e)
{
BasketPosition p = ctx.Position!;
double tp = p.TpPips;
bool pipsOk = double.IsFinite(e.PipsOpen);
bool pnlOk = double.IsFinite(e.PnlOpenUsd);
// Forced exits that are valid on any quote.
if (ctx.KillSwitched)
{
return Exit(e, ["kill_switch"], "kill-switch: chiudo il basket");
}
if (ctx.EquityStopped)
{
return Exit(e, ["equity_stop"], "equity stop: chiudo il basket");
}
if (pnlOk && e.PnlOpenUsd <= -p.MaxLossUsd)
{
return Exit(e, ["stop_max_loss"], F($"perdita netta {e.PnlOpenUsd:F2} USD oltre il massimo per basket {p.MaxLossUsd:F2} USD ({_cfg.MaxLossPerBasketPct:0.##} % dell'equity all'ingresso)"));
}
// An anomalous spread is a forced exit only when it persists (three bar closes,
// 45 minutes): closing into a momentary spike pays the very spread the rule is
// meant to avoid, and the spike at a fixing or a news print is over in minutes.
double medA = ctx.A.SpreadMedianPips24h();
double medB = ctx.B.SpreadMedianPips24h();
bool anomalous = (double.IsFinite(medA) && e.SpreadPipsA > _cfg.SpreadAnomalyMultiple * medA) || (double.IsFinite(medB) && e.SpreadPipsB > _cfg.SpreadAnomalyMultiple * medB);
if (ctx.IsBarClose)
{
p.BarsWithSpreadAnomaly = anomalous ? p.BarsWithSpreadAnomaly + 1 : 0;
if (p.BarsWithSpreadAnomaly >= 3)
{
return Exit(e, ["spread_anomaly"], F($"spread anomalo da {p.BarsWithSpreadAnomaly} barre (A {e.SpreadPipsA:F1} / mediana {medA:F1}, B {e.SpreadPipsB:F1} / mediana {medB:F1}): chiusura forzata"));
}
}
// Take-profit (either rule, per ExitMode).
bool tpByPips = pipsOk && e.PipsOpen >= tp;
bool tpByZ = double.IsFinite(e.Z) && Math.Abs(e.Z) <= _cfg.ZOut && (p.BuyCross ? e.Z >= -_cfg.ZOut : e.Z <= _cfg.ZOut);
bool tpHit = _cfg.ExitMode switch
{
ExitMode.FixedPips => tpByPips,
ExitMode.ZReturn => tpByZ,
_ => tpByPips || tpByZ,
};
if (tpHit)
{
string why = tpByPips
? F($"take-profit: {e.PipsOpen:+0.0;-0.0} pip di basket ≥ TP {tp:0.0} (netto {Fmt2(e.PnlOpenUsd)} USD)")
: F($"convergenza: |z| {Math.Abs(e.Z):0.00} ≤ zOut {_cfg.ZOut:0.00} ({e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD)");
return Exit(e, [tpByPips ? "tp_pips" : "tp_z"], why);
}
// z stop can be judged on any quote too; correlation and time only on bar close.
if (double.IsFinite(e.Z) && (p.BuyCross ? e.Z <= -_preset.ZStop : e.Z >= _preset.ZStop))
{
return Exit(e, ["stop_z"], F($"stop di basket: z {e.Z:+0.00;-0.00} oltre ±{_preset.ZStop:0.00} ({e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD)"));
}
if (!ctx.IsBarClose)
{
return Hold(e, F($"IN POSIZIONE — {e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD, z {e.Z:+0.00;-0.00}"));
}
if (p.BarsHeld >= _cfg.MaxHoldingBars)
{
return Exit(e, ["time_stop"], F($"time-stop: {p.BarsHeld} barre ≥ {_cfg.MaxHoldingBars} ({e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD)"));
}
if (double.IsFinite(e.RhoShort) && Math.Abs(e.RhoShort) < _cfg.RhoBreak)
{
p.BarsWithBrokenCorrelation++;
if (p.BarsWithBrokenCorrelation >= _cfg.RhoBreakBars)
{
return Exit(e, ["rho_break"], F($"correlazione rotta: |ρ_20| {Math.Abs(e.RhoShort):0.00} < {_cfg.RhoBreak:0.00} per {p.BarsWithBrokenCorrelation} barre"));
}
}
else
{
p.BarsWithBrokenCorrelation = 0;
}
// Averaging (§5.5): only when |z| grew by GridStepZ since the last entry, within MaxAdds.
int maxAdds = _cfg.AveragingMode switch
{
AveragingMode.Off => 0,
AveragingMode.AddOnce => Math.Min(1, _preset.MaxAdds),
_ => _preset.MaxAdds,
};
if (maxAdds > 0 && p.Adds < maxAdds && double.IsFinite(e.Z) &&
Math.Abs(e.Z) - Math.Abs(p.LastAddZ) >= _cfg.GridStepZ && Math.Abs(e.Z) < _preset.ZStop &&
ctx.EntriesBlockedReason is null && !ctx.DailyLossHit)
{
double scale = Math.Pow(_cfg.LotMultiplier, p.Adds + 1);
SizingResult add = new(true, Math.Round(p.A.Units * scale, 2), Math.Round(p.B.Units * scale, 2), 0, 0, 0, 0,
F($"aggiunta {p.Adds + 1}/{maxAdds}: |z| cresciuto di {Math.Abs(e.Z) - Math.Abs(p.LastAddZ):0.00} ≥ {_cfg.GridStepZ:0.00}, moltiplicatore {scale:0.00}"));
return new BasketDecision(DecisionKind.Add, p.BuyCross, add, ["add"], add.Reason, e, null);
}
return Hold(e, F($"IN POSIZIONE — {e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD, z {e.Z:+0.00;-0.00} (entrata {p.EntryZ:+0.00;-0.00}), {p.BarsHeld}/{_cfg.MaxHoldingBars} barre"));
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static BasketDecision Skip(BasketEvaluation e, IReadOnlyList<string> codes, string why, CostGateResult? cost) =>
new(DecisionKind.Skip, false, null, codes, why, e, cost);
private static BasketDecision Exit(BasketEvaluation e, IReadOnlyList<string> codes, string why) =>
new(DecisionKind.Exit, false, null, codes, why, e, null);
private static BasketDecision Hold(BasketEvaluation e, string why) =>
new(DecisionKind.Hold, false, null, ["hold"], why, e, null);
private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
private static string Fmt(double v) => double.IsFinite(v) ? v.ToString("0", CultureInfo.InvariantCulture) : "n/d";
private static string Fmt2(double v) => double.IsFinite(v) ? v.ToString("+0.00;-0.00;0.00", CultureInfo.InvariantCulture) : "n/d";
}
@@ -0,0 +1,359 @@
using System.Globalization;
using Encelado.Core.Broker;
namespace Encelado.Core.Baskets;
/// <summary>What opening (or adding to) a basket produced.</summary>
public sealed record EntryOutcome(
bool Ok,
BasketPosition? Position,
bool Unwound,
string Error,
double SlippagePipsA,
double SlippagePipsB,
double LatencyMs);
/// <summary>What closing a basket produced.</summary>
public sealed record ExitOutcome(
bool Ok,
double ExitPriceA,
double ExitPriceB,
double RealizedPnlUsd,
double PipsTotal,
double SlippagePipsA,
double SlippagePipsB,
string Error,
DateTime ClosedUtc,
IReadOnlyList<long> StuckPositionIds);
/// <summary>
/// The two-leg execution protocol of §5.7, over any <see cref="IBroker"/>.
/// <list type="number">
/// <item>Send leg A at market and wait for its fill.</item>
/// <item>Within two seconds send leg B. If B is rejected or unconfirmed within the leg
/// timeout, close A at once and report <c>leg_risk_unwind</c>.</item>
/// <item>Every order carries a unique client reference; before resending, the venue is
/// asked what became of the reference, so nothing is ever duplicated.</item>
/// <item>On exit both legs are closed; a leg that fails is retried three times with
/// backoff and then reported as stuck.</item>
/// </list>
/// </summary>
public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config, Func<string, double?> mid, Action<string> log)
{
private readonly IBroker _broker = broker ?? throw new ArgumentNullException(nameof(broker));
private readonly BasketStrategyConfig _cfg = config ?? throw new ArgumentNullException(nameof(config));
private readonly Func<string, double?> _mid = mid ?? throw new ArgumentNullException(nameof(mid));
private readonly Action<string> _log = log ?? (static _ => { });
public async Task<EntryOutcome> OpenAsync(BasketContext ctx, BasketDecision decision, BasketPreset preset, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(decision);
if (decision.Kind != DecisionKind.Enter || decision.Sizing is not { Ok: true } sizing)
{
return new EntryOutcome(false, null, false, "nessuna decisione di ingresso", 0, 0, 0);
}
long t0 = Environment.TickCount64;
(bool buyA, bool buyB) = ctx.Cross.Legs(decision.BuyCross);
double quoteA = buyA ? ctx.A.Quote.Ask : ctx.A.Quote.Bid;
double quoteB = buyB ? ctx.B.Quote.Ask : ctx.B.Quote.Bid;
OrderRequest reqA = Request(ctx.A, buyA, sizing.UnitsA, quoteA, decision.Motivazione);
OrderOutcome a = await SendAsync(reqA, ct).ConfigureAwait(false);
if (!a.Filled)
{
return new EntryOutcome(false, null, false, $"gamba A ({ctx.A.Symbol}) non eseguita: {Describe(a)}", 0, 0, Environment.TickCount64 - t0);
}
OrderRequest reqB = Request(ctx.B, buyB, sizing.UnitsB, quoteB, decision.Motivazione);
OrderOutcome b = await SendAsync(reqB, ct).ConfigureAwait(false);
if (!b.Filled)
{
// Leg risk: A is alone in the market. Undo it now.
_log($"[{ctx.Name}] gamba B ({ctx.B.Symbol}) non eseguita ({Describe(b)}): chiudo subito la gamba A (leg_risk_unwind)");
CloseOutcome undo = await CloseLegAsync(a.PositionId, ctx.A.Instrument.Id, ct).ConfigureAwait(false);
string error = $"gamba B non eseguita: {Describe(b)}; gamba A {(undo.Closed ? "richiusa" : "NON richiusa: " + undo.Error)}";
return new EntryOutcome(false, null, undo.Closed, error, SlipPips(ctx.A, buyA, quoteA, a.FillRate), 0, Environment.TickCount64 - t0);
}
BasketLeg legA = new()
{
Symbol = ctx.A.Symbol,
InstrumentId = ctx.A.Instrument.Id,
IsBuy = buyA,
Units = a.Units > 0 ? a.Units : sizing.UnitsA,
EntryPrice = a.FillRate > 0 ? a.FillRate : quoteA,
PositionId = a.PositionId,
ClientRef = reqA.ClientRef,
OpenedUtc = a.TimeUtc,
EntryFeesUsd = a.Fees,
StopLossRate = reqA.StopLossRate ?? 0,
};
BasketLeg legB = new()
{
Symbol = ctx.B.Symbol,
InstrumentId = ctx.B.Instrument.Id,
IsBuy = buyB,
Units = b.Units > 0 ? b.Units : sizing.UnitsB,
EntryPrice = b.FillRate > 0 ? b.FillRate : quoteB,
PositionId = b.PositionId,
ClientRef = reqB.ClientRef,
OpenedUtc = b.TimeUtc,
EntryFeesUsd = b.Fees,
StopLossRate = reqB.StopLossRate ?? 0,
};
BasketPosition position = new()
{
BasketId = ctx.BasketId,
Name = ctx.Name,
BuyCross = decision.BuyCross,
A = legA,
B = legB,
OpenedUtc = ctx.TimeUtc,
EntryZ = decision.Evaluation.Z,
LastAddZ = decision.Evaluation.Z,
EntryCostPips = decision.Cost?.CostPips ?? double.NaN,
TpPips = _cfg.TpMode == TpMode.AtrMultiple && double.IsFinite(decision.Evaluation.AtrPipsA) ? Math.Max(1, _cfg.TpAtrMultiple * decision.Evaluation.AtrPipsA) : preset.TpPips,
MaxLossUsd = ctx.Equity * _cfg.MaxLossPerBasketPct / 100.0,
EquityAtEntry = ctx.Equity,
EntryMotivazione = decision.Motivazione,
};
return new EntryOutcome(true, position, false, string.Empty,
SlipPips(ctx.A, buyA, quoteA, legA.EntryPrice), SlipPips(ctx.B, buyB, quoteB, legB.EntryPrice), Environment.TickCount64 - t0);
}
/// <summary>Adds to both legs of an open basket (a new position per leg on eToro).</summary>
public async Task<EntryOutcome> AddAsync(BasketContext ctx, BasketDecision decision, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(decision);
if (ctx.Position is not { } p || decision.Kind != DecisionKind.Add || decision.Sizing is not { Ok: true } sizing)
{
return new EntryOutcome(false, null, false, "nessuna decisione di aggiunta", 0, 0, 0);
}
long t0 = Environment.TickCount64;
double quoteA = p.A.IsBuy ? ctx.A.Quote.Ask : ctx.A.Quote.Bid;
double quoteB = p.B.IsBuy ? ctx.B.Quote.Ask : ctx.B.Quote.Bid;
OrderRequest reqA = Request(ctx.A, p.A.IsBuy, sizing.UnitsA, quoteA, decision.Motivazione);
OrderOutcome a = await SendAsync(reqA, ct).ConfigureAwait(false);
if (!a.Filled)
{
return new EntryOutcome(false, p, false, $"aggiunta su A non eseguita: {Describe(a)}", 0, 0, Environment.TickCount64 - t0);
}
OrderRequest reqB = Request(ctx.B, p.B.IsBuy, sizing.UnitsB, quoteB, decision.Motivazione);
OrderOutcome b = await SendAsync(reqB, ct).ConfigureAwait(false);
if (!b.Filled)
{
_log($"[{ctx.Name}] aggiunta su B non eseguita ({Describe(b)}): richiudo l'aggiunta su A (leg_risk_unwind)");
CloseOutcome undo = await CloseLegAsync(a.PositionId, ctx.A.Instrument.Id, ct).ConfigureAwait(false);
return new EntryOutcome(false, p, undo.Closed, $"aggiunta su B non eseguita: {Describe(b)}", 0, 0, Environment.TickCount64 - t0);
}
Merge(p.A, a, sizing.UnitsA, quoteA, reqA.ClientRef);
Merge(p.B, b, sizing.UnitsB, quoteB, reqB.ClientRef);
p.Adds++;
p.LastAddZ = decision.Evaluation.Z;
return new EntryOutcome(true, p, false, string.Empty, SlipPips(ctx.A, p.A.IsBuy, quoteA, a.FillRate), SlipPips(ctx.B, p.B.IsBuy, quoteB, b.FillRate), Environment.TickCount64 - t0);
}
private static void Merge(BasketLeg leg, OrderOutcome fill, double requestedUnits, double quote, string clientRef)
{
double units = fill.Units > 0 ? fill.Units : requestedUnits;
double price = fill.FillRate > 0 ? fill.FillRate : quote;
double before = leg.TotalUnits;
leg.Adds.Add((fill.PositionId, units, price, clientRef));
leg.EntryPrice = ((leg.EntryPrice * before) + (price * units)) / (before + units);
leg.EntryFeesUsd += fill.Fees;
}
/// <summary>Closes both legs, with retries; reports what is still open when a leg refuses to close.</summary>
public async Task<ExitOutcome> CloseAsync(BasketContext ctx, BasketPosition p, string reason, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(p);
double quoteA = p.A.IsBuy ? ctx.A.Quote.Bid : ctx.A.Quote.Ask;
double quoteB = p.B.IsBuy ? ctx.B.Quote.Bid : ctx.B.Quote.Ask;
List<long> stuck = [];
double pnl = 0;
double exitA = 0, exitB = 0;
DateTime closedUtc = DateTime.UtcNow;
(double priceA, double pnlA, bool okA) = await CloseLegAllAsync(p.A, ctx.A.Instrument.Id, stuck, ct).ConfigureAwait(false);
(double priceB, double pnlB, bool okB) = await CloseLegAllAsync(p.B, ctx.B.Instrument.Id, stuck, ct).ConfigureAwait(false);
exitA = priceA > 0 ? priceA : quoteA;
exitB = priceB > 0 ? priceB : quoteB;
// Realised P&L: the venue's number when it reports one, our own otherwise.
double own = p.NetPnlUsd(exitA, exitB, _mid);
pnl = okA && okB && (pnlA != 0 || pnlB != 0) ? pnlA + pnlB - p.AccruedFeesUsd : (double.IsNaN(own) ? 0 : own);
double pips = p.PipsTotal(exitA, exitB, ctx.A.Instrument.Pip, ctx.B.Instrument.Pip);
bool ok = okA && okB;
return new ExitOutcome(ok, exitA, exitB, pnl, pips,
SlipPips(ctx.A, !p.A.IsBuy, quoteA, exitA), SlipPips(ctx.B, !p.B.IsBuy, quoteB, exitB),
ok ? string.Empty : $"gambe non chiuse: {string.Join(", ", stuck)}", closedUtc, stuck);
}
private async Task<(double Price, double Pnl, bool Ok)> CloseLegAllAsync(BasketLeg leg, long instrumentId, List<long> stuck, CancellationToken ct)
{
double weighted = 0, units = 0, pnl = 0;
bool ok = true;
foreach (long id in leg.AllPositionIds.ToList())
{
CloseOutcome c = await CloseLegAsync(id, instrumentId, ct).ConfigureAwait(false);
if (c.Closed)
{
double u = c.Units > 0 ? c.Units : (id == leg.PositionId ? leg.Units : leg.Adds.FirstOrDefault(a => a.PositionId == id).Units);
weighted += c.CloseRate * u;
units += u;
pnl += c.RealizedPnl;
}
else
{
ok = false;
stuck.Add(id);
}
}
return (units > 0 ? weighted / units : 0, pnl, ok);
}
/// <summary>Three attempts with backoff; a pending outcome is re-checked against the position list.</summary>
private async Task<CloseOutcome> CloseLegAsync(long positionId, long instrumentId, CancellationToken ct)
{
CloseOutcome last = new(false, false, 0, 0, 0, DateTime.UtcNow, 0, "non tentata");
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
last = await _broker.CloseAsync(positionId, instrumentId, ct).ConfigureAwait(false);
}
catch (BrokerException ex)
{
last = new CloseOutcome(false, false, 0, 0, 0, DateTime.UtcNow, 0, ex.Message);
}
if (last.Closed)
{
return last;
}
// Pending on the venue, or a transient failure: is the position still there?
await Task.Delay(TimeSpan.FromMilliseconds(500 * (1 << attempt)), ct).ConfigureAwait(false);
IReadOnlyList<BrokerPosition> positions;
try
{
positions = await _broker.GetPositionsAsync(ct).ConfigureAwait(false);
}
catch (BrokerException)
{
continue;
}
if (positions.All(x => x.PositionId != positionId))
{
// Gone from the account: closed by the venue (our order, or a native stop).
return new CloseOutcome(true, false, last.OrderId, last.CloseRate, last.Units, DateTime.UtcNow, last.RealizedPnl, string.Empty);
}
}
return last;
}
/// <summary>Sends one leg. On an unknown outcome the venue is asked by client reference before giving up.</summary>
private async Task<OrderOutcome> SendAsync(OrderRequest request, CancellationToken ct)
{
OrderOutcome outcome;
try
{
outcome = await _broker.OpenAsync(request, ct).ConfigureAwait(false);
}
catch (BrokerException ex)
{
outcome = new OrderOutcome(false, false, 0, 0, 0, request.Units, DateTime.UtcNow, 0, "Unknown", ex.Message);
}
if (outcome.Filled || outcome.Rejected)
{
return outcome;
}
// Idempotency: never resend; ask what became of this reference until the leg timeout.
DateTime deadline = DateTime.UtcNow.AddSeconds(_cfg.LegTimeoutSec);
while (DateTime.UtcNow < deadline)
{
await Task.Delay(500, ct).ConfigureAwait(false);
try
{
OrderOutcome? looked = await _broker.LookupOrderAsync(request.ClientRef, ct).ConfigureAwait(false);
if (looked is { Pending: false })
{
return looked;
}
}
catch (BrokerException)
{
// Try again until the deadline.
}
}
return outcome with { Error = outcome.Error.Length > 0 ? outcome.Error : $"esito sconosciuto dopo {_cfg.LegTimeoutSec} s" };
}
private OrderRequest Request(SymbolSeries s, bool isBuy, double units, double quote, string reason)
{
// The venue wants a native stop on every short and on every leveraged order: put it
// at the distance the basket's own max loss implies (as a fraction of margin, within
// the venue's bounds), which is far outside the basket stop the bot applies itself.
int leverage = ChooseLeverage(s.Instrument);
double maxPct = s.Instrument.MaxStopLossPct > 0 ? s.Instrument.MaxStopLossPct : 50;
double minPct = s.Instrument.MinStopLossPct;
double pct = Math.Clamp(Math.Min(maxPct, Math.Max(minPct + 1, 5.0 * _cfg.MaxLossPerBasketPct)), minPct + 0.5, maxPct);
double distance = quote * (pct / 100.0) / Math.Max(1, leverage);
double stop = s.Instrument.RoundPrice(isBuy ? quote - distance : quote + distance);
return new OrderRequest(Guid.NewGuid().ToString("D"), s.Instrument.Id, s.Symbol, isBuy, Math.Round(units, 2), leverage, stop, null, reason.Length > 160 ? reason[..160] : reason);
}
private int ChooseLeverage(Instrument instrument)
{
int wanted = _cfg.OrderLeverage;
if (instrument.AllowedLeverages.Length == 0)
{
return wanted;
}
int best = instrument.AllowedLeverages[0];
foreach (int l in instrument.AllowedLeverages)
{
if (l <= wanted && l > best)
{
best = l;
}
}
return best;
}
private static double SlipPips(SymbolSeries s, bool isBuy, double quote, double fill)
{
if (quote <= 0 || fill <= 0)
{
return 0;
}
return (isBuy ? fill - quote : quote - fill) / s.Instrument.Pip;
}
private static string Describe(OrderOutcome o) =>
o.Error.Length > 0 ? $"{o.Status} — {o.Error}" : o.Status;
public static string Money(double v) => v.ToString("0.00", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,297 @@
using Encelado.Core.Baskets.Data;
using Encelado.Core.Statistics;
namespace Encelado.Core.Baskets;
/// <summary>
/// The indicators of the strategy, every one written here from its definition and
/// covered by a test against a reference computation. They take spans so the callers
/// (live engine and backtest) hand over exactly the window the specification names.
/// </summary>
public static class BasketMath
{
public static double Mean(ReadOnlySpan<double> values)
{
if (values.Length == 0)
{
return double.NaN;
}
double sum = 0;
foreach (double v in values)
{
sum += v;
}
return sum / values.Length;
}
/// <summary>Sample standard deviation (n 1).</summary>
public static double StdDev(ReadOnlySpan<double> values)
{
if (values.Length < 2)
{
return double.NaN;
}
double mean = Mean(values);
double sum = 0;
foreach (double v in values)
{
double d = v - mean;
sum += d * d;
}
return Math.Sqrt(sum / (values.Length - 1));
}
/// <summary>Pearson correlation of two aligned samples. NaN when either has no variance.</summary>
public static double Correlation(ReadOnlySpan<double> x, ReadOnlySpan<double> y)
{
int n = Math.Min(x.Length, y.Length);
if (n < 3)
{
return double.NaN;
}
double mx = 0, my = 0;
for (int i = 0; i < n; i++)
{
mx += x[i];
my += y[i];
}
mx /= n;
my /= n;
double sxy = 0, sxx = 0, syy = 0;
for (int i = 0; i < n; i++)
{
double dx = x[i] - mx;
double dy = y[i] - my;
sxy += dx * dy;
sxx += dx * dx;
syy += dy * dy;
}
return sxx > 0 && syy > 0 ? sxy / Math.Sqrt(sxx * syy) : double.NaN;
}
/// <summary><c>(last SMA_W) / StdDev_W</c> over the whole span, which is the window.</summary>
public static double ZScore(ReadOnlySpan<double> window)
{
if (window.Length < 3)
{
return double.NaN;
}
double sd = StdDev(window);
return sd > 0 ? (window[^1] - Mean(window)) / sd : double.NaN;
}
/// <summary>
/// Half-life of mean reversion by OLS on <c>ΔX_t = α + λ·X_{t1}</c>: <c>ln 2 / λ</c>.
/// NaN when λ ≥ 0 (no reversion) or when the half-life exceeds the sample.
/// </summary>
/// <summary>
/// Half-life of mean reversion of a series, in bars: an OLS of Δx on x(t1) gives
/// λ, and the half-life is ln 2 / λ. NaN when the series is too short (fewer than
/// 20 points), when λ ≥ 0 (a deviation grows rather than decays), or when the
/// half-life would exceed the sample — none of those is a measurement.
/// </summary>
public static double HalfLife(ReadOnlySpan<double> series)
{
int n = series.Length;
if (n < 20)
{
return double.NaN;
}
double[] lagged = new double[n - 1];
double[] delta = new double[n - 1];
for (int i = 1; i < n; i++)
{
lagged[i - 1] = series[i - 1];
delta[i - 1] = series[i] - series[i - 1];
}
if (!Ols.FitLine(lagged, delta, out _, out double lambda) || lambda >= -1e-12)
{
return double.NaN;
}
double halfLife = -Math.Log(2) / lambda;
return halfLife >= n ? double.NaN : halfLife;
}
/// <summary>Wilder's ATR over the mid prices of the last <paramref name="period"/>+ bars, in price units.</summary>
public static double Atr(ReadOnlySpan<BidAskBar> bars, int period)
{
if (bars.Length < period + 1 || period < 1)
{
return double.NaN;
}
// Seed with the simple average of the first `period` true ranges, then smooth.
double atr = 0;
int start = bars.Length - period - 1;
for (int i = start + 1; i <= start + period; i++)
{
atr += TrueRange(bars[i], bars[i - 1]);
}
atr /= period;
return atr;
}
/// <summary>Wilder ATR with the full smoothing over a longer history (used when at least 3×period bars exist).</summary>
public static double AtrSmoothed(ReadOnlySpan<BidAskBar> bars, int period)
{
if (bars.Length < period + 1)
{
return double.NaN;
}
int first = Math.Max(1, bars.Length - (4 * period));
double atr = 0;
int seeded = 0;
for (int i = first; i < bars.Length; i++)
{
double tr = TrueRange(bars[i], bars[i - 1]);
if (seeded < period)
{
atr += tr;
seeded++;
if (seeded == period)
{
atr /= period;
}
}
else
{
atr = ((atr * (period - 1)) + tr) / period;
}
}
return seeded < period ? double.NaN : atr;
}
private static double TrueRange(in BidAskBar bar, in BidAskBar previous)
{
double hl = bar.MidHigh - bar.MidLow;
double hc = Math.Abs(bar.MidHigh - previous.MidClose);
double lc = Math.Abs(bar.MidLow - previous.MidClose);
return Math.Max(hl, Math.Max(hc, lc));
}
/// <summary>Exponentially weighted volatility of returns, span-based (α = 2/(span+1)), over the whole span.</summary>
public static double EwmaVolatility(ReadOnlySpan<double> returns, int span)
{
if (returns.Length < 2)
{
return double.NaN;
}
double alpha = 2.0 / (span + 1);
double variance = 0;
int seed = Math.Min(returns.Length, Math.Max(2, span / 4));
for (int i = 0; i < seed; i++)
{
variance += returns[i] * returns[i];
}
variance /= seed;
for (int i = seed; i < returns.Length; i++)
{
variance = ((1 - alpha) * variance) + (alpha * returns[i] * returns[i]);
}
return Math.Sqrt(variance);
}
/// <summary>
/// ADX-like trend strength (0-100) over the mid prices: Wilder's +DI/DI and the
/// smoothed DX. Needs about 3×period bars to settle; NaN before that.
/// </summary>
public static double TrendStrength(ReadOnlySpan<BidAskBar> bars, int period)
{
if (bars.Length < (2 * period) + 1)
{
return double.NaN;
}
int first = Math.Max(1, bars.Length - (6 * period));
double tr = 0, plus = 0, minus = 0, adx = double.NaN;
int count = 0;
int dxCount = 0;
double dxSum = 0;
for (int i = first; i < bars.Length; i++)
{
double upMove = bars[i].MidHigh - bars[i - 1].MidHigh;
double downMove = bars[i - 1].MidLow - bars[i].MidLow;
double plusDm = upMove > downMove && upMove > 0 ? upMove : 0;
double minusDm = downMove > upMove && downMove > 0 ? downMove : 0;
double range = TrueRange(bars[i], bars[i - 1]);
if (count < period)
{
tr += range;
plus += plusDm;
minus += minusDm;
count++;
continue;
}
tr = tr - (tr / period) + range;
plus = plus - (plus / period) + plusDm;
minus = minus - (minus / period) + minusDm;
if (tr <= 0)
{
continue;
}
double plusDi = 100 * plus / tr;
double minusDi = 100 * minus / tr;
double sum = plusDi + minusDi;
double dx = sum > 0 ? 100 * Math.Abs(plusDi - minusDi) / sum : 0;
if (dxCount < period)
{
dxSum += dx;
dxCount++;
if (dxCount == period)
{
adx = dxSum / period;
}
}
else
{
adx = ((adx * (period - 1)) + dx) / period;
}
}
return adx;
}
public static double Median(List<double> values)
{
ArgumentNullException.ThrowIfNull(values);
if (values.Count == 0)
{
return double.NaN;
}
double[] copy = [.. values];
Array.Sort(copy);
int mid = copy.Length / 2;
return copy.Length % 2 == 0 ? (copy[mid - 1] + copy[mid]) / 2 : copy[mid];
}
/// <summary>The hour of day on the unit circle, so 23:45 and 00:15 are neighbours.</summary>
public static (double Sin, double Cos) HourFeatures(DateTime utc)
{
double angle = 2 * Math.PI * (utc.Hour + (utc.Minute / 60.0)) / 24.0;
return (Math.Sin(angle), Math.Cos(angle));
}
}
@@ -0,0 +1,150 @@
using System.Globalization;
namespace Encelado.Core.Baskets;
/// <summary>The lifecycle of one basket. Transitions are checked by <see cref="BasketLifecycle"/>.</summary>
public enum BasketState
{
Idle = 0,
Entering,
Open,
Adding,
Exiting,
Closed,
Error,
}
public static class BasketLifecycle
{
public static bool CanTransition(BasketState from, BasketState to) => (from, to) switch
{
(BasketState.Idle, BasketState.Entering) => true,
(BasketState.Entering, BasketState.Open) => true,
(BasketState.Entering, BasketState.Idle) => true, // leg-risk unwind, both legs flat again
(BasketState.Entering, BasketState.Error) => true,
(BasketState.Open, BasketState.Adding) => true,
(BasketState.Adding, BasketState.Open) => true,
(BasketState.Adding, BasketState.Error) => true,
(BasketState.Open, BasketState.Exiting) => true,
(BasketState.Exiting, BasketState.Closed) => true,
(BasketState.Exiting, BasketState.Error) => true,
(BasketState.Closed, BasketState.Idle) => true,
(BasketState.Error, BasketState.Idle) => true, // after a manual/automatic reconciliation
(BasketState.Error, BasketState.Exiting) => true,
_ => from == to,
};
}
/// <summary>One leg of an open basket, as filled.</summary>
public sealed class BasketLeg
{
public required string Symbol { get; init; }
public required long InstrumentId { get; init; }
public required bool IsBuy { get; init; }
public double Units { get; set; }
/// <summary>Volume-weighted entry across the initial fill and the adds.</summary>
public double EntryPrice { get; set; }
public long PositionId { get; set; }
public string ClientRef { get; set; } = string.Empty;
public DateTime OpenedUtc { get; set; }
public double EntryFeesUsd { get; set; }
public double StopLossRate { get; set; }
/// <summary>Extra positions opened by adds on the same leg (eToro opens a new position per order).</summary>
public List<(long PositionId, double Units, double Price, string ClientRef)> Adds { get; } = [];
public IEnumerable<long> AllPositionIds
{
get
{
if (PositionId != 0)
{
yield return PositionId;
}
foreach ((long id, _, _, _) in Adds)
{
yield return id;
}
}
}
public double TotalUnits => Units + Adds.Sum(static a => a.Units);
/// <summary>Signed pips from entry at the exit price of this leg (bid for a long, ask for a short).</summary>
public double Pips(double exitPrice, double pip) => (IsBuy ? exitPrice - EntryPrice : EntryPrice - exitPrice) / pip;
}
/// <summary>An open (or opening/closing) basket: both legs plus what the decision knew at entry.</summary>
public sealed class BasketPosition
{
public required string BasketId { get; init; }
public required string Name { get; init; }
public required bool BuyCross { get; init; }
public required BasketLeg A { get; init; }
public required BasketLeg B { get; init; }
public required DateTime OpenedUtc { get; init; }
public required double EntryZ { get; init; }
public double LastAddZ { get; set; }
public int Adds { get; set; }
public int BarsHeld { get; set; }
/// <summary>Cost estimate written at entry, in pip-equivalents of leg A.</summary>
public double EntryCostPips { get; init; }
public double TpPips { get; init; }
public double MaxLossUsd { get; init; }
public double EquityAtEntry { get; init; }
public int BarsWithBrokenCorrelation { get; set; }
/// <summary>Consecutive bar closes with a spread beyond the anomaly multiple: the forced exit waits for persistence.</summary>
public int BarsWithSpreadAnomaly { get; set; }
/// <summary>Overnight and other fees accrued so far, in USD (positive = cost).</summary>
public double AccruedFeesUsd { get; set; }
public string EntryMotivazione { get; init; } = string.Empty;
/// <summary>Sum of the two legs' pips at the given exit prices — the "Pips" of the Titany screen.</summary>
public double PipsTotal(double exitA, double exitB, double pipA, double pipB) => A.Pips(exitA, pipA) + B.Pips(exitB, pipB);
/// <summary>
/// Net P&amp;L in USD at the given exit prices: both legs converted to the account
/// currency, minus entry fees and accrued overnight.
/// </summary>
public double NetPnlUsd(double exitA, double exitB, Func<string, double?> mid)
{
double pa = PipMath.LegPnlUsd(A.Symbol, A.IsBuy, A.TotalUnits, A.EntryPrice, exitA, mid);
double pb = PipMath.LegPnlUsd(B.Symbol, B.IsBuy, B.TotalUnits, B.EntryPrice, exitB, mid);
if (double.IsNaN(pa) || double.IsNaN(pb))
{
return double.NaN;
}
return pa + pb - A.EntryFeesUsd - B.EntryFeesUsd - AccruedFeesUsd;
}
public string Describe() => string.Create(CultureInfo.InvariantCulture,
$"{Name} {(BuyCross ? "compro" : "vendo")} il cross: {(A.IsBuy ? "long" : "short")} {A.TotalUnits:0.##} {A.Symbol} @ {A.EntryPrice}, {(B.IsBuy ? "long" : "short")} {B.TotalUnits:0.##} {B.Symbol} @ {B.EntryPrice}, z entrata {EntryZ:+0.00;-0.00}, {Adds} aggiunte, {BarsHeld} barre");
}
@@ -0,0 +1,593 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Encelado.Core.Baskets;
public enum SignalMode { ZScoreSynthetic = 0, PipDivergence }
public enum ExitMode { First = 0, FixedPips, ZReturn }
public enum AveragingMode { Off = 0, AddOnce, Grid }
public enum TpMode { Pips = 0, AtrMultiple }
/// <summary>What to do when two baskets carry the same synthetic cross (baskets 4 and 5 are both EURCAD).</summary>
public enum SameCrossPolicy { Exclusive = 0, Half }
public enum PresetName { Conservative = 0, Moderate, Aggressive }
/// <summary>The six numbers a style preset fixes (§5.9 of the specification).</summary>
public sealed record BasketPreset(PresetName Name, double ZIn, double RiskPerBasketPct, int MaxBaskets, double TpPips, int MaxAdds, double ZStop)
{
public string Label => Name.ToString().ToUpperInvariant();
}
public static class BasketPresets
{
public static readonly BasketPreset Conservative = new(PresetName.Conservative, 2.5, 0.25, 2, 8, 0, 3.0);
public static readonly BasketPreset Moderate = new(PresetName.Moderate, 2.0, 0.50, 3, 10, 1, 3.5);
public static readonly BasketPreset Aggressive = new(PresetName.Aggressive, 1.5, 1.00, 5, 12, 2, 4.0);
public static BasketPreset Get(PresetName name) => name switch
{
PresetName.Conservative => Conservative,
PresetName.Aggressive => Aggressive,
_ => Moderate,
};
public static bool TryParse(string? text, out PresetName name)
{
switch (text?.Trim().ToLowerInvariant())
{
case "conservative" or "conservativo" or "prudente": name = PresetName.Conservative; return true;
case "moderate" or "moderato": name = PresetName.Moderate; return true;
case "aggressive" or "aggressivo": name = PresetName.Aggressive; return true;
default: name = PresetName.Moderate; return false;
}
}
}
/// <summary>One basket: two pairs. The synthetic cross and the leg signs are derived, never configured.</summary>
public sealed class BasketDefinition
{
public string A { get; set; } = string.Empty;
public string B { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public string Note { get; set; } = string.Empty;
public string Name => $"{A}/{B}";
}
/// <summary>
/// Every parameter of the strategy, from <c>strategy.json</c>. Values not in the file take
/// the defaults written in the specification; the active preset supplies six of them and
/// can be swapped at runtime without touching open baskets.
/// </summary>
public sealed class BasketStrategyConfig
{
public PresetName Preset { get; set; } = PresetName.Moderate;
public SignalMode SignalMode { get; set; } = SignalMode.ZScoreSynthetic;
public ExitMode ExitMode { get; set; } = ExitMode.First;
public AveragingMode AveragingMode { get; set; } = AveragingMode.Off;
public TpMode TpMode { get; set; } = TpMode.Pips;
public SameCrossPolicy SameCrossPolicy { get; set; } = SameCrossPolicy.Exclusive;
/// <summary>Trade the direct cross (EURCHF, EURCAD…) instead of two legs when the venue quotes it. Off: the basket stays two legs like Titany.</summary>
public bool PreferDirectCross { get; set; }
/// <summary>Research only (falsification test 5): trade against the signal. Never set in strategy.json.</summary>
public bool InvertSignal { get; set; }
// ---- indicators ----
public int Window { get; set; } = 100;
public int WindowShort { get; set; } = 20;
public double RhoMin { get; set; } = 0.60;
public double RhoShortMin { get; set; } = 0.40;
public double HalfLifeMinBars { get; set; } = 4;
public double HalfLifeMaxBars { get; set; } = 96;
public int HalfLifeRecalcHours { get; set; } = 4;
public int AtrPeriod { get; set; } = 14;
public int EwmaSpan { get; set; } = 100;
public int TrendPeriod { get; set; } = 14;
// ---- signal ----
public double ZOut { get; set; } = 0.25;
/// <summary>PipDivergence: divergence in pips from the anchor that opens a basket.</summary>
public double DIn { get; set; } = 15;
/// <summary>PipDivergence: bars between two anchor resets.</summary>
public int AnchorBars { get; set; } = 32;
public double GridStepZ { get; set; } = 0.75;
/// <summary>Lot multiplier of each add. 1.0 always outside the backtest falsification test.</summary>
public double LotMultiplier { get; set; } = 1.0;
// ---- exits ----
public double MaxLossPerBasketPct { get; set; } = 1.5;
public double RhoBreak { get; set; } = 0.20;
public int RhoBreakBars { get; set; } = 8;
public int MaxHoldingBars { get; set; } = 96;
public double TpAtrMultiple { get; set; } = 1.0;
// ---- cost gate ----
public double CostMultiple { get; set; } = 3;
public double SpreadMedianMultiple { get; set; } = 2;
public double SpreadAnomalyMultiple { get; set; } = 3;
public double SlippagePipsPerLeg { get; set; } = 0.3;
public double OvernightPipsPerDay { get; set; } = 0.3;
// ---- calendar and time ----
public int BlackoutBeforeMin { get; set; } = 45;
public int BlackoutAfterMin { get; set; } = 30;
public int FridayCutoffUtcHour { get; set; } = 20;
public int OpenDelayMinutes { get; set; } = 30;
/// <summary>Allowed entry hours in UTC, inclusive start, exclusive end. Empty = always.</summary>
public List<(int From, int To)> Sessions { get; set; } = [];
// ---- sizing and risk ----
public double MaxEffectiveLeverage { get; set; } = 10;
public int OrderLeverage { get; set; } = 10;
public double VolScaleMin { get; set; } = 0.8;
public double VolScaleMax { get; set; } = 1.5;
public int VolAverageDays { get; set; } = 30;
public double MlMinProbability { get; set; } = 0.55;
public double EquityStopPct { get; set; } = 9;
public double DailyLossPct { get; set; } = 3;
public int LegTimeoutSec { get; set; } = 5;
public int ClockSkewMaxSeconds { get; set; } = 5;
// ---- overrides of the preset (NaN / 0 = take the preset's value) ----
public double ZInOverride { get; set; } = double.NaN;
public double RiskPerBasketPctOverride { get; set; } = double.NaN;
public int MaxBasketsOverride { get; set; }
public double TpPipsOverride { get; set; } = double.NaN;
public int MaxAddsOverride { get; set; } = -1;
public double ZStopOverride { get; set; } = double.NaN;
public List<BasketDefinition> Baskets { get; set; } = DefaultBaskets();
/// <summary>The preset in force, with overrides applied.</summary>
public BasketPreset Effective(PresetName? preset = null)
{
BasketPreset p = BasketPresets.Get(preset ?? Preset);
return p with
{
ZIn = double.IsNaN(ZInOverride) ? p.ZIn : ZInOverride,
RiskPerBasketPct = double.IsNaN(RiskPerBasketPctOverride) ? p.RiskPerBasketPct : RiskPerBasketPctOverride,
MaxBaskets = MaxBasketsOverride > 0 ? MaxBasketsOverride : p.MaxBaskets,
TpPips = double.IsNaN(TpPipsOverride) ? p.TpPips : TpPipsOverride,
MaxAdds = MaxAddsOverride >= 0 ? MaxAddsOverride : p.MaxAdds,
ZStop = double.IsNaN(ZStopOverride) ? p.ZStop : ZStopOverride,
};
}
public static List<BasketDefinition> DefaultBaskets() =>
[
new() { A = "EURUSD", B = "USDCHF", Note = "cross sintetico EURCHF" },
new() { A = "AUDUSD", B = "USDCAD", Note = "cross sintetico AUDCAD" },
new() { A = "NZDUSD", B = "EURNZD", Note = "cross sintetico EURUSD: replica EURUSD pagando due spread" },
new() { A = "USDCAD", B = "EURUSD", Note = "cross sintetico EURCAD (stessa esposizione del basket 5)" },
new() { A = "EURAUD", B = "AUDCAD", Note = "cross sintetico EURCAD (stessa esposizione del basket 4)" },
];
/// <summary>Every symbol the baskets need, in first-seen order, plus the direct crosses when asked.</summary>
public List<string> Symbols(bool includeDirectCrosses)
{
List<string> list = [];
foreach (BasketDefinition b in Baskets)
{
if (!list.Contains(b.A, StringComparer.OrdinalIgnoreCase)) { list.Add(b.A.ToUpperInvariant()); }
if (!list.Contains(b.B, StringComparer.OrdinalIgnoreCase)) { list.Add(b.B.ToUpperInvariant()); }
}
if (includeDirectCrosses)
{
foreach (BasketDefinition b in Baskets)
{
if (SyntheticCross.TryDerive(b.A, b.B, out SyntheticCross? cross) && cross is not null && !list.Contains(cross.Symbol, StringComparer.OrdinalIgnoreCase))
{
list.Add(cross.Symbol);
}
}
}
return list;
}
public void Validate()
{
if (Window is < 20 or > 2000) { throw Bad("window", "fra 20 e 2000 barre"); }
if (WindowShort is < 5 || WindowShort >= Window) { throw Bad("windowShort", "almeno 5 e minore di window"); }
if (RhoMin is < 0 or > 1 || RhoShortMin is < 0 or > 1) { throw Bad("rhoMin/rhoShortMin", "fra 0 e 1"); }
if (HalfLifeMinBars < 1 || HalfLifeMaxBars <= HalfLifeMinBars) { throw Bad("halfLife", "min ≥ 1 e max > min"); }
if (ZOut < 0) { throw Bad("zOut", "non negativo"); }
if (MaxLossPerBasketPct is <= 0 or > 20) { throw Bad("maxLossPerBasketPct", "fra 0 e 20"); }
if (MaxHoldingBars < 1) { throw Bad("maxHoldingBars", "almeno 1"); }
if (CostMultiple < 1) { throw Bad("costMultiple", "almeno 1"); }
if (MaxEffectiveLeverage is <= 0 or > 30) { throw Bad("maxEffectiveLeverage", "fra 0 e 30"); }
if (OrderLeverage is < 1 or > 30) { throw Bad("orderLeverage", "fra 1 e 30"); }
if (EquityStopPct is <= 0 or > 50) { throw Bad("equityStopPct", "fra 0 e 50"); }
if (DailyLossPct is <= 0 or > 50) { throw Bad("dailyLossPct", "fra 0 e 50"); }
if (LotMultiplier is < 1 or > 1.5) { throw Bad("lotMultiplier", "fra 1,0 e 1,5"); }
if (Baskets.Count == 0) { throw Bad("baskets", "almeno un basket"); }
foreach (BasketDefinition b in Baskets)
{
b.A = b.A.Trim().ToUpperInvariant();
b.B = b.B.Trim().ToUpperInvariant();
if (b.A.Length != 6 || b.B.Length != 6)
{
throw Bad("baskets", $"'{b.A}/{b.B}' non è una coppia di simboli a sei lettere");
}
if (!SyntheticCross.TryDerive(b.A, b.B, out _))
{
throw Bad("baskets", $"'{b.A}/{b.B}' non hanno una valuta in comune: nessun cross sintetico");
}
}
BasketPreset e = Effective();
if (e.ZIn <= e.ZOutOrZero(ZOut)) { throw Bad("zIn/zOut", "zIn deve superare zOut"); }
if (e.ZStop <= e.ZIn) { throw Bad("zStop", "deve superare zIn"); }
if (e.RiskPerBasketPct is <= 0 or > 5) { throw Bad("riskPerBasketPct", "fra 0 e 5"); }
if (e.MaxBaskets is < 1 or > 10) { throw Bad("maxBaskets", "fra 1 e 10"); }
if (e.TpPips <= 0) { throw Bad("tpPips", "positivo"); }
}
private static InvalidOperationException Bad(string key, string rule) =>
new($"strategy.json: '{key}' deve essere {rule}.");
/// <summary>SHA-256 of the canonical text of the configuration, for the ledger and the pre-registration.</summary>
public string Hash()
{
byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(Canonical()));
return Convert.ToHexString(bytes)[..16].ToLowerInvariant();
}
public string Canonical()
{
BasketPreset e = Effective();
StringBuilder sb = new();
sb.Append(CultureInfo.InvariantCulture, $"preset={Preset};zIn={e.ZIn};risk={e.RiskPerBasketPct};maxBaskets={e.MaxBaskets};tp={e.TpPips};maxAdds={e.MaxAdds};zStop={e.ZStop};");
sb.Append(CultureInfo.InvariantCulture, $"signal={SignalMode};exit={ExitMode};avg={AveragingMode};tpMode={TpMode};same={SameCrossPolicy};direct={PreferDirectCross};");
sb.Append(CultureInfo.InvariantCulture, $"W={Window};Ws={WindowShort};rho={RhoMin};rhoS={RhoShortMin};hl={HalfLifeMinBars}-{HalfLifeMaxBars};atr={AtrPeriod};ewma={EwmaSpan};");
sb.Append(CultureInfo.InvariantCulture, $"zOut={ZOut};dIn={DIn};anchor={AnchorBars};grid={GridStepZ};lotMul={LotMultiplier};maxLoss={MaxLossPerBasketPct};rhoBreak={RhoBreak}x{RhoBreakBars};hold={MaxHoldingBars};");
sb.Append(CultureInfo.InvariantCulture, $"cost={CostMultiple};spreadMed={SpreadMedianMultiple};slip={SlippagePipsPerLeg};on={OvernightPipsPerDay};blackout={BlackoutBeforeMin}/{BlackoutAfterMin};fri={FridayCutoffUtcHour};open={OpenDelayMinutes};");
sb.Append(CultureInfo.InvariantCulture, $"lev={MaxEffectiveLeverage}/{OrderLeverage};vol={VolScaleMin}-{VolScaleMax}/{VolAverageDays};pMin={MlMinProbability};eqStop={EquityStopPct};daily={DailyLossPct};");
foreach (BasketDefinition b in Baskets)
{
sb.Append(CultureInfo.InvariantCulture, $"{b.A}/{b.B}={(b.Enabled ? 1 : 0)};");
}
return sb.ToString();
}
// -----------------------------------------------------------------------
// JSON
// -----------------------------------------------------------------------
private static readonly JsonDocumentOptions ParseOptions = new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true };
public static BasketStrategyConfig Load(string path, out List<string> warnings)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
using FileStream stream = File.OpenRead(path);
using JsonDocument doc = JsonDocument.Parse(stream, ParseOptions);
return Parse(doc.RootElement, out warnings);
}
public static BasketStrategyConfig ParseText(string json, out List<string> warnings)
{
using JsonDocument doc = JsonDocument.Parse(json, ParseOptions);
return Parse(doc.RootElement, out warnings);
}
private static BasketStrategyConfig Parse(JsonElement root, out List<string> warnings)
{
warnings = [];
BasketStrategyConfig c = new();
if (root.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException("strategy.json deve contenere un oggetto.");
}
foreach (JsonProperty p in root.EnumerateObject())
{
if (p.Name.StartsWith('_'))
{
continue;
}
switch (p.Name.ToLowerInvariant())
{
case "preset": c.Preset = BasketPresets.TryParse(Str(p), out PresetName pn) ? pn : Warn(warnings, p, PresetName.Moderate); break;
case "signalmode": c.SignalMode = Enum.TryParse(Str(p), true, out SignalMode sm) ? sm : Warn(warnings, p, SignalMode.ZScoreSynthetic); break;
case "exitmode": c.ExitMode = Enum.TryParse(Str(p), true, out ExitMode em) ? em : Warn(warnings, p, ExitMode.First); break;
case "averagingmode": c.AveragingMode = Enum.TryParse(Str(p), true, out AveragingMode am) ? am : Warn(warnings, p, AveragingMode.Off); break;
case "tpmode": c.TpMode = Enum.TryParse(Str(p), true, out TpMode tm) ? tm : Warn(warnings, p, TpMode.Pips); break;
case "samecrosspolicy": c.SameCrossPolicy = Str(p).Contains("half", StringComparison.OrdinalIgnoreCase) ? SameCrossPolicy.Half : SameCrossPolicy.Exclusive; break;
case "preferdirectcross": c.PreferDirectCross = Bool(p); break;
case "invertsignal": c.InvertSignal = Bool(p); warnings.Add("invertSignal è solo per la ricerca: il bot lo ignora"); break;
case "window": c.Window = Int(p); break;
case "windowshort": c.WindowShort = Int(p); break;
case "rhomin": c.RhoMin = Num(p); break;
case "rhoshortmin": c.RhoShortMin = Num(p); break;
case "halflifeminbars": c.HalfLifeMinBars = Num(p); break;
case "halflifemaxbars": c.HalfLifeMaxBars = Num(p); break;
case "halfliferecalchours": c.HalfLifeRecalcHours = Int(p); break;
case "atrperiod": c.AtrPeriod = Int(p); break;
case "ewmaspan": c.EwmaSpan = Int(p); break;
case "trendperiod": c.TrendPeriod = Int(p); break;
case "zout": c.ZOut = Num(p); break;
case "din": c.DIn = Num(p); break;
case "anchorbars": c.AnchorBars = Int(p); break;
case "gridstepz": c.GridStepZ = Num(p); break;
case "lotmultiplier": c.LotMultiplier = Num(p); break;
case "maxlossperbasketpct": c.MaxLossPerBasketPct = Num(p); break;
case "rhobreak": c.RhoBreak = Num(p); break;
case "rhobreakbars": c.RhoBreakBars = Int(p); break;
case "maxholdingbars": c.MaxHoldingBars = Int(p); break;
case "tpatrmultiple": c.TpAtrMultiple = Num(p); break;
case "costmultiple": c.CostMultiple = Num(p); break;
case "spreadmedianmultiple": c.SpreadMedianMultiple = Num(p); break;
case "spreadanomalymultiple": c.SpreadAnomalyMultiple = Num(p); break;
case "slippagepipsperleg": c.SlippagePipsPerLeg = Num(p); break;
case "overnightpipsperday": c.OvernightPipsPerDay = Num(p); break;
case "blackoutbeforemin": c.BlackoutBeforeMin = Int(p); break;
case "blackoutaftermin": c.BlackoutAfterMin = Int(p); break;
case "fridaycutoffutchour": c.FridayCutoffUtcHour = Int(p); break;
case "opendelayminutes": c.OpenDelayMinutes = Int(p); break;
case "sessions": c.Sessions = ReadSessions(p.Value, warnings); break;
case "maxeffectiveleverage": c.MaxEffectiveLeverage = Num(p); break;
case "orderleverage": c.OrderLeverage = Int(p); break;
case "volscalemin": c.VolScaleMin = Num(p); break;
case "volscalemax": c.VolScaleMax = Num(p); break;
case "volaveragedays": c.VolAverageDays = Int(p); break;
case "mlminprobability": c.MlMinProbability = Num(p); break;
case "equitystoppct": c.EquityStopPct = Num(p); break;
case "dailylosspct": c.DailyLossPct = Num(p); break;
case "legtimeoutsec": c.LegTimeoutSec = Int(p); break;
case "clockskewmaxseconds": c.ClockSkewMaxSeconds = Int(p); break;
case "zin": c.ZInOverride = Num(p); break;
case "riskperbasketpct": c.RiskPerBasketPctOverride = Num(p); break;
case "maxbaskets": c.MaxBasketsOverride = Int(p); break;
case "tppips": c.TpPipsOverride = Num(p); break;
case "maxadds": c.MaxAddsOverride = Int(p); break;
case "zstop": c.ZStopOverride = Num(p); break;
case "baskets": c.Baskets = ReadBaskets(p.Value, warnings); break;
default: warnings.Add($"chiave sconosciuta '{p.Name}' in strategy.json"); break;
}
}
return c;
}
private static List<BasketDefinition> ReadBaskets(JsonElement e, List<string> warnings)
{
List<BasketDefinition> list = [];
if (e.ValueKind != JsonValueKind.Array)
{
warnings.Add("'baskets' deve essere un array: uso i cinque basket predefiniti");
return DefaultBaskets();
}
foreach (JsonElement item in e.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
string[] parts = (item.GetString() ?? string.Empty).Split('/');
if (parts.Length == 2)
{
list.Add(new BasketDefinition { A = parts[0], B = parts[1] });
}
continue;
}
if (item.ValueKind != JsonValueKind.Object)
{
continue;
}
BasketDefinition b = new();
foreach (JsonProperty p in item.EnumerateObject())
{
switch (p.Name.ToLowerInvariant())
{
case "a": b.A = Str(p); break;
case "b": b.B = Str(p); break;
case "enabled": b.Enabled = Bool(p); break;
case "note": b.Note = Str(p); break;
}
}
list.Add(b);
}
return list;
}
private static List<(int, int)> ReadSessions(JsonElement e, List<string> warnings)
{
List<(int, int)> list = [];
if (e.ValueKind != JsonValueKind.Array)
{
warnings.Add("'sessions' deve essere un array di \"HH-HH\" in UTC");
return list;
}
foreach (JsonElement item in e.EnumerateArray())
{
string[] parts = (item.GetString() ?? string.Empty).Split('-');
if (parts.Length == 2 && int.TryParse(parts[0], out int from) && int.TryParse(parts[1], out int to))
{
list.Add((from, to));
}
}
return list;
}
private static T Warn<T>(List<string> warnings, JsonProperty p, T fallback)
{
warnings.Add($"valore non riconosciuto per '{p.Name}': uso {fallback}");
return fallback;
}
private static string Str(JsonProperty p) => p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() ?? string.Empty : p.Value.GetRawText();
private static double Num(JsonProperty p) => p.Value.ValueKind switch
{
JsonValueKind.Number => p.Value.GetDouble(),
JsonValueKind.String when double.TryParse(p.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) => d,
_ => throw new InvalidOperationException($"strategy.json: '{p.Name}' deve essere un numero."),
};
private static int Int(JsonProperty p) => (int)Math.Round(Num(p));
private static bool Bool(JsonProperty p) => p.Value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => (p.Value.GetString() ?? string.Empty).Trim().ToLowerInvariant() is "true" or "1" or "yes" or "sì" or "si",
_ => throw new InvalidOperationException($"strategy.json: '{p.Name}' deve essere vero o falso."),
};
/// <summary>The factory <c>strategy.json</c>, verbatim (a test keeps it equal to the shipped file).</summary>
public const string DefaultJson = """
{
"_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)" }
]
}
""";
}
internal static class PresetExtensions
{
public static double ZOutOrZero(this BasketPreset preset, double zOut) => Math.Max(0, zOut);
}
@@ -0,0 +1,91 @@
using System.Globalization;
namespace Encelado.Core.Baskets;
/// <summary>What the cost gate looked at and what it decided.</summary>
public sealed record CostGateResult(
bool Passed,
double CostPips,
double SpreadPipsA,
double SpreadPipsB,
double MarkupPipsA,
double MarkupPipsB,
double OvernightPips,
double BreakEvenWinRate,
string Reason);
/// <summary>
/// The one rule that stands between the signal and the order: is the take-profit large
/// enough to pay for the round trip? Cost is expressed in pip-equivalents of leg A so
/// that it is comparable with the basket's TP in pips.
/// <para>
/// <c>cost = spread_A + spread_B·(pipValue_B/pipValue_A) + markup + commissioni +
/// overnight × giorni di detenzione massimi</c>. Entry is allowed only when
/// <c>TP ≥ CostMultiple × cost</c> and when neither spread is more than
/// <c>SpreadMedianMultiple</c> times its own 24-hour median — a widened book is not a
/// temporarily expensive opportunity, it is a different market.
/// </para>
/// <para>
/// The break-even win rate reported alongside is the honest number of §0: with a net
/// win of <c>TP cost</c> and a net loss of <c>stopDistance + cost</c>, the strategy
/// breaks even at <c>loss / (win + loss)</c> wins.
/// </para>
/// </summary>
public static class CostGate
{
public static CostGateResult Evaluate(
double spreadPipsA,
double spreadPipsB,
double pipValueA,
double pipValueB,
double markupPipsA,
double markupPipsB,
double commissionPipsA,
double overnightPipsPerDayPerLeg,
double maxHoldingDays,
double tpPips,
double stopDistancePips,
double costMultiple,
double medianSpreadPipsA,
double medianSpreadPipsB,
double medianMultiple)
{
if (!double.IsFinite(spreadPipsA) || !double.IsFinite(spreadPipsB) || spreadPipsA < 0 || spreadPipsB < 0)
{
return new CostGateResult(false, double.NaN, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, 0, double.NaN, "spread non disponibile");
}
// Leg B's pips are converted into leg A's pip-equivalents by the ratio of pip values,
// so one number can be compared with the basket TP.
double ratio = pipValueA > 0 && pipValueB > 0 ? pipValueB / pipValueA : 1;
double overnight = overnightPipsPerDayPerLeg * maxHoldingDays * (1 + ratio);
double cost = spreadPipsA + (spreadPipsB * ratio) + markupPipsA + (markupPipsB * ratio) + commissionPipsA + overnight;
double win = tpPips - cost;
double loss = stopDistancePips + cost;
double breakEven = win > 0 ? loss / (win + loss) : 1;
if (double.IsFinite(medianSpreadPipsA) && medianSpreadPipsA > 0 && spreadPipsA > medianMultiple * medianSpreadPipsA)
{
return new CostGateResult(false, cost, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, overnight, breakEven,
F($"spread di A {spreadPipsA:F1} pip oltre {medianMultiple:F1}× la mediana 24 h ({medianSpreadPipsA:F1})"));
}
if (double.IsFinite(medianSpreadPipsB) && medianSpreadPipsB > 0 && spreadPipsB > medianMultiple * medianSpreadPipsB)
{
return new CostGateResult(false, cost, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, overnight, breakEven,
F($"spread di B {spreadPipsB:F1} pip oltre {medianMultiple:F1}× la mediana 24 h ({medianSpreadPipsB:F1})"));
}
if (tpPips < costMultiple * cost)
{
return new CostGateResult(false, cost, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, overnight, breakEven,
F($"TP {tpPips:F1} pip sotto {costMultiple:F1}× il costo {cost:F1} pip (break-even {breakEven:P0})"));
}
return new CostGateResult(true, cost, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, overnight, breakEven,
F($"costo {cost:F1} pip, TP {tpPips:F1} = {tpPips / Math.Max(1e-9, cost):F1}× il costo, break-even {breakEven:P0}"));
}
private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,234 @@
using System.Globalization;
using System.Text;
namespace Encelado.Core.Baskets.Data;
/// <summary>
/// One M15 bar with both sides of the book. Forex CFDs are traded on bid/ask, not on a
/// last price: the strategy enters at the ask and exits at the bid (or the reverse), so
/// a bar that only carried a mid would hide the one cost the cost gate exists to measure.
/// <para>
/// <see cref="SpreadMean"/> is the average askbid over the ticks of the bar, in price
/// units; <see cref="Ticks"/> says how many quotes built it (zero for a bar that came
/// from the broker's candle endpoint, which has no ticks).
/// </para>
/// </summary>
public readonly record struct BidAskBar(
DateTime TimeUtc,
double BidOpen,
double BidHigh,
double BidLow,
double BidClose,
double AskOpen,
double AskHigh,
double AskLow,
double AskClose,
double SpreadMean,
int Ticks,
string Source)
{
public double MidOpen => (BidOpen + AskOpen) * 0.5;
public double MidHigh => (BidHigh + AskHigh) * 0.5;
public double MidLow => (BidLow + AskLow) * 0.5;
public double MidClose => (BidClose + AskClose) * 0.5;
/// <summary>Spread at the close, in price units.</summary>
public double SpreadClose => AskClose - BidClose;
public bool IsValid =>
BidOpen > 0 && BidHigh > 0 && BidLow > 0 && BidClose > 0 &&
AskOpen > 0 && AskHigh > 0 && AskLow > 0 && AskClose > 0 &&
BidHigh >= BidLow && AskHigh >= AskLow && AskClose >= BidClose * 0.99;
}
/// <summary>
/// The on-disk form of <see cref="BidAskBar"/>: <c>data/market/candles_&lt;SYMBOL&gt;_M15.csv</c>,
/// a <c>;</c>-separated table whose last column says where each bar came from.
/// </summary>
public static class BidAskBarCsv
{
public const string Header =
"timeUtc;bidOpen;bidHigh;bidLow;bidClose;askOpen;askHigh;askLow;askClose;spreadMean;ticks;motivazione";
private const string TimeFormat = "yyyy-MM-ddTHH:mm:ssZ";
public static string FileName(string symbol, string timeFrame = "M15") =>
$"candles_{symbol.ToUpperInvariant()}_{timeFrame}.csv";
public static string Format(in BidAskBar b)
{
StringBuilder sb = new(160);
sb.Append(b.TimeUtc.ToString(TimeFormat, CultureInfo.InvariantCulture)).Append(';');
Append(sb, b.BidOpen).Append(';');
Append(sb, b.BidHigh).Append(';');
Append(sb, b.BidLow).Append(';');
Append(sb, b.BidClose).Append(';');
Append(sb, b.AskOpen).Append(';');
Append(sb, b.AskHigh).Append(';');
Append(sb, b.AskLow).Append(';');
Append(sb, b.AskClose).Append(';');
Append(sb, b.SpreadMean).Append(';');
sb.Append(b.Ticks.ToString(CultureInfo.InvariantCulture)).Append(';');
sb.Append(b.Source.Replace(';', ',').Replace('\n', ' ').Replace('\r', ' '));
return sb.ToString();
}
private static StringBuilder Append(StringBuilder sb, double v) =>
sb.Append(v.ToString("0.#######", CultureInfo.InvariantCulture));
public static bool TryParse(string line, out BidAskBar bar)
{
bar = default;
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("timeUtc", StringComparison.Ordinal))
{
return false;
}
string[] f = line.Split(';');
if (f.Length < 12)
{
return false;
}
if (!DateTime.TryParseExact(f[0], TimeFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t))
{
return false;
}
double[] v = new double[9];
for (int i = 0; i < 9; i++)
{
if (!double.TryParse(f[i + 1], NumberStyles.Float, CultureInfo.InvariantCulture, out v[i]))
{
return false;
}
}
if (!int.TryParse(f[10], NumberStyles.Integer, CultureInfo.InvariantCulture, out int ticks))
{
return false;
}
bar = new BidAskBar(DateTime.SpecifyKind(t, DateTimeKind.Utc), v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], ticks, f[11]);
return true;
}
/// <summary>Writes the whole table atomically: to a temporary file, then moved into place.</summary>
public static void Write(string path, IEnumerable<BidAskBar> bars)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(bars);
string full = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
string temporary = full + ".tmp";
using (StreamWriter w = new(temporary, false, new UTF8Encoding(false), 1 << 16))
{
w.WriteLine(Header);
foreach (BidAskBar b in bars)
{
w.WriteLine(Format(b));
}
}
File.Move(temporary, full, overwrite: true);
}
/// <summary>Appends bars newer than the last one on disk. Returns how many were written.</summary>
public static int AppendNewer(string path, IEnumerable<BidAskBar> bars)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(bars);
DateTime last = LastTime(path) ?? DateTime.MinValue;
List<BidAskBar> fresh = [.. bars.Where(b => b.TimeUtc > last).OrderBy(static b => b.TimeUtc)];
if (fresh.Count == 0)
{
return 0;
}
string full = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
bool isNew = !File.Exists(full) || new FileInfo(full).Length == 0;
using StreamWriter w = new(full, append: true, new UTF8Encoding(false));
if (isNew)
{
w.WriteLine(Header);
}
foreach (BidAskBar b in fresh)
{
w.WriteLine(Format(b));
}
return fresh.Count;
}
/// <summary>Time of the last bar in the file, or null when the file is missing or empty.</summary>
public static DateTime? LastTime(string path)
{
if (!File.Exists(path))
{
return null;
}
// Read the tail rather than the whole file: the candle files span years.
using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
long length = fs.Length;
int take = (int)Math.Min(length, 4096);
fs.Seek(length - take, SeekOrigin.Begin);
byte[] buffer = new byte[take];
int read = fs.Read(buffer, 0, take);
string tail = Encoding.UTF8.GetString(buffer, 0, read);
string[] lines = tail.Split('\n', StringSplitOptions.RemoveEmptyEntries);
for (int i = lines.Length - 1; i >= 0; i--)
{
if (TryParse(lines[i].TrimEnd('\r'), out BidAskBar b))
{
return b.TimeUtc;
}
}
return null;
}
public static List<BidAskBar> Read(string path, DateTime? fromUtc = null, DateTime? toUtc = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
List<BidAskBar> bars = [];
if (!File.Exists(path))
{
return bars;
}
using StreamReader r = new(path, Encoding.UTF8, true, 1 << 16);
string? line;
while ((line = r.ReadLine()) is not null)
{
if (!TryParse(line, out BidAskBar b))
{
continue;
}
if (fromUtc is { } f && b.TimeUtc < f)
{
continue;
}
if (toUtc is { } t && b.TimeUtc > t)
{
break;
}
bars.Add(b);
}
return bars;
}
}
@@ -0,0 +1,264 @@
using System.Globalization;
using System.Text;
namespace Encelado.Core.Baskets.Data;
/// <summary>What the conversion of one tick file produced, for the report.</summary>
public sealed record TickConversionSummary(
string Symbol,
long TicksRead,
long TicksSkipped,
int Bars,
DateTime FirstBarUtc,
DateTime LastBarUtc,
int WeekdayGapsOverOneHour,
int SpikeBars,
double MedianSpread);
/// <summary>
/// Streams a MetaTrader 5 tick export (tab-separated <c>DATE TIME BID ASK LAST VOLUME FLAGS</c>,
/// timestamps in UTC as verified on the weekend boundaries) into bid/ask bars, one bar
/// per fixed interval, without ever holding the file in memory: the files run to eight
/// gigabytes each.
/// <para>
/// A tick may carry only one side (flag 2 = bid changed, 4 = ask changed, 6 = both). The
/// missing side keeps its last value, so the spread is always measured between two real
/// quotes and never between a quote and a zero.
/// </para>
/// </summary>
public static class TickToBars
{
/// <summary>Called with each finished bar, in time order.</summary>
public delegate void BarSink(in BidAskBar bar);
public static TickConversionSummary Convert(
string symbol,
string tickFile,
TimeSpan interval,
BarSink sink,
double pip,
Action<long>? progress = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(symbol);
ArgumentException.ThrowIfNullOrWhiteSpace(tickFile);
ArgumentNullException.ThrowIfNull(sink);
long intervalTicks = interval.Ticks;
long ticksRead = 0;
long skipped = 0;
int bars = 0;
int gaps = 0;
int spikes = 0;
DateTime first = default;
DateTime last = default;
List<double> spreadSamples = new(1 << 16);
double lastBid = 0;
double lastAsk = 0;
long bucket = long.MinValue;
BarBuilder current = default;
double previousClose = 0;
using StreamReader reader = new(tickFile, Encoding.ASCII, false, 1 << 20);
string? line = reader.ReadLine(); // header
while ((line = reader.ReadLine()) is not null)
{
ticksRead++;
if ((ticksRead & 0xFFFFF) == 0)
{
progress?.Invoke(ticksRead);
}
if (!TryParseTick(line, ref lastBid, ref lastAsk, out long time))
{
skipped++;
continue;
}
if (lastBid <= 0 || lastAsk <= 0)
{
skipped++;
continue;
}
long b = time / intervalTicks;
if (b != bucket)
{
if (bucket != long.MinValue)
{
BidAskBar done = current.Build(new DateTime(bucket * intervalTicks, DateTimeKind.Utc), "tick MT5");
Emit(done);
}
bucket = b;
current = BarBuilder.Start(lastBid, lastAsk);
}
else
{
current.Add(lastBid, lastAsk);
}
}
if (bucket != long.MinValue)
{
Emit(current.Build(new DateTime(bucket * intervalTicks, DateTimeKind.Utc), "tick MT5"));
}
spreadSamples.Sort();
double medianSpread = spreadSamples.Count > 0 ? spreadSamples[spreadSamples.Count / 2] / pip : 0;
return new TickConversionSummary(symbol, ticksRead, skipped, bars, first, last, gaps, spikes, medianSpread);
void Emit(in BidAskBar bar)
{
if (bars == 0)
{
first = bar.TimeUtc;
}
else
{
TimeSpan gap = bar.TimeUtc - last;
if (gap > TimeSpan.FromHours(1) && !IsWeekendGap(last, bar.TimeUtc))
{
gaps++;
}
if (previousClose > 0)
{
double r = Math.Log(bar.MidClose / previousClose);
if (Math.Abs(r) > 0.02)
{
spikes++;
}
}
}
last = bar.TimeUtc;
previousClose = bar.MidClose;
bars++;
if (spreadSamples.Count < 2_000_000)
{
spreadSamples.Add(bar.SpreadMean);
}
sink(bar);
}
}
/// <summary>Friday close to Sunday reopen, allowing a few hours of tolerance on either side.</summary>
private static bool IsWeekendGap(DateTime from, DateTime to) =>
from.DayOfWeek == DayOfWeek.Friday && to.DayOfWeek is DayOfWeek.Sunday or DayOfWeek.Monday && to - from < TimeSpan.FromHours(60);
/// <summary>
/// Parses <c>2018.12.12\t08:09:05.503\t0.96454\t0.96486\t\t\t6</c>. Updates only the
/// sides present on the line. Returns false for a malformed line.
/// </summary>
public static bool TryParseTick(ReadOnlySpan<char> line, ref double bid, ref double ask, out long timeTicks)
{
timeTicks = 0;
if (line.Length < 24)
{
return false;
}
// Date and time are fixed-width in the MT5 export.
if (!TryInt(line.Slice(0, 4), out int year) || !TryInt(line.Slice(5, 2), out int month) || !TryInt(line.Slice(8, 2), out int day) ||
!TryInt(line.Slice(11, 2), out int hour) || !TryInt(line.Slice(14, 2), out int minute) || !TryInt(line.Slice(17, 2), out int second))
{
return false;
}
int ms = 0;
int cursor = 20;
if (line.Length > 20 && line[19] == '.')
{
int end = line.Slice(20).IndexOf('\t');
if (end < 0)
{
return false;
}
if (!TryInt(line.Slice(20, end), out ms))
{
return false;
}
cursor = 20 + end;
}
else if (line[19] == '\t')
{
cursor = 19;
}
else
{
return false;
}
try
{
timeTicks = new DateTime(year, month, day, hour, minute, second, ms, DateTimeKind.Utc).Ticks;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
ReadOnlySpan<char> rest = line.Slice(cursor + 1);
int tab = rest.IndexOf('\t');
ReadOnlySpan<char> bidText = tab < 0 ? rest : rest.Slice(0, tab);
ReadOnlySpan<char> askText = tab < 0 ? default : rest.Slice(tab + 1);
int tab2 = askText.IndexOf('\t');
if (tab2 >= 0)
{
askText = askText.Slice(0, tab2);
}
bool any = false;
if (bidText.Length > 0 && double.TryParse(bidText, NumberStyles.Float, CultureInfo.InvariantCulture, out double b) && b > 0)
{
bid = b;
any = true;
}
if (askText.Length > 0 && double.TryParse(askText, NumberStyles.Float, CultureInfo.InvariantCulture, out double a) && a > 0)
{
ask = a;
any = true;
}
return any;
}
private static bool TryInt(ReadOnlySpan<char> s, out int value) =>
int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out value);
private struct BarBuilder
{
private double _bo, _bh, _bl, _bc, _ao, _ah, _al, _ac, _spreadSum;
private int _n;
public static BarBuilder Start(double bid, double ask)
{
BarBuilder b = default;
b._bo = b._bh = b._bl = b._bc = bid;
b._ao = b._ah = b._al = b._ac = ask;
b._spreadSum = ask - bid;
b._n = 1;
return b;
}
public void Add(double bid, double ask)
{
if (bid > _bh) { _bh = bid; }
if (bid < _bl) { _bl = bid; }
_bc = bid;
if (ask > _ah) { _ah = ask; }
if (ask < _al) { _al = ask; }
_ac = ask;
_spreadSum += ask - bid;
_n++;
}
public readonly BidAskBar Build(DateTime time, string source) =>
new(time, _bo, _bh, _bl, _bc, _ao, _ah, _al, _ac, _n > 0 ? _spreadSum / _n : 0, _n, source);
}
}
@@ -0,0 +1,54 @@
namespace Encelado.Core.Baskets;
/// <summary>
/// Who fills the orders. The bot runs unattended in every mode: it opens and closes
/// baskets by itself. The default is <see cref="Demo"/>: real orders on eToro's demo
/// account, virtual money. <see cref="Live"/> needs the configuration flag and the typed
/// phrase at start (decision D-20, 2026-09-16: no manual approval of single orders).
/// </summary>
public enum ExecutionMode
{
Backtest = 0,
Paper,
Demo,
Live,
}
public static class ExecutionModeExtensions
{
public static bool IsLive(this ExecutionMode m) => m == ExecutionMode.Live;
public static bool IsDemo(this ExecutionMode m) => m == ExecutionMode.Demo;
public static bool UsesVenue(this ExecutionMode m) => m is ExecutionMode.Demo or ExecutionMode.Live;
/// <summary>The label shown in the badge: PAPER, DEMO, LIVE or BACKTEST.</summary>
public static string Badge(this ExecutionMode m) => m switch
{
ExecutionMode.Paper => "PAPER",
ExecutionMode.Demo => "DEMO",
ExecutionMode.Live => "LIVE",
_ => "BACKTEST",
};
/// <summary><c>paper</c>, <c>demo</c>, <c>live</c> or <c>backtest</c>, for the badge colour.</summary>
public static string Kind(this ExecutionMode m) => m.Badge().ToLowerInvariant();
/// <summary>Whether <paramref name="text"/> is a mode name of a previous version (the approve/auto pairs), which still parse.</summary>
public static bool IsLegacyName(string? text) => Normalise(text) is "demoapprove" or "demoauto" or "liveapprove" or "liveauto";
public static bool TryParse(string? text, out ExecutionMode mode)
{
switch (Normalise(text))
{
case "backtest": mode = ExecutionMode.Backtest; return true;
case "paper": mode = ExecutionMode.Paper; return true;
case "demo" or "demoapprove" or "demoauto": mode = ExecutionMode.Demo; return true;
case "live" or "real" or "liveapprove" or "liveauto": mode = ExecutionMode.Live; return true;
default: mode = ExecutionMode.Demo; return false;
}
}
private static string Normalise(string? text) =>
(text ?? string.Empty).Trim().Replace("-", string.Empty, StringComparison.Ordinal).Replace("_", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
}
@@ -0,0 +1,85 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Core.Baskets.Learning;
/// <summary>
/// The feature vector of the meta-labeler, in a fixed order shared by training,
/// prediction, drift monitoring and the docs. Every value comes from the ledger row
/// written at the decision (anti look-ahead); missing values get the neutral value
/// named here, never a value computed later.
/// </summary>
public static class LearningFeatures
{
public static readonly string[] Names =
[
"z", "abs_z", "rho_W", "rho_20", "halfLife", "atrA", "atrB", "sigmaX", "ewmaVolX", "regimeTrend",
"costPips", "spreadA", "spreadB", "hourSin", "hourCos", "dow",
"minutesToNextHigh", "minutesSinceLastHigh", "surpriseLast",
"netSentDiff_1h", "netSentDiff_4h", "netSentDiff_24h", "hawkishDiff", "riskOff", "newsCount",
"volRatio", "lastNOutcomes", "buy_cross",
];
public static int Count => Names.Length;
public static double[] From(BasketContext ctx, BasketEvaluation e, bool buyCross)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(e);
double volRatio = double.IsFinite(ctx.SigmaForecast) && double.IsFinite(ctx.SigmaAverage30d) && ctx.SigmaAverage30d > 0 ? ctx.SigmaForecast / ctx.SigmaAverage30d : 1;
return
[
Or(e.Z, 0), Math.Abs(Or(e.Z, 0)), Or(e.RhoW, 0), Or(e.RhoShort, 0), Or(e.HalfLife, 96), Or(e.AtrPipsA, 0), Or(e.AtrPipsB, 0), Or(e.SigmaX, 0), Or(e.EwmaVolX, 0), Or(e.TrendStrength, 20),
Or(e.CostPips, 0), Or(e.SpreadPipsA, 0), Or(e.SpreadPipsB, 0), e.HourSin, e.HourCos, e.DayOfWeek,
Minutes(ctx.MinutesToNextHigh), Minutes(ctx.MinutesSinceLastHigh), Or(ctx.SurpriseLast, 0),
Or(ctx.NetSentimentDiff1h, 0), Or(ctx.NetSentimentDiff4h, 0), Or(ctx.NetSentimentDiff24h, 0), Or(ctx.HawkishDiff, 0), Or(ctx.RiskOff, 0), ctx.NewsCount,
volRatio, Or(ctx.LastOutcomes, 0.5), buyCross ? 1 : 0,
];
}
/// <summary>Rebuilds the vector from a ledger line (an <c>ingresso</c> row of decisions.jsonl).</summary>
public static double[]? FromLedgerLine(string jsonLine, out string basketId, out DateTime ts)
{
basketId = string.Empty;
ts = default;
using JsonDocument doc = JsonDocument.Parse(jsonLine);
JsonElement r = doc.RootElement;
if (!r.TryGetProperty("evento", out JsonElement ev) || ev.GetString() != "ingresso")
{
return null;
}
basketId = r.TryGetProperty("basket_id", out JsonElement b) ? b.GetString() ?? string.Empty : string.Empty;
if (r.TryGetProperty("ts", out JsonElement t) && DateTime.TryParse(t.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime parsed))
{
ts = parsed;
}
double z = Num(r, "z", 0);
double sf = Num(r, "sigmaForecast", double.NaN);
double sa = Num(r, "sigmaAverage30d", double.NaN);
double volRatio = double.IsFinite(sf) && double.IsFinite(sa) && sa > 0 ? sf / sa : 1;
bool buy = r.TryGetProperty("buy_cross", out JsonElement bc) && bc.ValueKind == JsonValueKind.True;
return
[
z, Math.Abs(z), Num(r, "rho_W", 0), Num(r, "rho_20", 0), Num(r, "halfLife", 96), Num(r, "atrA", 0), Num(r, "atrB", 0), Num(r, "sigmaX", 0), Num(r, "ewmaVolX", 0), Num(r, "regimeTrend", 20),
Num(r, "costPips", 0), Num(r, "spreadA", 0), Num(r, "spreadB", 0), Num(r, "hourSin", 0), Num(r, "hourCos", 0), Num(r, "dow", 0),
Minutes(Int(r, "minutesToNextHigh")), Minutes(Int(r, "minutesSinceLastHigh")), Num(r, "surpriseLast", 0),
Num(r, "netSentDiff_1h", 0), Num(r, "netSentDiff_4h", 0), Num(r, "netSentDiff_24h", 0), Num(r, "hawkishDiff", 0), Num(r, "riskOff", 0), Num(r, "newsCount", 0),
volRatio, Num(r, "lastNOutcomes", 0.5), buy ? 1 : 0,
];
}
private static double Or(double v, double fallback) => double.IsFinite(v) ? v : fallback;
private static double Minutes(int v) => v == int.MaxValue || v < 0 ? 1440 : Math.Min(1440, v);
private static double Num(JsonElement r, string name, double fallback) =>
r.TryGetProperty(name, out JsonElement p) && p.ValueKind == JsonValueKind.Number ? p.GetDouble() : fallback;
private static int Int(JsonElement r, string name) =>
r.TryGetProperty(name, out JsonElement p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : int.MaxValue;
}
/// <summary>One labelled basket for the learning stack.</summary>
public sealed record LabelledBasket(string BasketId, DateTime OpenedUtc, DateTime ClosedUtc, double[] Features, int Label, double PnlNetUsd, string Basket, string Preset, double VolRatio);
@@ -0,0 +1,429 @@
using System.Globalization;
using System.Text;
using Encelado.Core.Ml;
using Encelado.Core.Statistics;
namespace Encelado.Core.Baskets.Learning;
/// <summary>Everything the activation rule of §8.3 looks at, plus the calibration curve.</summary>
public sealed record ModelReport(
string Model,
int Rows,
double Auc,
double AucLow,
double AucHigh,
double Brier,
double LogLoss,
IReadOnlyList<CalibrationBin> Calibration,
double PnlAll,
double PnlFiltered,
int FilteredCount,
double SharpeAll,
double SharpeFiltered,
double DsrFiltered,
bool PassesActivation,
string Motivazione)
{
public string Summary => string.Create(CultureInfo.InvariantCulture,
$"{Model}: {Rows} basket, AUC {Auc:F3} [{AucLow:F3}; {AucHigh:F3}], Brier {Brier:F3}, log-loss {LogLoss:F3}; P&L tutti {PnlAll:F0} USD, filtrati ({FilteredCount}) {PnlFiltered:F0} USD, DSR {DsrFiltered:F2} → {(PassesActivation ? "ATTIVABILE" : "resta in ombra")}");
}
/// <summary>
/// Walk-forward evaluation of the meta-labelers on the ledger's labelled baskets.
/// Level 1 is online, so its walk-forward is exact by construction: each prediction
/// uses only the baskets closed before that basket opened. Level 2 is trained in
/// chronological folds with a purge/embargo of 24 h around the test fold.
/// </summary>
public static class ModelEvaluator
{
public const int MinRowsForActivation = 300;
public const double MinAuc = 0.55;
public const double MinDsr = 0.95;
public const double DeactivationAuc = 0.52;
public const int BurnIn = 30;
/// <summary>Level 1: sequential predict-then-update over the baskets, ordered by open time; the update happens only when the basket has closed.</summary>
public static (ModelReport Report, OnlineLogistic Model) EvaluateLogistic(IReadOnlyList<LabelledBasket> rows, double pMin, int seed = 42)
{
ArgumentNullException.ThrowIfNull(rows);
List<LabelledBasket> ordered = [.. rows.OrderBy(static r => r.OpenedUtc)];
OnlineLogistic model = new(LearningFeatures.Count);
double[] p = new double[ordered.Count];
// A basket's outcome becomes available at its close: updates are applied in close order,
// and a prediction for basket i uses only baskets closed before i opened.
List<(DateTime ClosedUtc, int Index)> pending = [];
for (int i = 0; i < ordered.Count; i++)
{
DateTime open = ordered[i].OpenedUtc;
foreach ((DateTime closed, int idx) in pending.Where(x => x.ClosedUtc <= open).OrderBy(static x => x.ClosedUtc).ToList())
{
model.Update(ordered[idx].Features, ordered[idx].Label);
pending.RemoveAll(x => x.Index == idx);
}
p[i] = model.Predict(ordered[i].Features);
pending.Add((ordered[i].ClosedUtc, i));
}
foreach ((_, int idx) in pending.OrderBy(static x => x.ClosedUtc))
{
model.Update(ordered[idx].Features, ordered[idx].Label);
}
return (Report("logistica", ordered, p, pMin, seed), model);
}
/// <summary>Level 2: five chronological folds, purge/embargo of 24 h, early stopping on the tail of the training rows, five seeds averaged.</summary>
public static (ModelReport Report, SmallMlp? Model) EvaluateMlp(IReadOnlyList<LabelledBasket> rows, double pMin, int folds = 5, int[]? seeds = null)
{
ArgumentNullException.ThrowIfNull(rows);
seeds ??= [42, 43, 44, 45, 46];
List<LabelledBasket> ordered = [.. rows.OrderBy(static r => r.OpenedUtc)];
int n = ordered.Count;
if (n < 60)
{
return (new ModelReport("mlp16", n, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, [], 0, 0, 0, 0, 0, 0, false, "meno di 60 basket: il challenger non è valutabile"), null);
}
double[] p = new double[n];
bool[] predicted = new bool[n];
TimeSpan embargo = TimeSpan.FromHours(24);
for (int k = 0; k < folds; k++)
{
int from = k * n / folds;
int to = (k + 1) * n / folds;
DateTime testStart = ordered[from].OpenedUtc;
DateTime testEnd = ordered[to - 1].ClosedUtc;
List<int> train = [];
for (int i = 0; i < n; i++)
{
if (i >= from && i < to)
{
continue;
}
// Purged: a training basket whose life overlaps the test window (± embargo) is dropped.
if (ordered[i].ClosedUtc >= testStart - embargo && ordered[i].OpenedUtc <= testEnd + embargo)
{
continue;
}
train.Add(i);
}
if (train.Count < 40)
{
continue;
}
double[][] preds = new double[seeds.Length][];
for (int s = 0; s < seeds.Length; s++)
{
SmallMlp m = Train(ordered, train, seeds[s]);
preds[s] = new double[to - from];
for (int i = from; i < to; i++)
{
preds[s][i - from] = m.Predict(ordered[i].Features);
}
}
for (int i = from; i < to; i++)
{
double sum = 0;
for (int s = 0; s < seeds.Length; s++)
{
sum += preds[s][i - from];
}
p[i] = sum / seeds.Length;
predicted[i] = true;
}
}
List<LabelledBasket> evaluated = [];
List<double> scores = [];
for (int i = 0; i < n; i++)
{
if (predicted[i])
{
evaluated.Add(ordered[i]);
scores.Add(p[i]);
}
}
SmallMlp final = Train(ordered, [.. Enumerable.Range(0, n)], seeds[0]);
return (Report("mlp16", evaluated, [.. scores], pMin, seeds[0], burnIn: 0), final);
}
/// <summary>Mini-batch Adam with early stopping on the last 20 % of the training rows (chronological, purged by 24 h).</summary>
public static SmallMlp Train(IReadOnlyList<LabelledBasket> ordered, IReadOnlyList<int> train, int seed, int maxEpochs = 300, int patience = 20)
{
ArgumentNullException.ThrowIfNull(ordered);
ArgumentNullException.ThrowIfNull(train);
int split = (int)(train.Count * 0.8);
DateTime watchStart = ordered[train[Math.Min(split, train.Count - 1)]].OpenedUtc;
List<int> fit = [.. train.Take(split).Where(i => ordered[i].ClosedUtc < watchStart - TimeSpan.FromHours(24))];
List<int> watch = [.. train.Skip(split)];
if (fit.Count < 20)
{
fit = [.. train];
watch = [];
}
SmallMlp model = new(LearningFeatures.Count, 16, seed);
model.FitStandardizer([.. fit.Select(i => ordered[i].Features)]);
double[][] zFit = new double[fit.Count][];
int[] yFit = new int[fit.Count];
for (int i = 0; i < fit.Count; i++)
{
zFit[i] = model.Standardize(ordered[fit[i]].Features, update: false);
yFit[i] = ordered[fit[i]].Label;
}
double[][] zWatch = [.. watch.Select(i => model.Standardize(ordered[i].Features, update: false))];
int[] yWatch = [.. watch.Select(i => ordered[i].Label)];
Random rng = new(seed);
SmallMlp best = model.Clone();
double bestLoss = double.PositiveInfinity;
int since = 0;
int batch = Math.Clamp(fit.Count / 8, 8, 64);
int[] order = [.. Enumerable.Range(0, fit.Count)];
for (int epoch = 0; epoch < maxEpochs; epoch++)
{
rng.Shuffle(order);
for (int start = 0; start < order.Length; start += batch)
{
int len = Math.Min(batch, order.Length - start);
double[][] zb = new double[len][];
int[] yb = new int[len];
for (int j = 0; j < len; j++)
{
zb[j] = zFit[order[start + j]];
yb[j] = yFit[order[start + j]];
}
model.TrainBatchRaw(zb, yb, 0.003);
}
if (zWatch.Length == 0)
{
continue;
}
double loss = 0;
for (int i = 0; i < zWatch.Length; i++)
{
double pr = Math.Clamp(model.ForwardRaw(zWatch[i]), 1e-7, 1 - 1e-7);
loss -= (yWatch[i] * Math.Log(pr)) + ((1 - yWatch[i]) * Math.Log(1 - pr));
}
loss /= zWatch.Length;
if (loss < bestLoss - 1e-6)
{
bestLoss = loss;
best.CopyWeightsFrom(model);
since = 0;
}
else if (++since >= patience)
{
break;
}
}
if (zWatch.Length > 0)
{
model.CopyWeightsFrom(best);
}
model.MarkSeen(fit.Count);
return model;
}
private static ModelReport Report(string name, IReadOnlyList<LabelledBasket> rows, double[] p, double pMin, int seed, int burnIn = BurnIn)
{
int n = rows.Count;
List<double> scores = [];
List<int> labels = [];
List<double> pnlAll = [];
List<double> pnlFiltered = [];
for (int i = burnIn; i < n; i++)
{
scores.Add(p[i]);
labels.Add(rows[i].Label);
pnlAll.Add(rows[i].PnlNetUsd);
pnlFiltered.Add(p[i] >= pMin ? rows[i].PnlNetUsd : 0);
}
if (scores.Count < 10)
{
return new ModelReport(name, n, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, [], pnlAll.Sum(), pnlFiltered.Sum(), 0, 0, 0, 0, false, $"solo {n} basket: servono almeno {MinRowsForActivation} per valutare l'attivazione");
}
double auc = Classification.Auc(scores, labels);
(double lo, double hi) = BootstrapAuc(scores, labels, 1000, seed);
double brier = Classification.Brier(scores, labels);
double logLoss = Classification.LogLoss(scores, labels);
List<CalibrationBin> calibration = Classification.Calibration(scores, labels, 10);
double sharpeAll = Performance.Sharpe(pnlAll);
double sharpeFiltered = Performance.Sharpe(pnlFiltered);
int filteredCount = pnlFiltered.Count(static v => v != 0);
// The filter is one choice among the thresholds that could have been tried: deflate accordingly.
double variance = 0.05;
double dsr = pnlFiltered.Count > 3
? Performance.DeflatedSharpe(sharpeFiltered, pnlFiltered.Count, Performance.Skewness(pnlFiltered), Performance.Kurtosis(pnlFiltered), 3, variance)
: double.NaN;
bool passes = n >= MinRowsForActivation && double.IsFinite(auc) && auc >= MinAuc && lo > 0.5 && pnlFiltered.Sum() > pnlAll.Sum() && dsr >= MinDsr;
string why = passes
? $"tutte le condizioni di §8.3 soddisfatte su {n} basket"
: string.Join("; ", new[]
{
n < MinRowsForActivation ? $"{n} basket su {MinRowsForActivation} richiesti" : null,
!(auc >= MinAuc) ? F($"AUC {auc:F3} sotto {MinAuc:F2}") : null,
!(lo > 0.5) ? F($"intervallo bootstrap dell'AUC [{lo:F3}; {hi:F3}] include 0,50") : null,
!(pnlFiltered.Sum() > pnlAll.Sum()) ? F($"il filtro non migliora il P&L ({pnlFiltered.Sum():F0} contro {pnlAll.Sum():F0} USD)") : null,
!(dsr >= MinDsr) ? F($"DSR {dsr:F2} sotto {MinDsr:F2}") : null,
}.Where(static s => s is not null));
return new ModelReport(name, n, auc, lo, hi, brier, logLoss, calibration, pnlAll.Sum(), pnlFiltered.Sum(), filteredCount, sharpeAll, sharpeFiltered, dsr, passes, why);
}
/// <summary>Percentile bootstrap (2.5 %, 97.5 %) of the AUC.</summary>
public static (double Low, double High) BootstrapAuc(IReadOnlyList<double> scores, IReadOnlyList<int> labels, int resamples, int seed)
{
ArgumentNullException.ThrowIfNull(scores);
ArgumentNullException.ThrowIfNull(labels);
int n = scores.Count;
if (n < 10)
{
return (double.NaN, double.NaN);
}
Random rng = new(seed);
List<double> aucs = new(resamples);
double[] s = new double[n];
int[] l = new int[n];
for (int b = 0; b < resamples; b++)
{
for (int i = 0; i < n; i++)
{
int j = rng.Next(n);
s[i] = scores[j];
l[i] = labels[j];
}
double a = Classification.Auc(s, l);
if (double.IsFinite(a))
{
aucs.Add(a);
}
}
if (aucs.Count < 10)
{
return (double.NaN, double.NaN);
}
aucs.Sort();
return (aucs[(int)(0.025 * (aucs.Count - 1))], aucs[(int)(0.975 * (aucs.Count - 1))]);
}
/// <summary>AUC over the last <paramref name="window"/> predictions of a running model: the deactivation guard.</summary>
public static double RollingAuc(IReadOnlyList<(double P, int Label)> recent, int window = 100)
{
ArgumentNullException.ThrowIfNull(recent);
if (recent.Count < 20)
{
return double.NaN;
}
var tail = recent.Skip(Math.Max(0, recent.Count - window)).ToList();
return Classification.Auc([.. tail.Select(static t => t.P)], [.. tail.Select(static t => t.Label)]);
}
private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
/// <summary>The calibration curve as text, for the insights file.</summary>
public static string DescribeCalibration(IReadOnlyList<CalibrationBin> bins)
{
ArgumentNullException.ThrowIfNull(bins);
StringBuilder sb = new();
foreach (CalibrationBin b in bins)
{
if (b.Count > 0)
{
sb.Append(CultureInfo.InvariantCulture, $"[{b.Lower:0.0}-{b.Upper:0.0}] n={b.Count} previsto {b.MeanPredicted:0.00} osservato {b.ObservedRate:0.00}; ");
}
}
return sb.ToString().TrimEnd(' ', ';');
}
}
/// <summary>Level 0 (§8.2): win rate and mean net P&amp;L per bucket of the features that matter, straight from the ledger.</summary>
public static class CalibrationTables
{
public sealed record Row(string Dimension, string Bucket, int Count, double WinRate, double MeanPnl, double TotalPnl)
{
public string ToCsv() => string.Create(CultureInfo.InvariantCulture,
$"{Dimension};{Bucket};{Count};{WinRate:0.000};{MeanPnl:0.00};{TotalPnl:0.00};{(Count < 20 ? "campione piccolo: indicativo" : WinRate >= 0.5 && MeanPnl > 0 ? "bucket in utile" : "bucket in perdita")}");
}
public const string Header = "dimensione;bucket;n;win_rate;pnl_medio;pnl_totale;motivazione";
public static List<Row> Build(IReadOnlyList<LabelledBasket> rows)
{
ArgumentNullException.ThrowIfNull(rows);
List<Row> result = [];
Add("abs_z", r => Bucket(Math.Abs(r.Features[0]), [1.5, 2.0, 2.5, 3.0]));
Add("rho_W", r => Bucket(r.Features[2], [-0.9, -0.8, -0.7, -0.6, 0]));
Add("ora_utc", r => $"{HourOf(r.Features[13], r.Features[14]):00}h");
Add("giorno", r => ((DayOfWeek)(int)r.Features[15]).ToString());
Add("minuti_evento", r => Bucket(r.Features[16], [60, 180, 720]));
Add("sentiment_4h", r => Bucket(r.Features[20], [-0.2, -0.05, 0.05, 0.2]));
Add("preset", r => r.Preset);
Add("basket", r => r.Basket);
Add("verso", r => r.Features[27] > 0.5 ? "compra il cross" : "vende il cross");
return result;
void Add(string dimension, Func<LabelledBasket, string> bucket)
{
foreach (IGrouping<string, LabelledBasket> g in rows.GroupBy(bucket).OrderBy(static g => g.Key, StringComparer.Ordinal))
{
int n = g.Count();
result.Add(new Row(dimension, g.Key, n, g.Average(static r => r.Label), g.Average(static r => r.PnlNetUsd), g.Sum(static r => r.PnlNetUsd)));
}
}
}
private static string Bucket(double v, double[] edges)
{
for (int i = 0; i < edges.Length; i++)
{
if (v < edges[i])
{
return i == 0 ? F($"< {edges[0]}") : F($"{edges[i - 1]} {edges[i]}");
}
}
return F($"≥ {edges[^1]}");
}
private static int HourOf(double sin, double cos)
{
double angle = Math.Atan2(sin, cos);
if (angle < 0)
{
angle += 2 * Math.PI;
}
return (int)Math.Round(angle / (2 * Math.PI) * 24) % 24;
}
private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,263 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Core.Baskets.Learning;
/// <summary>Anything that turns a feature row into a probability and learns from an outcome.</summary>
public interface IModel
{
string Name { get; }
int InputCount { get; }
/// <summary>How many labelled rows the model has seen.</summary>
int Seen { get; }
double Predict(double[] features);
void Update(double[] features, int label);
string ToJson();
}
/// <summary>
/// Running mean and variance per feature with exponential forgetting, so the
/// standardisation of a live row uses the recent distribution, never a global one
/// computed on rows that came later (which would be a leak).
/// </summary>
public sealed class RollingStandardizer
{
private readonly double[] _mean;
private readonly double[] _var;
private readonly double _alpha;
private long _n;
public RollingStandardizer(int size, double halfLifeRows = 200)
{
_mean = new double[size];
_var = new double[size];
_alpha = 1 - Math.Pow(0.5, 1.0 / Math.Max(1, halfLifeRows));
}
public int Size => _mean.Length;
public long Count => _n;
/// <summary>Standardises a row with the statistics seen so far, then updates them. NaN inputs become 0 (the mean).</summary>
public double[] Transform(double[] x, bool update)
{
ArgumentNullException.ThrowIfNull(x);
double[] z = new double[_mean.Length];
for (int i = 0; i < _mean.Length; i++)
{
double v = i < x.Length && double.IsFinite(x[i]) ? x[i] : _mean[i];
double sd = _n > 5 && _var[i] > 1e-12 ? Math.Sqrt(_var[i]) : 1;
z[i] = Math.Clamp((v - _mean[i]) / sd, -5, 5);
if (update)
{
if (_n == 0)
{
_mean[i] = v;
_var[i] = 0;
}
else
{
double delta = v - _mean[i];
_mean[i] += _alpha * delta;
_var[i] = ((1 - _alpha) * _var[i]) + (_alpha * delta * delta);
}
}
}
if (update)
{
_n++;
}
return z;
}
/// <summary>
/// Sets the statistics from a whole batch at once, for a model trained offline: the
/// rolling estimate needs a few hundred rows to settle, and standardising the first
/// rows of a training set with statistics that are still zero clips them to ±5 and
/// hands the network a distorted picture of the very rows it learns from. After the
/// fit the rolling update continues from these values.
/// </summary>
public void Fit(IReadOnlyList<double[]> rows)
{
ArgumentNullException.ThrowIfNull(rows);
if (rows.Count == 0)
{
return;
}
for (int i = 0; i < _mean.Length; i++)
{
double sum = 0;
int count = 0;
foreach (double[] r in rows)
{
if (i < r.Length && double.IsFinite(r[i]))
{
sum += r[i];
count++;
}
}
double mean = count > 0 ? sum / count : 0;
double sq = 0;
foreach (double[] r in rows)
{
if (i < r.Length && double.IsFinite(r[i]))
{
sq += (r[i] - mean) * (r[i] - mean);
}
}
_mean[i] = mean;
_var[i] = count > 1 ? sq / (count - 1) : 0;
}
_n = rows.Count;
}
public void Write(Utf8JsonWriter w)
{
ArgumentNullException.ThrowIfNull(w);
w.WriteStartObject("standardizer");
w.WriteNumber("n", _n);
w.WriteNumber("alpha", _alpha);
Arr(w, "mean", _mean);
Arr(w, "var", _var);
w.WriteEndObject();
}
public static RollingStandardizer Read(JsonElement e)
{
double[] mean = Nums(e.GetProperty("mean"));
RollingStandardizer s = new(mean.Length);
Array.Copy(mean, s._mean, mean.Length);
Array.Copy(Nums(e.GetProperty("var")), s._var, mean.Length);
s._n = e.GetProperty("n").GetInt64();
return s;
}
internal static void Arr(Utf8JsonWriter w, string name, double[] values)
{
w.WriteStartArray(name);
foreach (double v in values)
{
w.WriteNumberValue(double.IsFinite(v) ? v : 0);
}
w.WriteEndArray();
}
internal static double[] Nums(JsonElement e) => [.. e.EnumerateArray().Select(static x => x.GetDouble())];
}
/// <summary>
/// Level 1 of the learning stack (§8.3): logistic regression trained online by SGD with
/// L2, on rolling-standardised features. Starts in shadow mode: it predicts, the engine
/// records the prediction, nobody acts on it until the walk-forward gates of §8.3 pass.
/// </summary>
public sealed class OnlineLogistic : IModel
{
private readonly double[] _w;
private double _b;
private readonly double _l2;
private readonly double _lr0;
private int _seen;
private readonly RollingStandardizer _std;
public OnlineLogistic(int inputs, double learningRate = 0.01, double l2 = 1e-3, RollingStandardizer? standardizer = null)
{
_w = new double[inputs];
_lr0 = learningRate;
_l2 = l2;
_std = standardizer ?? new RollingStandardizer(inputs);
}
public string Name => "logistic";
public int InputCount => _w.Length;
public int Seen => _seen;
public double[] Weights => (double[])_w.Clone();
public double Bias => _b;
/// <summary>Learning rate with a slow decay: <c>lr₀ / (1 + n/1000)</c>.</summary>
private double LearningRate => _lr0 / (1 + (_seen / 1000.0));
public double Predict(double[] features)
{
double[] z = _std.Transform(features, update: false);
return Sigmoid(Dot(z) + _b);
}
/// <summary>One SGD step on the logistic loss with L2; standardises with the statistics before this row.</summary>
public void Update(double[] features, int label)
{
double[] z = _std.Transform(features, update: true);
double p = Sigmoid(Dot(z) + _b);
double g = p - label;
double lr = LearningRate;
for (int i = 0; i < _w.Length; i++)
{
_w[i] -= lr * ((g * z[i]) + (_l2 * _w[i]));
}
_b -= lr * g;
_seen++;
}
private double Dot(double[] z)
{
double s = 0;
for (int i = 0; i < _w.Length; i++)
{
s += _w[i] * z[i];
}
return s;
}
public static double Sigmoid(double x) => 1.0 / (1.0 + Math.Exp(-Math.Clamp(x, -40, 40)));
public string ToJson()
{
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
{
w.WriteStartObject();
w.WriteString("type", Name);
w.WriteNumber("inputs", _w.Length);
w.WriteNumber("seen", _seen);
w.WriteNumber("lr0", _lr0);
w.WriteNumber("l2", _l2);
w.WriteNumber("bias", _b);
RollingStandardizer.Arr(w, "weights", _w);
_std.Write(w);
w.WriteEndObject();
}
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
}
public static OnlineLogistic FromJson(string json)
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement r = doc.RootElement;
double[] w = RollingStandardizer.Nums(r.GetProperty("weights"));
OnlineLogistic m = new(w.Length, r.GetProperty("lr0").GetDouble(), r.GetProperty("l2").GetDouble(), RollingStandardizer.Read(r.GetProperty("standardizer")));
Array.Copy(w, m._w, w.Length);
m._b = r.GetProperty("bias").GetDouble();
m._seen = r.GetProperty("seen").GetInt32();
return m;
}
public string Describe() => string.Create(CultureInfo.InvariantCulture, $"logistica: {_seen} righe, |w| {Math.Sqrt(_w.Sum(static x => x * x)):0.000}, b {_b:+0.000;-0.000}");
}
@@ -0,0 +1,263 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Core.Baskets.Learning;
/// <summary>
/// Level 2 of the learning stack (§8.4): one hidden layer of 16 ReLU units, a sigmoid
/// output, backprop and Adam written here, trained by mini-batch on a purged split
/// with early stopping. Deliberately small: with a few hundred labelled baskets, more
/// capacity is more ways to memorise them.
/// </summary>
public sealed class SmallMlp : IModel
{
private readonly int _inputs;
private readonly int _hidden;
private readonly double[] _w1;
private readonly double[] _b1;
private readonly double[] _w2;
private double _b2;
private readonly double[] _m1, _v1, _mb1, _vb1, _m2, _v2;
private double _mb2, _vb2;
private int _step;
private int _seen;
private readonly int _seed;
private readonly RollingStandardizer _std;
public SmallMlp(int inputs, int hidden = 16, int seed = 42, RollingStandardizer? standardizer = null)
{
_inputs = inputs;
_hidden = hidden;
_seed = seed;
Random rng = new(seed);
_w1 = Init(hidden * inputs, inputs, rng);
_b1 = new double[hidden];
_w2 = Init(hidden, hidden, rng);
_m1 = new double[_w1.Length]; _v1 = new double[_w1.Length]; _mb1 = new double[hidden]; _vb1 = new double[hidden];
_m2 = new double[hidden]; _v2 = new double[hidden];
_std = standardizer ?? new RollingStandardizer(inputs);
}
public string Name => "mlp16";
public int InputCount => _inputs;
public int Seen => _seen;
public int Seed => _seed;
private static double[] Init(int size, int fanIn, Random rng)
{
double limit = Math.Sqrt(6.0 / fanIn);
double[] w = new double[size];
for (int i = 0; i < size; i++)
{
w[i] = ((rng.NextDouble() * 2) - 1) * limit;
}
return w;
}
public double Predict(double[] features) => Forward(_std.Transform(features, update: false), new double[_hidden]);
/// <summary>Raw forward pass on already standardised inputs (used by the gradient check).</summary>
public double ForwardRaw(double[] z) => Forward(z, new double[_hidden]);
private double Forward(double[] z, double[] h)
{
for (int j = 0; j < _hidden; j++)
{
double s = _b1[j];
int row = j * _inputs;
for (int i = 0; i < _inputs; i++)
{
s += _w1[row + i] * z[i];
}
h[j] = s > 0 ? s : 0;
}
double o = _b2;
for (int j = 0; j < _hidden; j++)
{
o += _w2[j] * h[j];
}
return OnlineLogistic.Sigmoid(o);
}
/// <summary>Online update: one Adam step on this row (used for the shadow model between retrains).</summary>
public void Update(double[] features, int label)
{
double[] z = _std.Transform(features, update: true);
TrainBatchRaw([z], [label], 0.001);
_seen++;
}
/// <summary>Standardises with the current statistics (updating them) — for the batch trainer.</summary>
public double[] Standardize(double[] features, bool update) => _std.Transform(features, update);
/// <summary>Sets the standardiser from the whole training set before a batch fit.</summary>
public void FitStandardizer(IReadOnlyList<double[]> rows) => _std.Fit(rows);
/// <summary>One Adam step on the mean logistic loss of the batch (inputs already standardised). Returns the loss.</summary>
public double TrainBatchRaw(double[][] z, int[] labels, double learningRate, double l2 = 1e-4)
{
ArgumentNullException.ThrowIfNull(z);
ArgumentNullException.ThrowIfNull(labels);
double[] g1 = new double[_w1.Length], gb1 = new double[_hidden], g2 = new double[_hidden];
double gb2 = 0;
double[] h = new double[_hidden];
double loss = 0;
for (int n = 0; n < z.Length; n++)
{
double p = Forward(z[n], h);
double y = labels[n];
double pc = Math.Clamp(p, 1e-7, 1 - 1e-7);
loss -= (y * Math.Log(pc)) + ((1 - y) * Math.Log(1 - pc));
double dOut = p - y; // dL/dlogit for the logistic loss
gb2 += dOut;
for (int j = 0; j < _hidden; j++)
{
g2[j] += dOut * h[j];
if (h[j] <= 0)
{
continue;
}
double dh = dOut * _w2[j];
gb1[j] += dh;
int row = j * _inputs;
double[] zn = z[n];
for (int i = 0; i < _inputs; i++)
{
g1[row + i] += dh * zn[i];
}
}
}
double scale = 1.0 / Math.Max(1, z.Length);
for (int i = 0; i < _w1.Length; i++) { g1[i] = (g1[i] * scale) + (l2 * _w1[i]); }
for (int j = 0; j < _hidden; j++) { gb1[j] *= scale; g2[j] = (g2[j] * scale) + (l2 * _w2[j]); }
gb2 *= scale;
_step++;
Adam(_w1, g1, _m1, _v1, learningRate);
Adam(_b1, gb1, _mb1, _vb1, learningRate);
Adam(_w2, g2, _m2, _v2, learningRate);
AdamScalar(ref _b2, gb2, ref _mb2, ref _vb2, learningRate);
return loss * scale;
}
/// <summary>Numerical gradient of the loss for one row with respect to one weight of layer 1 (for the gradient-check test).</summary>
public (double Analytic, double Numeric) GradientCheck(double[] z, int label, int weightIndex, double eps = 1e-5)
{
ArgumentNullException.ThrowIfNull(z);
double[] h = new double[_hidden];
// Analytic, straight from the backprop formulas.
double p = Forward(z, h);
double dOut = p - label;
int j = weightIndex / _inputs;
int i = weightIndex % _inputs;
double analytic = h[j] > 0 ? dOut * _w2[j] * z[i] : 0;
// Numeric, by central difference on the loss.
double original = _w1[weightIndex];
_w1[weightIndex] = original + eps;
double lp = Loss(Forward(z, h), label);
_w1[weightIndex] = original - eps;
double lm = Loss(Forward(z, h), label);
_w1[weightIndex] = original;
return (analytic, (lp - lm) / (2 * eps));
static double Loss(double p, int y)
{
double pc = Math.Clamp(p, 1e-9, 1 - 1e-9);
return -((y * Math.Log(pc)) + ((1 - y) * Math.Log(1 - pc)));
}
}
private void Adam(double[] w, double[] g, double[] m, double[] v, double lr)
{
const double beta1 = 0.9, beta2 = 0.999, eps = 1e-8;
double c1 = 1 - Math.Pow(beta1, _step);
double c2 = 1 - Math.Pow(beta2, _step);
for (int i = 0; i < w.Length; i++)
{
m[i] = (beta1 * m[i]) + ((1 - beta1) * g[i]);
v[i] = (beta2 * v[i]) + ((1 - beta2) * g[i] * g[i]);
w[i] -= lr * (m[i] / c1) / (Math.Sqrt(v[i] / c2) + eps);
}
}
private void AdamScalar(ref double w, double g, ref double m, ref double v, double lr)
{
const double beta1 = 0.9, beta2 = 0.999, eps = 1e-8;
double c1 = 1 - Math.Pow(beta1, _step);
double c2 = 1 - Math.Pow(beta2, _step);
m = (beta1 * m) + ((1 - beta1) * g);
v = (beta2 * v) + ((1 - beta2) * g * g);
w -= lr * (m / c1) / (Math.Sqrt(v / c2) + eps);
}
public SmallMlp Clone()
{
SmallMlp c = new(_inputs, _hidden, _seed, _std);
Array.Copy(_w1, c._w1, _w1.Length);
Array.Copy(_b1, c._b1, _b1.Length);
Array.Copy(_w2, c._w2, _w2.Length);
c._b2 = _b2;
c._seen = _seen;
return c;
}
public void CopyWeightsFrom(SmallMlp other)
{
ArgumentNullException.ThrowIfNull(other);
Array.Copy(other._w1, _w1, _w1.Length);
Array.Copy(other._b1, _b1, _b1.Length);
Array.Copy(other._w2, _w2, _w2.Length);
_b2 = other._b2;
}
public void MarkSeen(int rows) => _seen = rows;
public string ToJson()
{
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
{
w.WriteStartObject();
w.WriteString("type", Name);
w.WriteNumber("inputs", _inputs);
w.WriteNumber("hidden", _hidden);
w.WriteNumber("seed", _seed);
w.WriteNumber("seen", _seen);
w.WriteNumber("b2", _b2);
RollingStandardizer.Arr(w, "w1", _w1);
RollingStandardizer.Arr(w, "b1", _b1);
RollingStandardizer.Arr(w, "w2", _w2);
_std.Write(w);
w.WriteEndObject();
}
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
}
public static SmallMlp FromJson(string json)
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement r = doc.RootElement;
SmallMlp m = new(r.GetProperty("inputs").GetInt32(), r.GetProperty("hidden").GetInt32(), r.GetProperty("seed").GetInt32(), RollingStandardizer.Read(r.GetProperty("standardizer")));
Array.Copy(RollingStandardizer.Nums(r.GetProperty("w1")), m._w1, m._w1.Length);
Array.Copy(RollingStandardizer.Nums(r.GetProperty("b1")), m._b1, m._b1.Length);
Array.Copy(RollingStandardizer.Nums(r.GetProperty("w2")), m._w2, m._w2.Length);
m._b2 = r.GetProperty("b2").GetDouble();
m._seen = r.GetProperty("seen").GetInt32();
return m;
}
public string Describe() => string.Create(CultureInfo.InvariantCulture, $"MLP {_inputs}→{_hidden}→1, seme {_seed}, {_seen} righe");
}
@@ -0,0 +1,232 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Core.Baskets.Learning;
/// <summary>
/// Level 3 (§8.6): Thompson sampling with Beta posteriors, one arm per (preset ×
/// volatility tercile). Reward 1 when a basket closes with a net profit, 0 otherwise.
/// It proposes; the engine applies the proposal only in paper/demo, and never in live
/// until the forward test says so. Exploration is capped at 10 % of the choices.
/// <para>
/// Why a bandit and not deep RL: with a few hundred episodes a year, three arms per
/// context and a binary reward are what the data can tell apart; a value network would
/// fit the noise long before it saw a regime twice. See docs/ML_AND_LEARNING.md.
/// </para>
/// </summary>
public sealed class ThompsonBandit
{
private readonly double[,] _alpha;
private readonly double[,] _beta;
private readonly int _contexts;
private readonly Random _rng;
private int _choices;
private int _explorations;
public ThompsonBandit(int contexts = 3, int seed = 42)
{
_contexts = contexts;
_alpha = new double[contexts, 3];
_beta = new double[contexts, 3];
for (int c = 0; c < contexts; c++)
{
for (int a = 0; a < 3; a++)
{
_alpha[c, a] = 1;
_beta[c, a] = 1;
}
}
_rng = new Random(seed);
}
public double ExplorationCap { get; init; } = 0.10;
public int Choices => _choices;
/// <summary>Proposes a preset for the context. Greedy on the posterior mean, except for a capped share of sampled (exploratory) choices.</summary>
public (PresetName Preset, bool Explored, double[] Means) Propose(int context)
{
context = Math.Clamp(context, 0, _contexts - 1);
double[] means = new double[3];
for (int a = 0; a < 3; a++)
{
means[a] = _alpha[context, a] / (_alpha[context, a] + _beta[context, a]);
}
bool explore = _choices == 0 || (_explorations + 1.0) / (_choices + 1.0) <= ExplorationCap;
int best = 0;
if (explore)
{
double[] samples = new double[3];
for (int a = 0; a < 3; a++)
{
samples[a] = SampleBeta(_alpha[context, a], _beta[context, a]);
}
best = ArgMax(samples);
if (best != ArgMax(means))
{
_explorations++;
}
else
{
explore = false;
}
}
else
{
best = ArgMax(means);
}
_choices++;
return ((PresetName)best, explore, means);
}
public void Reward(int context, PresetName preset, bool success)
{
context = Math.Clamp(context, 0, _contexts - 1);
int a = (int)preset;
if (success)
{
_alpha[context, a] += 1;
}
else
{
_beta[context, a] += 1;
}
}
/// <summary>Volatility tercile of the current forecast against the recent history: 0 low, 1 mid, 2 high.</summary>
public static int VolatilityContext(double sigmaForecast, IReadOnlyList<double> history)
{
ArgumentNullException.ThrowIfNull(history);
if (!double.IsFinite(sigmaForecast) || history.Count < 9)
{
return 1;
}
double[] sorted = [.. history.Where(double.IsFinite).Order()];
if (sorted.Length < 9)
{
return 1;
}
double t1 = sorted[sorted.Length / 3];
double t2 = sorted[2 * sorted.Length / 3];
return sigmaForecast < t1 ? 0 : sigmaForecast < t2 ? 1 : 2;
}
private static int ArgMax(double[] v)
{
int best = 0;
for (int i = 1; i < v.Length; i++)
{
if (v[i] > v[best])
{
best = i;
}
}
return best;
}
/// <summary>Beta(a, b) by two Gamma draws (MarsagliaTsang).</summary>
private double SampleBeta(double a, double b)
{
double x = SampleGamma(a);
double y = SampleGamma(b);
return x + y > 0 ? x / (x + y) : 0.5;
}
private double SampleGamma(double shape)
{
if (shape < 1)
{
return SampleGamma(shape + 1) * Math.Pow(_rng.NextDouble(), 1 / shape);
}
double d = shape - (1.0 / 3);
double c = 1 / Math.Sqrt(9 * d);
while (true)
{
double x = Normal();
double v = 1 + (c * x);
if (v <= 0)
{
continue;
}
v = v * v * v;
double u = _rng.NextDouble();
if (u < 1 - (0.0331 * x * x * x * x) || Math.Log(u) < (0.5 * x * x) + (d * (1 - v + Math.Log(v))))
{
return d * v;
}
}
}
private double Normal()
{
double u1 = 1 - _rng.NextDouble();
double u2 = _rng.NextDouble();
return Math.Sqrt(-2 * Math.Log(u1)) * Math.Cos(2 * Math.PI * u2);
}
public string Describe(int context)
{
context = Math.Clamp(context, 0, _contexts - 1);
return string.Create(CultureInfo.InvariantCulture,
$"contesto vol {context}: CONS {_alpha[context, 0] - 1:0}/{_alpha[context, 0] + _beta[context, 0] - 2:0}, MOD {_alpha[context, 1] - 1:0}/{_alpha[context, 1] + _beta[context, 1] - 2:0}, AGG {_alpha[context, 2] - 1:0}/{_alpha[context, 2] + _beta[context, 2] - 2:0}; {_choices} scelte, {_explorations} esplorative");
}
public string ToJson()
{
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
{
w.WriteStartObject();
w.WriteNumber("contexts", _contexts);
w.WriteNumber("choices", _choices);
w.WriteNumber("explorations", _explorations);
w.WriteStartArray("arms");
for (int c = 0; c < _contexts; c++)
{
for (int a = 0; a < 3; a++)
{
w.WriteStartObject();
w.WriteNumber("context", c);
w.WriteNumber("arm", a);
w.WriteNumber("alpha", _alpha[c, a]);
w.WriteNumber("beta", _beta[c, a]);
w.WriteEndObject();
}
}
w.WriteEndArray();
w.WriteEndObject();
}
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
}
public static ThompsonBandit FromJson(string json, int seed = 42)
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement r = doc.RootElement;
ThompsonBandit b = new(r.GetProperty("contexts").GetInt32(), seed)
{
_choices = r.GetProperty("choices").GetInt32(),
_explorations = r.GetProperty("explorations").GetInt32(),
};
foreach (JsonElement arm in r.GetProperty("arms").EnumerateArray())
{
int c = arm.GetProperty("context").GetInt32();
int a = arm.GetProperty("arm").GetInt32();
b._alpha[c, a] = arm.GetProperty("alpha").GetDouble();
b._beta[c, a] = arm.GetProperty("beta").GetDouble();
}
return b;
}
}
@@ -0,0 +1,246 @@
using System.Globalization;
using Encelado.Core.Statistics;
namespace Encelado.Core.Baskets.Learning;
/// <summary>
/// "Forward thinking" for volatility (§8.5): the next 1-4 hours' realised volatility of
/// the synthetic cross, by an EWMA baseline and by HAR-RV on 15-minute realised
/// variance — <c>RV_{t+1} = β₀ + β_d·RV_t + β_w·mean(RV over 4 h) + β_m·mean(RV over 24 h)</c>,
/// refitted by OLS once a day. Both are scored by squared error on a rolling window and
/// the better one is used.
/// </summary>
public sealed class VolForecaster
{
private readonly int _horizonBars;
private readonly List<double> _rv = [];
private readonly List<(double Ewma, double Har, double Realised)> _scores = [];
private double[]? _beta;
private DateTime _lastFitUtc = DateTime.MinValue;
private double _ewmaVar;
private bool _ewmaSeeded;
public VolForecaster(int horizonBars = 8, double ewmaSpan = 100)
{
_horizonBars = horizonBars;
EwmaAlpha = 2.0 / (ewmaSpan + 1);
}
public double EwmaAlpha { get; }
public int Count => _rv.Count;
public string ActiveModel { get; private set; } = "EWMA";
/// <summary>Feeds one bar's squared return (the 15-minute realised variance).</summary>
public void Observe(double logReturn, DateTime nowUtc)
{
double r2 = logReturn * logReturn;
_rv.Add(r2);
if (_rv.Count > 20_000)
{
_rv.RemoveAt(0);
}
if (!_ewmaSeeded)
{
_ewmaVar = r2;
_ewmaSeeded = true;
}
else
{
_ewmaVar = ((1 - EwmaAlpha) * _ewmaVar) + (EwmaAlpha * r2);
}
// Score the forecasts made `horizon` bars ago against what happened.
if (_pending.Count > 0 && _rv.Count - _pending.Peek().At >= _horizonBars)
{
(int at, double e, double h) = _pending.Dequeue();
double realised = 0;
for (int i = at; i < Math.Min(_rv.Count, at + _horizonBars); i++)
{
realised += _rv[i];
}
realised = Math.Sqrt(realised / _horizonBars);
_scores.Add((e, h, realised));
if (_scores.Count > 500)
{
_scores.RemoveAt(0);
}
}
if (nowUtc - _lastFitUtc >= TimeSpan.FromDays(1) && _rv.Count >= 96 * 5)
{
Fit();
_lastFitUtc = nowUtc;
}
}
private readonly Queue<(int At, double Ewma, double Har)> _pending = new();
/// <summary>Per-bar volatility forecast for the next horizon (in return units per bar).</summary>
public double Forecast()
{
if (!_ewmaSeeded)
{
return double.NaN;
}
double ewma = Math.Sqrt(_ewmaVar);
double har = HarForecast();
_pending.Enqueue((_rv.Count, ewma, double.IsFinite(har) ? har : ewma));
ActiveModel = ChooseModel();
return ActiveModel == "HAR" && double.IsFinite(har) ? har : ewma;
}
private string ChooseModel()
{
if (_scores.Count < 50 || _beta is null)
{
return "EWMA";
}
double e = 0, h = 0;
foreach ((double ewma, double har, double realised) in _scores)
{
e += (ewma - realised) * (ewma - realised);
h += (har - realised) * (har - realised);
}
return h < e ? "HAR" : "EWMA";
}
private double HarForecast()
{
if (_beta is null || _rv.Count < 96)
{
return double.NaN;
}
(double d, double w, double m) = Components(_rv.Count - 1);
double variance = _beta[0] + (_beta[1] * d) + (_beta[2] * w) + (_beta[3] * m);
return variance > 0 ? Math.Sqrt(variance) : double.NaN;
}
private (double D, double W, double M) Components(int at)
{
double d = _rv[at];
double w = Mean(at - 15, at);
double m = Mean(at - 95, at);
return (d, w, m);
}
private double Mean(int from, int to)
{
from = Math.Max(0, from);
double s = 0;
int n = 0;
for (int i = from; i <= to; i++)
{
s += _rv[i];
n++;
}
return n > 0 ? s / n : 0;
}
/// <summary>OLS of the next-bar RV on the daily/weekly/monthly components (here 15 min / 4 h / 24 h).</summary>
private void Fit()
{
int n = Math.Min(_rv.Count - 97, 96 * 60);
if (n < 200)
{
return;
}
List<double[]> x = new(n);
List<double> y = new(n);
int start = _rv.Count - 1 - n;
for (int t = Math.Max(96, start); t < _rv.Count - 1; t++)
{
(double d, double w, double m) = Components(t);
x.Add([1, d, w, m]);
y.Add(_rv[t + 1]);
}
OlsFit? fit = Ols.Fit(x, y);
if (fit is not null)
{
_beta = fit.Coefficients;
}
}
/// <summary>Average forecast over the last <paramref name="days"/> days of bars (for the zIn scaling of §5.8).</summary>
public double AverageVolatility(int days)
{
int n = Math.Min(_rv.Count, days * 96);
if (n < 96)
{
return double.NaN;
}
double s = 0;
for (int i = _rv.Count - n; i < _rv.Count; i++)
{
s += _rv[i];
}
return Math.Sqrt(s / n);
}
public string Describe()
{
if (_scores.Count == 0)
{
return string.Create(CultureInfo.InvariantCulture, $"EWMA σ {Math.Sqrt(_ewmaVar):0.0000} per barra, HAR non ancora confrontabile ({_rv.Count} barre)");
}
double e = 0, h = 0;
foreach ((double ewma, double har, double realised) in _scores)
{
e += (ewma - realised) * (ewma - realised);
h += (har - realised) * (har - realised);
}
return string.Create(CultureInfo.InvariantCulture,
$"{ActiveModel} in uso; errore quadratico medio su {_scores.Count} previsioni: EWMA {Math.Sqrt(e / _scores.Count):0.00000}, HAR {Math.Sqrt(h / _scores.Count):0.00000}; σ prevista {Forecast():0.0000} per barra");
}
}
/// <summary>Population Stability Index between two samples of one feature, on ten quantile bins of the reference.</summary>
public static class Psi
{
public static double Compute(IReadOnlyList<double> reference, IReadOnlyList<double> current, int bins = 10)
{
ArgumentNullException.ThrowIfNull(reference);
ArgumentNullException.ThrowIfNull(current);
double[] r = [.. reference.Where(double.IsFinite).Order()];
double[] c = [.. current.Where(double.IsFinite)];
if (r.Length < bins * 2 || c.Length < bins)
{
return double.NaN;
}
double[] edges = new double[bins + 1];
for (int b = 0; b <= bins; b++)
{
edges[b] = r[Math.Min(r.Length - 1, (int)Math.Floor((double)b * (r.Length - 1) / bins))];
}
double psi = 0;
for (int b = 0; b < bins; b++)
{
double lo = edges[b], hi = edges[b + 1];
bool last = b == bins - 1;
double pr = r.Count(v => v >= lo && (last ? v <= hi : v < hi)) / (double)r.Length;
double pc = c.Count(v => v >= lo && (last ? v <= hi : v < hi)) / (double)c.Length;
pr = Math.Max(pr, 1e-4);
pc = Math.Max(pc, 1e-4);
psi += (pc - pr) * Math.Log(pc / pr);
}
return psi;
}
}
@@ -0,0 +1,89 @@
using System.Globalization;
namespace Encelado.Core.Baskets;
/// <summary>
/// Pips and pip values for the currency pairs in scope. Every pair the strategy trades
/// is quoted to four decimals (pip = 0.0001, prices to five), the yen crosses to two —
/// kept here so the rule exists in exactly one place.
/// <para>
/// The account currency is USD. A pip on <c>XXXUSD</c> is worth <c>0.0001 × units</c>
/// dollars directly; on <c>USDXXX</c> the pip is in XXX and has to be divided by the
/// USDXXX rate; on a cross <c>XXXYYY</c> the pip is in YYY and is converted through
/// <c>YYYUSD</c> or <c>1/USDYYY</c>, whichever is quoted.
/// </para>
/// </summary>
public static class PipMath
{
public static double Pip(string symbol) =>
symbol.EndsWith("JPY", StringComparison.OrdinalIgnoreCase) ? 0.01 : 0.0001;
public static int Digits(string symbol) =>
symbol.EndsWith("JPY", StringComparison.OrdinalIgnoreCase) ? 3 : 5;
public static string BaseCurrency(string symbol) => symbol[..3].ToUpperInvariant();
public static string QuoteCurrency(string symbol) => symbol.Substring(3, 3).ToUpperInvariant();
/// <summary>Price difference expressed in pips.</summary>
public static double ToPips(string symbol, double priceDelta) => priceDelta / Pip(symbol);
/// <summary>
/// USD value of one pip for <paramref name="units"/> of <paramref name="symbol"/>.
/// <paramref name="mid"/> resolves a pair to its mid price (null when unknown).
/// Returns NaN when the conversion pair is not available.
/// </summary>
public static double PipValueUsd(string symbol, double units, Func<string, double?> mid)
{
ArgumentException.ThrowIfNullOrWhiteSpace(symbol);
ArgumentNullException.ThrowIfNull(mid);
double pip = Pip(symbol);
string quote = QuoteCurrency(symbol);
double? factor = QuoteToUsd(quote, mid);
return factor is { } f ? pip * units * f : double.NaN;
}
/// <summary>How many USD one unit of <paramref name="currency"/> is worth right now.</summary>
public static double? QuoteToUsd(string currency, Func<string, double?> mid)
{
ArgumentNullException.ThrowIfNull(mid);
currency = currency.ToUpperInvariant();
if (currency == "USD")
{
return 1;
}
if (mid(currency + "USD") is { } direct && direct > 0)
{
return direct;
}
if (mid("USD" + currency) is { } inverse && inverse > 0)
{
return 1 / inverse;
}
return null;
}
/// <summary>
/// Unrealised result of a leg in USD: <c>units × (exit entry)</c> in quote currency,
/// signed by direction, converted at the current rate. NaN when unconvertible.
/// </summary>
public static double LegPnlUsd(string symbol, bool isBuy, double units, double entry, double exit, Func<string, double?> mid)
{
double quoteAmount = (isBuy ? 1 : -1) * units * (exit - entry);
double? f = QuoteToUsd(QuoteCurrency(symbol), mid);
return f is { } factor ? quoteAmount * factor : double.NaN;
}
/// <summary>USD notional of <paramref name="units"/> at <paramref name="price"/>.</summary>
public static double NotionalUsd(string symbol, double units, double price, Func<string, double?> mid)
{
double? f = QuoteToUsd(QuoteCurrency(symbol), mid);
return f is { } factor ? units * price * factor : double.NaN;
}
public static string FormatPips(double pips) => pips.ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,250 @@
using Encelado.Core.Baskets.Data;
using Encelado.Core.Broker;
namespace Encelado.Core.Baskets;
/// <summary>
/// One instrument's M15 history as the strategy reads it: the bars, the log mid closes,
/// the returns, the recent spreads. Also builds the current bar from live quotes so the
/// engine can decide on a bar it closed itself, before the venue's candle is available.
/// </summary>
public sealed class SymbolSeries
{
private readonly List<BidAskBar> _bars;
private readonly List<double> _logClose;
private readonly List<double> _returns;
private readonly int _capacity;
private BarBuilder? _forming;
public SymbolSeries(Instrument instrument, TimeSpan interval, int capacity = 4000)
{
ArgumentNullException.ThrowIfNull(instrument);
Instrument = instrument;
Interval = interval;
_capacity = Math.Max(500, capacity);
_bars = new List<BidAskBar>(_capacity + 16);
_logClose = new List<double>(_capacity + 16);
_returns = new List<double>(_capacity + 16);
}
public Instrument Instrument { get; }
public string Symbol => Instrument.Symbol;
public TimeSpan Interval { get; }
public int Count => _bars.Count;
public IReadOnlyList<BidAskBar> Bars => _bars;
public BidAskBar Last => _bars[^1];
public DateTime LastTimeUtc => _bars.Count > 0 ? _bars[^1].TimeUtc : DateTime.MinValue;
/// <summary>The latest quote seen, valid or not.</summary>
public QuoteSnapshot Quote { get; private set; }
/// <summary>Quote arrival time by the local clock, for the staleness check.</summary>
public DateTime QuoteSeenUtc { get; private set; }
public bool HasQuote => Quote.IsValid;
public double Mid => Quote.IsValid ? Quote.Mid : _bars.Count > 0 ? _bars[^1].MidClose : double.NaN;
/// <summary>Current spread in pips from the live quote, or from the last bar's close.</summary>
public double SpreadPips => Quote.IsValid ? Quote.Spread / Instrument.Pip : _bars.Count > 0 ? _bars[^1].SpreadClose / Instrument.Pip : double.NaN;
/// <summary>Data-quality flag raised by <see cref="Append"/> when a bar looks wrong; cleared on the next good one.</summary>
public string? QualityIssue { get; private set; }
/// <summary>Appends a closed bar in time order; older or duplicate bars are ignored. Returns whether it was added.</summary>
public bool Append(in BidAskBar bar)
{
if (_bars.Count > 0 && bar.TimeUtc <= _bars[^1].TimeUtc)
{
return false;
}
QualityIssue = null;
if (!bar.IsValid)
{
QualityIssue = "barra con prezzi non validi";
return false;
}
double lc = Math.Log(bar.MidClose);
if (_bars.Count > 0)
{
double r = lc - _logClose[^1];
TimeSpan gap = bar.TimeUtc - _bars[^1].TimeUtc;
bool weekend = _bars[^1].TimeUtc.DayOfWeek == DayOfWeek.Friday && bar.TimeUtc.DayOfWeek is DayOfWeek.Sunday or DayOfWeek.Monday;
if (gap > TimeSpan.FromHours(2) && !weekend)
{
QualityIssue = $"buco di {gap.TotalMinutes:F0} minuti prima di {bar.TimeUtc:HH:mm}";
}
// A one-bar move beyond eight sigmas of the recent returns is a spike, not a price.
if (_returns.Count >= 50)
{
double sd = BasketMath.StdDev(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_returns)[^Math.Min(200, _returns.Count)..]);
if (sd > 0 && Math.Abs(r) > 8 * sd && Math.Abs(r) > 0.004)
{
QualityIssue = $"salto di {r * 100:F2} % in una barra ({Math.Abs(r) / sd:F0} σ)";
}
}
_returns.Add(r);
}
else
{
_returns.Add(0);
}
_bars.Add(bar);
_logClose.Add(lc);
Trim();
return true;
}
private void Trim()
{
if (_bars.Count <= _capacity)
{
return;
}
int remove = _bars.Count - _capacity;
_bars.RemoveRange(0, remove);
_logClose.RemoveRange(0, remove);
_returns.RemoveRange(0, remove);
}
// -----------------------------------------------------------------------
// Windows, most recent last
// -----------------------------------------------------------------------
public ReadOnlySpan<double> LogCloses(int count) =>
System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_logClose)[^Math.Min(count, _logClose.Count)..];
/// <summary>The last <paramref name="count"/> log returns (the first bar's return is zero and is never in a window of interest).</summary>
public ReadOnlySpan<double> Returns(int count) =>
System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_returns)[^Math.Min(count, _returns.Count)..];
public ReadOnlySpan<BidAskBar> LastBars(int count) =>
System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_bars)[^Math.Min(count, _bars.Count)..];
public double AtrPips(int period)
{
double atr = BasketMath.AtrSmoothed(LastBars(4 * period + 1), period);
return atr / Instrument.Pip;
}
public double EwmaVolatility(int span) => BasketMath.EwmaVolatility(Returns(4 * span), span);
/// <summary>Median spread in pips over the last 24 hours of bars (96 M15 bars), from the per-bar mean spreads.</summary>
public double SpreadMedianPips24h()
{
int n = (int)(TimeSpan.FromHours(24).Ticks / Interval.Ticks);
ReadOnlySpan<BidAskBar> bars = LastBars(n);
List<double> samples = new(bars.Length);
foreach (BidAskBar b in bars)
{
if (b.SpreadMean > 0)
{
samples.Add(b.SpreadMean / Instrument.Pip);
}
}
return samples.Count >= 8 ? BasketMath.Median(samples) : double.NaN;
}
// -----------------------------------------------------------------------
// Live bar building
// -----------------------------------------------------------------------
/// <summary>
/// Feeds one quote. When the quote falls in a new interval the bar that was forming is
/// closed and returned, so the caller can append it and decide on it.
/// </summary>
public BidAskBar? OnQuote(in QuoteSnapshot quote, DateTime nowUtc)
{
QuoteSeenUtc = nowUtc;
if (!quote.IsValid)
{
return null;
}
Quote = quote;
DateTime t = quote.TimeUtc == default ? nowUtc : quote.TimeUtc;
long bucket = t.Ticks / Interval.Ticks;
if (_forming is { } f)
{
if (f.Bucket == bucket)
{
f.Add(quote);
return null;
}
BidAskBar closed = f.Build(Interval, "barra locale dalle quotazioni");
_forming = new BarBuilder(bucket, quote);
return closed;
}
_forming = new BarBuilder(bucket, quote);
return null;
}
/// <summary>Forces the forming bar to close (used when the clock passes the boundary without a new quote).</summary>
public BidAskBar? CloseFormingBar(DateTime nowUtc)
{
if (_forming is not { } f)
{
return null;
}
long current = nowUtc.Ticks / Interval.Ticks;
if (current <= f.Bucket)
{
return null;
}
BidAskBar closed = f.Build(Interval, "barra locale dalle quotazioni");
_forming = null;
return closed;
}
/// <summary>Start of the bar currently forming, or null.</summary>
public DateTime? FormingBarStartUtc => _forming is { } f ? new DateTime(f.Bucket * Interval.Ticks, DateTimeKind.Utc) : null;
private sealed class BarBuilder
{
public readonly long Bucket;
private double _bo, _bh, _bl, _bc, _ao, _ah, _al, _ac, _spread;
private int _n;
public BarBuilder(long bucket, in QuoteSnapshot q)
{
Bucket = bucket;
_bo = _bh = _bl = _bc = q.Bid;
_ao = _ah = _al = _ac = q.Ask;
_spread = q.Spread;
_n = 1;
}
public void Add(in QuoteSnapshot q)
{
if (q.Bid > _bh) { _bh = q.Bid; }
if (q.Bid < _bl) { _bl = q.Bid; }
_bc = q.Bid;
if (q.Ask > _ah) { _ah = q.Ask; }
if (q.Ask < _al) { _al = q.Ask; }
_ac = q.Ask;
_spread += q.Spread;
_n++;
}
public BidAskBar Build(TimeSpan interval, string source) =>
new(new DateTime(Bucket * interval.Ticks, DateTimeKind.Utc), _bo, _bh, _bl, _bc, _ao, _ah, _al, _ac, _spread / _n, _n, source);
}
}
@@ -0,0 +1,82 @@
using System.Globalization;
namespace Encelado.Core.Baskets;
/// <summary>
/// The cross two pairs make when their common currency cancels. Everything about a
/// basket follows from the two symbols: which currency is shared, whether it plays the
/// same role in both (both quote, both base) or opposite roles, and therefore whether
/// the synthetic log price is <c>ln A + ln B</c> or <c>ln A ln B</c> and whether the
/// legs are traded in the same or opposite direction.
/// <para>
/// Same role → returns correlate positively, <c>X = ln A ln B</c>, legs opposite.
/// Opposite roles → returns correlate negatively, <c>X = ln A + ln B</c>, legs the same way.
/// All five baskets of the specification fall in the second case.
/// </para>
/// </summary>
public sealed record SyntheticCross(
string A,
string B,
string Common,
string Symbol,
int SignB,
bool SameDirectionLegs,
double ExpectedCorrelationSign)
{
/// <summary><c>ln A + SignB·ln B</c>.</summary>
public double Value(double priceA, double priceB) => Math.Log(priceA) + (SignB * Math.Log(priceB));
/// <summary>Direction of each leg when the synthetic cross is bought (+1) or sold (1).</summary>
public (bool BuyA, bool BuyB) Legs(bool buyCross) => (buyCross, SignB > 0 ? buyCross : !buyCross);
public string Describe() => string.Create(CultureInfo.InvariantCulture,
$"{A}/{B}: comune {Common}, X = ln {A} {(SignB > 0 ? "+" : "")} ln {B} = ln {Symbol}, correlazione attesa {(ExpectedCorrelationSign > 0 ? "positiva" : "negativa")}, gambe {(SameDirectionLegs ? "nello stesso verso" : "in verso opposto")}");
public static bool TryDerive(string a, string b, out SyntheticCross? cross)
{
cross = null;
if (a is null || b is null || a.Length != 6 || b.Length != 6)
{
return false;
}
a = a.ToUpperInvariant();
b = b.ToUpperInvariant();
string aBase = a[..3], aQuote = a[3..];
string bBase = b[..3], bQuote = b[3..];
// Same role in both pairs: X = ln A ln B, legs opposite.
if (aQuote == bQuote && aBase != bBase)
{
cross = new SyntheticCross(a, b, aQuote, aBase + bBase, -1, false, +1);
return true;
}
if (aBase == bBase && aQuote != bQuote)
{
cross = new SyntheticCross(a, b, aBase, bQuote + aQuote, -1, false, +1);
return true;
}
// Opposite roles: X = ln A + ln B, legs the same way.
if (aQuote == bBase && aBase != bQuote)
{
cross = new SyntheticCross(a, b, aQuote, aBase + bQuote, +1, true, -1);
return true;
}
if (aBase == bQuote && aQuote != bBase)
{
cross = new SyntheticCross(a, b, aBase, bBase + aQuote, +1, true, -1);
return true;
}
return false;
}
public static SyntheticCross Derive(string a, string b) =>
TryDerive(a, b, out SyntheticCross? c) ? c! : throw new ArgumentException($"{a}/{b} non condividono una valuta");
/// <summary>The two currencies that are not shared: the ones the sentiment features compare.</summary>
public (string Long, string Short) Exposure() => (Symbol[..3], Symbol[3..]);
}

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