Compare commits
9
Commits
61f1e59964
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c3c75647 | ||
|
|
4c26fd3209 | ||
|
|
b39e08b15c | ||
|
|
a4e297f77a | ||
|
|
9453e20cfc | ||
|
|
e080a0e867 | ||
|
|
2b4c53ec70 | ||
|
|
ebc391eadd | ||
|
|
f96ed670ca |
@@ -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/
|
||||
Vendored
+12
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+18
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+142
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Cronologia
|
||||
|
||||
Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto.
|
||||
|
||||
## 2026-09-16 (pomeriggio) — 4.0.0: solo Correlation Baskets su eToro, bot autonomo, interfaccia nuova
|
||||
|
||||
- **Rimossi** i motori precedenti: Binance, Alpaca, cTrader/proba, SQLite, GBDT, RL, TA-Lib, indicatori, backtest a coppie, pagine e test relativi (ADR-0004). Nessun pacchetto NuGet nell'applicazione.
|
||||
- **Modalità** ridotte a `Paper`, `Demo` (default), `Live`: nessuna approvazione manuale dei singoli ordini (decisione dell'utente, ADR-0005). I nomi precedenti vengono letti con un avviso.
|
||||
- **Interfaccia** rifatta: barra in alto con tre schede, stato, ambiente, ora e AVVIA; dashboard con equity, P&L di oggi, P&L aperto, drawdown, basket aperti, tabella dei basket, contesto e attività. Tema nuovo. Test di rendering in PNG.
|
||||
- **Fuso orario** della finestra selezionabile (`ui.timeZone`); il log porta l'offset, il ledger resta UTC.
|
||||
- **Corretto** il parser dei costi di eToro (campo `value`): markup e overnight non erano letti.
|
||||
- **Apprendimento** collegato al motore: modello in ombra, challenger, bandit, previsione di volatilità, ciclo settimanale, `knowledge/`. Standardizzatore dell'MLP adattato all'insieme di addestramento.
|
||||
- **Backtest**: test di falsificazione 5 (segnale invertito), scenario di costi `api`, `docs/STRATEGY.md` con il verdetto negativo e i numeri.
|
||||
- Feed: dopo due errori consecutivi una fonte logga solo a debug e ritenta con attese crescenti.
|
||||
- Documenti nuovi: `STRATEGY.md`, `ML_AND_LEARNING.md`, `RUNBOOK.md`, `GLOSSARY.md`, `KNOWN_ISSUES.md`, ADR-0004, ADR-0005. Catena di rilascio aggiornata ai tre comandi dello strumento.
|
||||
- Versione 4.0.0.
|
||||
|
||||
## 2026-09-16 (mattina) — Correlation Baskets su eToro, Fasi 0-7
|
||||
|
||||
- Ricognizione del repository; verifica dell'API eToro (rotte, quote, schemi, limiti), degli strumenti, della valuta del conto, dei feed, del formato dei tick.
|
||||
- Broker eToro (`Encelado.Etoro`), `PaperBroker`, chiavi DPAPI, `--headless`; cross sintetici, indicatori, decisore, cost gate, sizing, esecutore con protocollo leg-risk, backtest event-driven e griglia con PSR/DSR/PBO/walk-forward; calendario, RSS, sentiment, ledger; livelli di apprendimento 0-3 nel Core.
|
||||
- Documenti: `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/QUESTIONS.md`, `docs/STATE.md`, `DATA_SOURCES.md`, `LEDGER_SCHEMA.md`, `RISK_RULES.md`, ADR-0001 (eToro), ADR-0002 (storage su file), ADR-0003 (motore cTrader mantenuto selezionabile; superata da ADR-0004).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Encelado — guida per chi lavora sul repository (umano o AI)
|
||||
|
||||
**Leggi prima `docs/STATE.md`.** È la memoria di lavoro fra una sessione e l'altra: dice a che fase siamo, cosa è stato fatto per ultimo e cosa manca.
|
||||
|
||||
## Scopo
|
||||
|
||||
Bot di trading in C# (.NET 10, WPF) su **eToro** con la strategia "Correlation Baskets": cinque basket di due coppie forex correlate, ingresso quando il cross sintetico diverge (z-score), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale, ledger completo, feed gratuiti di calendario e notizie, livelli di apprendimento 0-3 costruiti da zero. È l'unica strategia del repository: i motori precedenti (Binance, cTrader/proba, ricerca) sono stati rimossi il 2026-09-16 (ADR-0004) e vivono solo nella storia git. Il bot opera da solo in ogni modalità (ADR-0005): `Paper`, `Demo` (default), `Live`.
|
||||
|
||||
## Mappa dei documenti
|
||||
|
||||
| File | Contenuto |
|
||||
|---|---|
|
||||
| `docs/STATE.md` | stato corrente, fase, ultima sessione, prossimi passi, problemi aperti |
|
||||
| `docs/ARCHITECTURE.md` | progetti, flusso dati, macchine a stati, interfacce |
|
||||
| `docs/STRATEGY.md` | logica dei basket, cross sintetici, formule, preset, aspettative oneste, numeri |
|
||||
| `docs/ML_AND_LEARNING.md` | livelli 0-3, feature, label, addestramento, attivazione, esclusioni |
|
||||
| `docs/DATA_SOURCES.md` | ogni fonte (URL, formato, limiti), schema dei file in `data/` |
|
||||
| `docs/LEDGER_SCHEMA.md` | schema di `decisions.jsonl`, `baskets.csv`, `trials.csv`, `calibration.csv`, `preregistrazione.csv`, `proposals.csv` |
|
||||
| `docs/RISK_RULES.md` | regole di sicurezza con i default e chi può cambiarle |
|
||||
| `docs/RUNBOOK.md` | avvio, arresto, kill-switch, reset, riconciliazione, chiavi, errori API, checklist |
|
||||
| `docs/QUESTIONS.md` | domande poste per fase, risposte o default applicati, con data |
|
||||
| `docs/GLOSSARY.md`, `docs/KNOWN_ISSUES.md`, `CHANGELOG.md`, `docs/adr/` | glossario, problemi noti, cronologia, decisioni architetturali |
|
||||
| `build/README.md` | catena di verifica, pacchetto e rilascio |
|
||||
|
||||
## Convenzioni
|
||||
|
||||
- **C#**, `Nullable` e `TreatWarningsAsErrors` attivi. Identificatori e commenti tecnici in inglese; documentazione, report e colonna `motivazione` in italiano.
|
||||
- **Nessun pacchetto NuGet.** Solo BCL e WPF nei progetti dell'applicazione; xunit nei test.
|
||||
- **Tabelle**: CSV con separatore `;`, header, ultima colonna `motivazione`; JSONL append-only per ledger e notizie; JSON per modelli e stato. Scritture atomiche (`.tmp` + `File.Move`), rotazione mensile. **Nessuna riga del ledger viene mai modificata**: le correzioni sono righe nuove con `evento = correzione`.
|
||||
- **Tempo**: UTC ovunque; conversione solo in UI. `CultureInfo.InvariantCulture` per ogni parsing e formattazione su file.
|
||||
- **Concorrenza**: un solo thread di decisione; I/O asincrono; `Channel<T>` fra ingestion, strategia, esecuzione e UI.
|
||||
- **Riproducibilità**: seed fisso 42 per ogni componente stocastica; ogni run scrive `run_id`, hash della configurazione e versione del codice nel ledger.
|
||||
- Cartelle a runtime sotto `Documenti\Encelado\`: `data/`, `knowledge/`, `reports/`, `results/`, `logs/`. Credenziali solo in `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o variabili d'ambiente `ETORO_API_KEY`, `ETORO_USER_KEY`.
|
||||
- **Interfaccia**: una barra in alto (schede Dashboard / Log / Impostazioni, stato, ambiente, ora nel fuso scelto, AVVIA), pagine sotto. Nella dashboard solo le informazioni principali; i dettagli nei tooltip e nel log. Gli orari a schermo passano da `UiClock` (`ui.timeZone`); il log porta l'offset, il ledger è UTC.
|
||||
- **Verifica visiva**: `ENCELADO_RENDER_DIR=<cartella> dotnet test tests/Encelado.Tests --filter UiRenderTests` scrive `dashboard.png`, `log.png`, `settings.png`, `window.png`.
|
||||
|
||||
## Comandi
|
||||
|
||||
```powershell
|
||||
dotnet build Encelado.slnx # compilazione
|
||||
dotnet test tests/Encelado.Tests --no-restore # test (xunit)
|
||||
dotnet msbuild build/Release.proj -t:Verifica # compilazione + test nella cartella di verifica
|
||||
dotnet run --project src/Encelado.Bot -- --headless [--minutes 240] # bot senza finestra (VPS, test lunghi)
|
||||
dotnet run --project tools/Encelado.Backtest -- ticks --data "A:\Download\Trading" --out "%USERPROFILE%\Documents\Encelado\data\market"
|
||||
dotnet run --project tools/Encelado.Backtest -- baskets --data "%USERPROFILE%\Documents\Encelado\data\market" --out results
|
||||
dotnet run --project tools/Encelado.Backtest -- falsify --data "%USERPROFILE%\Documents\Encelado\data\market" --out reports [--costs api]
|
||||
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=4.0.0 # installatore + zip + tag + release su Gitea (dopo il commit e il push del ramo)
|
||||
```
|
||||
|
||||
## Regole
|
||||
|
||||
1. **Chiedi se hai un dubbio.** Le domande vanno in `docs/QUESTIONS.md`, numerate, con il default che applicheresti; in assenza di risposta applica il default più prudente e annotalo.
|
||||
2. **Mai un ordine reale senza flag e conferma.** `run.executionMode` predefinito `Demo`; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` all'avvio. Nessuna approvazione per singolo ordine (D-20): il bot opera da solo.
|
||||
3. **Mai una riga del ledger modificata.**
|
||||
4. **Mai un risultato abbellito.** Se la strategia non regge i costi di eToro, il report lo dice con i numeri. "Nessuna configurazione profittevole" è un esito ammesso.
|
||||
5. **Non toccare la catena di rilascio** (`build/`) se non richiesto; è condivisa con Mimante/AutoBidder.
|
||||
6. **Un commit a fine sessione**, dopo che la verifica passa, con un messaggio che dice cosa cambia e perché. Il push lo decide l'utente.
|
||||
7. Aggiorna `docs/STATE.md` e `CHANGELOG.md` a fine sessione; un ADR per ogni scelta non ovvia.
|
||||
|
||||
## Cose da non fare
|
||||
|
||||
- Non scrivere chiavi in chat, nel log, nel repo o in `Documenti`.
|
||||
- Non usare spread fissi nel cost gate: sempre lo spread reale letto dall'API in quel momento più il markup dell'endpoint dei costi.
|
||||
- Non ricostruire feature a posteriori: il dataset di addestramento è il ledger scritto al momento della decisione.
|
||||
- Non cambiare parametri live in automatico: le proposte passano da `knowledge/proposals.csv` e dal forward test.
|
||||
- Non spostare l'installazione in Program Files (vedi `docs/adr/`): l'app scrive accanto alla configurazione.
|
||||
- Non usare heredoc lunghi o con backslash nel Bash tool: usare `Write`/`Edit` (vedi memoria `strumenti-heredoc-backslash`).
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
Ottimo! Ora effettua le seguenti modifiche:
|
||||
-
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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 "$(TestProj)" --nologo -v q --artifacts-path "$(CartellaProve)""
|
||||
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="%USERPROFILE%\Documents\Encelado\data\market" (barre candles_SYMBOL_M15.csv) per baskets e falsify, oppure la cartella dei tick MT5 per ticks.%0AComandi disponibili in -p:Comando= : ticks, baskets, falsify (altre opzioni in -p:Extra=, es. --costs api --quick)." />
|
||||
|
||||
<Error Condition="!Exists('$(Dati)')" Text="Cartella dati non trovata: $(Dati)" />
|
||||
|
||||
<Message Importance="High" Text="== Ricerca sui basket ==" />
|
||||
<Message Importance="High" Text=" dati : $(Dati)" />
|
||||
<Message Importance="High" Text=" comando : $(Comando) $(Extra)" />
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)" EnvironmentVariables="$(AmbientePulito)"
|
||||
Command="dotnet build "$(BacktestProj)" -c Release --nologo -v q" />
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)"
|
||||
Command=""$(BacktestExe)" $(Comando) --data "$(Dati)" $(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 "$(Csproj)" -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 "$(CartellaPubblicazione)"" />
|
||||
|
||||
<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=""$(Iscc)" /Qp "/DAppVersion=$(V)" "/DSourceDir=$(CartellaPubblicazione)" "/DOutputDir=$(CartellaPacchetti)" "$(Iss)"" />
|
||||
|
||||
<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 "Encelado $(V)"" 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 = "Authorization: token $(GToken)"" />
|
||||
|
||||
<!--
|
||||
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 "$(CurlCfg)" -o "$(RispostaJson)" "$(Api)/releases/tags/$(Tag)"" />
|
||||
|
||||
<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 "$(CurlCfg)" -X DELETE "$(Api)/releases/$(IdEsistente)"" />
|
||||
<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 "$(CurlCfg)" -X POST -H "Content-Type: application/json" --data-binary "@$(CorpoJson)" -o "$(RispostaJson)" "$(Api)/releases"" />
|
||||
|
||||
<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 "$(CurlCfg)" -X POST -F "attachment=@%(Allegato.FullPath)" "$(Api)/releases/$(IdRelease)/assets?name=%(Allegato.Filename)%(Allegato.Extension)"" />
|
||||
|
||||
<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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"_comment": "Encelado — strategia Correlation Baskets su eToro. Cinque basket di due coppie forex correlate: si entra quando il cross sintetico diverge (z-score), si esce quando converge o al take-profit di basket in pip; lo stop di basket è obbligatorio. Ogni chiave con '_' davanti è documentazione.",
|
||||
|
||||
"_preset": "Conservative | Moderate | Aggressive. Fissa zIn, riskPerBasketPct, maxBaskets, tpPips, maxAdds, zStop; si cambia a caldo dalla finestra senza toccare i basket aperti. Le chiavi omonime qui sotto, se presenti, sovrascrivono il preset.",
|
||||
"preset": "Moderate",
|
||||
|
||||
"_signalMode": "ZScoreSynthetic (default, |z| >= zIn sul cross sintetico) oppure PipDivergence (fedele all'interfaccia Titany: divergenza in pip dall'ancora, dIn).",
|
||||
"signalMode": "ZScoreSynthetic",
|
||||
"_exitMode": "First = la prima fra TP in pip e rientro dello z; FixedPips = solo TP in pip lordi; ZReturn = solo |z| <= zOut.",
|
||||
"exitMode": "First",
|
||||
"_averagingMode": "Off | AddOnce | Grid. Off in live; AddOnce in paper. Moltiplicatore di lotto sempre 1,0 (niente martingala).",
|
||||
"averagingMode": "Off",
|
||||
"tpMode": "Pips",
|
||||
"_sameCrossPolicy": "I basket 4 e 5 sono entrambi EURCAD: Exclusive = uno solo aperto per volta; Half = entrambi a metà size.",
|
||||
"sameCrossPolicy": "Exclusive",
|
||||
"preferDirectCross": false,
|
||||
|
||||
"_indicatori": "Correlazione di Pearson rolling dei rendimenti M15 su window (ρ_W) e windowShort (ρ_20); z-score del cross sintetico su window; half-life OLS ricalcolata ogni halfLifeRecalcHours.",
|
||||
"window": 100,
|
||||
"windowShort": 20,
|
||||
"rhoMin": 0.60,
|
||||
"rhoShortMin": 0.40,
|
||||
"halfLifeMinBars": 4,
|
||||
"halfLifeMaxBars": 96,
|
||||
"halfLifeRecalcHours": 4,
|
||||
"atrPeriod": 14,
|
||||
"ewmaSpan": 100,
|
||||
"trendPeriod": 14,
|
||||
|
||||
"zOut": 0.25,
|
||||
"dIn": 15,
|
||||
"anchorBars": 32,
|
||||
"gridStepZ": 0.75,
|
||||
"lotMultiplier": 1.0,
|
||||
|
||||
"_uscite": "Stop di basket: |z| >= zStop, oppure perdita netta >= maxLossPerBasketPct dell'equity, oppure |ρ_20| < rhoBreak per rhoBreakBars barre, oppure maxHoldingBars barre (96 = 24 h).",
|
||||
"maxLossPerBasketPct": 1.5,
|
||||
"rhoBreak": 0.20,
|
||||
"rhoBreakBars": 8,
|
||||
"maxHoldingBars": 96,
|
||||
"tpAtrMultiple": 1.0,
|
||||
|
||||
"_costGate": "Costo = spread_A + spread_B (in pip-equivalenti di A) + markup e commissioni dell'API + overnight stimato per maxHoldingBars. Entrata solo se TP >= costMultiple × costo e ogni spread <= spreadMedianMultiple × la sua mediana delle ultime 24 h; spread oltre spreadAnomalyMultiple × mediana = chiusura forzata.",
|
||||
"costMultiple": 3,
|
||||
"spreadMedianMultiple": 2,
|
||||
"spreadAnomalyMultiple": 3,
|
||||
"slippagePipsPerLeg": 0.3,
|
||||
"overnightPipsPerDay": 0.3,
|
||||
|
||||
"_calendario": "Nessuna entrata nei blackoutBeforeMin minuti prima e blackoutAfterMin dopo un evento ad alto impatto sulle valute del basket; niente entrate dal venerdì fridayCutoffUtcHour UTC alla riapertura né nei primi openDelayMinutes dopo l'apertura settimanale; sessions = fasce orarie UTC ammesse (vuoto = sempre).",
|
||||
"blackoutBeforeMin": 45,
|
||||
"blackoutAfterMin": 30,
|
||||
"fridayCutoffUtcHour": 20,
|
||||
"openDelayMinutes": 30,
|
||||
"sessions": [],
|
||||
|
||||
"_sizing": "Lotto B = lotto A × (ATR_A × pipValue_A) / (ATR_B × pipValue_B); lotto A tale che la perdita allo stop valga riskPerBasketPct dell'equity; leva effettiva <= maxEffectiveLeverage sul nozionale complessivo; orderLeverage è la leva dichiarata a eToro per ogni gamba (1, 2, 5, 10, 20, 30).",
|
||||
"maxEffectiveLeverage": 10,
|
||||
"orderLeverage": 10,
|
||||
"_volScale": "zIn effettivo = zIn × clamp(σ_prevista / σ_media_30g, volScaleMin, volScaleMax).",
|
||||
"volScaleMin": 0.8,
|
||||
"volScaleMax": 1.5,
|
||||
"volAverageDays": 30,
|
||||
"mlMinProbability": 0.55,
|
||||
|
||||
"_sicurezza": "equityStopPct: perdita dal picco di equity oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.",
|
||||
"equityStopPct": 9,
|
||||
"dailyLossPct": 3,
|
||||
"legTimeoutSec": 5,
|
||||
"clockSkewMaxSeconds": 5,
|
||||
|
||||
"_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.",
|
||||
"baskets": [
|
||||
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
|
||||
{ "a": "AUDUSD", "b": "USDCAD", "enabled": true, "note": "cross sintetico AUDCAD" },
|
||||
{ "a": "NZDUSD", "b": "EURNZD", "enabled": true, "note": "cross sintetico EURUSD: replica EURUSD pagando due spread" },
|
||||
{ "a": "USDCAD", "b": "EURUSD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 5)" },
|
||||
{ "a": "EURAUD", "b": "AUDCAD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 4)" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# Architettura di Encelado
|
||||
|
||||
Aggiornato: 2026-09-16 (Fase 0 della modifica "Correlation Baskets" su eToro).
|
||||
|
||||
## 1. Che cosa c'era prima della modifica (ricognizione)
|
||||
|
||||
### 1.1 Albero dei progetti
|
||||
|
||||
```
|
||||
Encelado.slnx
|
||||
├── src/Encelado.Core libreria portabile (net10.0), zero NuGet, AOT/trim-compatibile
|
||||
│ ├── Backtest/ replay su coppie cointegrate (era Binance), CsvBarSource, CrossSectional
|
||||
│ ├── Indicators/ SMA, EMA, RSI, MACD, ATR, RollingStdDev, Bollinger, Donchian, RollingWindow<T>
|
||||
│ ├── Journal/ IJournalSink e record dei journal (DecisionRow, TradeRow, ProbaDecisionRow…)
|
||||
│ ├── Market/ Bar, Quote, Tick, Side, TimeFrame
|
||||
│ ├── Ml/ GBDT nativo, meta-labeling, triple barrier, PurgedCv/CPCV, Pbo (CSCV), Classification (AUC, Brier, log-loss, calibrazione), DriftMonitor (PSI/KS)
|
||||
│ ├── Research/ pipeline ProbaBot: AssetFrame, eventi CUSUM, bracci di feature B/C, EventBacktest, Trials, Gates
|
||||
│ ├── Risk/ RiskEngine e RiskLimits (kill-switch giornaliero, esposizione, spread)
|
||||
│ ├── Rl/ Mlp a due strati (Adam), DqnAgent, PairEnvironment
|
||||
│ ├── Statistics/ Ols, DickeyFuller, Cointegration (+HalfLife), Johansen, Kalman, Pca, Performance (Sharpe, PSR, DSR, Kelly), Normal
|
||||
│ └── Strategies/ StatArbStrategy (coppie), StrategyParameters
|
||||
├── src/Encelado.Storage SQLite (Microsoft.Data.Sqlite) — l'unica dipendenza NuGet a runtime; journal in doppia scrittura, dataset, modelli, campioni
|
||||
├── src/Encelado.CTrader adattatore cTrader Open API (NuGet cTrader.OpenAPI.Net). NON referenziato dal Bot: non è mai stato collegato
|
||||
├── src/Encelado.Bot WPF (net10.0-windows), WinExe "Encelado.exe", zero NuGet
|
||||
│ ├── Configuration/ BotConfig, ConfigLoader (JsonDocument a mano, chiavi sconosciute segnalate), ConfigDefaults (JSON di fabbrica incorporato), ConfigWriter (modifica per percorso puntato, scrittura atomica), CredentialStore (DPAPI in %LOCALAPPDATA%\Encelado), CredentialResolver
|
||||
│ ├── Engine/ BotSupervisor (ciclo di vita, snapshot), ProbaEngine (motore cTrader, incompleto), AccountState, BotSnapshot, TradeJournal, DecisionLog
|
||||
│ ├── Diagnostics/ CsvTable (tabelle ';' con header e spostamento in .old), Metrics
|
||||
│ ├── Logging/ Log statico non bloccante su Channel<T>, file ';' con rotazione, Sink per la UI
|
||||
│ ├── Ui/ MainViewModel (INotifyPropertyChanged a mano), Theme.xaml (tema scuro proprio), pagine Status/Log/Settings, LoginWindow (OAuth cTrader), SettingsCatalogue, SettingField, Converters
|
||||
│ └── MainWindow.xaml(.cs) shell con navigazione laterale, timer 1 s che applica lo snapshot
|
||||
├── tests/Encelado.Tests xunit 2.9 (framework GIÀ presente: si usa quello, niente mini-runner)
|
||||
├── tools/Encelado.Backtest strumento console di ricerca ("backtest <comando>"), unico progetto con TA-Lib
|
||||
└── build/ Release.proj (verifica, pacchetto, rilascio su Gitea), Encelado.iss (Inno Setup)
|
||||
```
|
||||
|
||||
- **Target framework**: `net10.0` (Bot e test `net10.0-windows`), `Nullable` e `TreatWarningsAsErrors` attivi per tutti i progetti via `Directory.Build.props`. SDK installato: 10.0.301.
|
||||
- **Pattern**: nessun contenitore DI; oggetti costruiti a mano nel supervisore; async/await con `ConfigureAwait(false)` nel motore; `Channel<T>` per il log; `Lock` per lo stato condiviso; snapshot immutabili verso la UI; ogni tabella è CSV `;` con colonna finale `motivazione`; log strutturato `timestamp;level;source;subject;event;message;exception;stack`.
|
||||
- **Client broker esistente**: nessun client eToro. Esisteva un adattatore Binance (cancellato, non committato) e un adattatore cTrader (mai collegato al Bot). Il motore `ProbaEngine` usa i tipi cTrader direttamente.
|
||||
- **Storage**: SQLite in `%ProgramData%\Encelado\encelado.db` (barre, dataset, modelli, journal) più CSV nella cartella dei log. Configurazione in `Documenti\Encelado\encelado.json`; credenziali cifrate DPAPI in `%LOCALAPPDATA%\Encelado`.
|
||||
- **UI**: WPF, tema scuro proprio (`Ui/Theme.xaml`: palette, `Card`, `Chip`, `Kpi`, `Label`, `Value`, `Sub`, `Head`, pulsanti `Primary`/`Danger`, `PowerButton`, `ModeBadge`), font tabulare `Cascadia Mono`. Nessuna libreria MVVM: `MainViewModel` implementa `INotifyPropertyChanged` a mano.
|
||||
- **Test**: xunit con test di binding WPF (`UiBindingTests` ascolta la trace source dei binding e fallisce su ogni binding irrisolto), test di configurazione, statistica, ML, rischio.
|
||||
- **Build ed esecuzione**: `dotnet build Encelado.slnx`; verifica completa `dotnet msbuild build/Release.proj -t:Verifica`; l'app legge `Documenti\Encelado\encelado.json` (creato dal JSON di fabbrica al primo avvio); la versione rilasciata viene dal tag git (`build/Release.proj`).
|
||||
|
||||
### 1.2 Stato dell'albero di lavoro trovato il 2026-09-16
|
||||
|
||||
L'albero **non compilava**: la sessione precedente (rework verso cTrader, 2026-09-09) era rimasta a metà e non committata.
|
||||
|
||||
| Problema | Dove |
|
||||
|---|---|
|
||||
| `Encelado.Bot` non referenzia `Encelado.CTrader`, quindi `ProbaEngine`, `BotConfig`, `CredentialResolver`, `LoginWindow`, `AccountState` non risolvono i tipi cTrader | `src/Encelado.Bot/Encelado.Bot.csproj` |
|
||||
| `MainWindow.xaml.cs` referenzia `PositionsPage` (cancellata), `_config.Binance`, `ClosePairAsync`, `EnabledPairs` (era Binance) | `src/Encelado.Bot/MainWindow.xaml.cs` |
|
||||
| `CsvTable.cs` usa `Side` senza `using Encelado.Core.Market` | `src/Encelado.Bot/Diagnostics/CsvTable.cs` |
|
||||
| `TestSnapshots.cs` costruisce lo snapshot dell'era Binance (`PairRow`, `EquityCurve`, `OrderRow`…) | `tests/Encelado.Tests/TestSnapshots.cs` |
|
||||
| `Documenti\Encelado\encelado.json` dell'utente è nel formato Binance (sezioni `binance`, `pairs`) | file dell'utente, non nel repo |
|
||||
|
||||
Decisione presa (vedi `docs/QUESTIONS.md`, D-09): il motore cTrader resta nel repository come modulo selezionabile (`engine.strategy = "proba"`) e viene rimesso in compilazione; il motore nuovo (`"baskets"`) è il predefinito.
|
||||
|
||||
### 1.3 Punti di estensione usati dalla modifica
|
||||
|
||||
| Cosa | Dove si aggancia |
|
||||
|---|---|
|
||||
| Nuova strategia | `BotSupervisor` costruisce il motore in base a `engine.strategy`; il motore espone `IEngine` (`RunAsync`, `Snapshot`, comandi) |
|
||||
| Flusso dati di mercato | il motore basket interroga `IBroker.GetQuotesAsync` a polling (2-5 s) e costruisce le barre M15 in locale; le candele ufficiali servono per il riscaldamento e la riconciliazione |
|
||||
| Esecuzione ordini | `IBroker.OpenAsync/CloseAsync/UpdateStopsAsync`, tre implementazioni (`EtoroBroker`, `PaperBroker`, `BacktestBroker`) |
|
||||
| Log delle operazioni | `Log` (file `;`), più il ledger nuovo (`data/ledger/decisions.jsonl`, `baskets.csv`) |
|
||||
| UI | pagine nuove (`BasketsPage`) selezionate dalla shell in base al motore; `Theme.xaml` riusato |
|
||||
| Configurazione | `encelado.json` (sezioni `engine`, `etoro`, `logging`) + `strategy.json` (parametri e preset dei basket) letti con `JsonDocument`, modificati con `ConfigWriter` |
|
||||
| Test | xunit esistente; nuove suite in `tests/Encelado.Tests/Baskets*.cs` |
|
||||
|
||||
## 2. Architettura della modifica (obiettivo)
|
||||
|
||||
### 2.1 Progetti (stato del 2026-09-16 sera, dopo la rimozione dei motori precedenti — ADR-0004)
|
||||
|
||||
```
|
||||
src/Encelado.Core/Broker/ IBroker, modelli (Instrument, QuoteSnapshot, AccountSnapshot, BrokerPosition, OrderRequest, OrderOutcome), PaperBroker (simulatore sopra un feed reale), RateLimiter
|
||||
src/Encelado.Core/Baskets/ matematica e logica pura, senza I/O:
|
||||
SyntheticCross (derivazione automatica del cross e dei segni), PipMath, BasketMath (rendimenti log, ATR, EWMA vol, ρ_W/ρ_20, z-score, semiperiodo OLS, forza di trend),
|
||||
SymbolSeries (barre + quote + qualità dati), BasketDecider (entrate/uscite/averaging di §5), CostGate, VolParitySizing, BasketExecutor (protocollo leg-risk),
|
||||
BasketPosition (macchina a stati), BasketStrategyConfig (strategy.json, preset), ExecutionMode (Paper | Demo | Live)
|
||||
src/Encelado.Core/Baskets/Data/ BidAskBar + CSV, TickToBars (tick MT5 → M15)
|
||||
src/Encelado.Core/Baskets/Learning/ livelli 0-3: CalibrationTables, OnlineLogistic (SGD+L2, standardizzazione rolling), SmallMlp (16 ReLU, Adam, early stopping, gradient check),
|
||||
ThompsonBandit (Beta per preset × terzile di vol), VolForecast (EWMA vs HAR-RV, PSI), LearningFeatures (28 feature del ledger), ModelEvaluator (walk-forward, fold purgati, bootstrap, attivazione)
|
||||
src/Encelado.Core/Baskets/Backtest/ BasketBacktest (event-driven su barre M15 bid/ask), BacktestBroker, BasketTrials (griglia, PSR/DSR, PBO, walk-forward 6m/1m)
|
||||
src/Encelado.Core/News/ parser puri: CalendarParser (JSON/XML FairEconomy), RssParser (XmlReader), SentimentLexicon, SentimentEngine (finestre 1h/4h/24h con decadimento)
|
||||
src/Encelado.Core/Ml/, Statistics/ la statistica condivisa rimasta: Classification (AUC, Brier, log-loss, calibrazione), Pbo (CSCV), Performance (Sharpe, PSR, DSR, drawdown, momenti), Ols, Normal
|
||||
src/Encelado.Etoro/ EtoroOptions, EtoroHttp (HttpClient, x-api-key/x-user-key/x-request-id, limitatore per classe di quota, 429 con Retry-After, scarto orologio dall'header Date), EtoroBroker : IBroker
|
||||
src/Encelado.Bot/Baskets/ BasketEngine (ciclo di decisione a thread singolo, polling quote, barre locali, esecuzione diretta, equity stop, kill-switch, file STOP, riconciliazione),
|
||||
Ledger (decisions.jsonl append-only, baskets.csv, rotazione mensile, scritture atomiche), Feeds (calendario + RSS con cache su disco, robots.txt, backoff),
|
||||
LearningState (modello in ombra, bandit, ciclo settimanale, knowledge/), HeadlessRunner (--headless)
|
||||
src/Encelado.Bot/Configuration/ BotConfig (etoro, run, ui, logging), ConfigLoader (JsonDocument, avvisi sulle sezioni di versioni precedenti), ConfigDefaults, ConfigWriter, EtoroKeyStore (DPAPI)
|
||||
src/Encelado.Bot/Engine/ IEngine, BotSupervisor (ciclo di vita, snapshot, feed di attività), BotSnapshot
|
||||
src/Encelado.Bot/Ui/ Theme.xaml, MainWindow (barra in alto con le tre schede), Pages/DashboardPage (i cinque numeri, la tabella dei basket, il contesto, l'attività), LogPage, SettingsPage (SettingsCatalogue, fuso orario),
|
||||
EtoroLoginWindow, PromptWindow (CONFERMO LIVE, motivazione del reset), UiClock (fuso orario della finestra), MainViewModel
|
||||
tools/Encelado.Backtest `ticks` (tick MT5 → barre M15 bid/ask), `baskets` (baseline, griglia, trials, PBO, walk-forward), `falsify` (i cinque test di falsificazione); `--costs etoro|api`
|
||||
```
|
||||
|
||||
Progetti rimossi il 2026-09-16 (ADR-0004): `Encelado.CTrader`, `Encelado.Storage`, `Core/Backtest`, `Indicators`, `Journal`, `Market`, `Portfolio`, `Research`, `Risk`, `Rl`, `Strategies`, il grosso di `Ml` e `Statistics`, `ProbaEngine`, le pagine `StatusPage`/`LoginWindow`, `ApprovalQueue`.
|
||||
|
||||
### 2.2 Flusso dati (live)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E[eToro Public API] -->|rates ogni 3 s| Q[Quote poller]
|
||||
Q --> B[Bar builder M15]
|
||||
E -->|candles| B
|
||||
B --> S[Strategy loop<br/>un solo thread]
|
||||
C[Calendario + RSS] --> F[Feature contesto]
|
||||
F --> S
|
||||
M[Meta-modello in ombra<br/>vol forecast] --> S
|
||||
S -->|decisione| X[Executor<br/>leg-risk protocol]
|
||||
X --> E
|
||||
S --> L[(Ledger jsonl/csv)]
|
||||
X --> L
|
||||
S --> U[Snapshot → UI / headless]
|
||||
L --> K[Ciclo settimanale: L0-L3]
|
||||
K --> M
|
||||
```
|
||||
|
||||
Le decisioni avvengono su un solo thread; l'I/O è asincrono; l'unico gate umano per ordine è sparito (ADR-0005): restano avvio del reale, kill-switch, reset e cambio di preset.
|
||||
|
||||
### 2.3 Macchina a stati del basket
|
||||
|
||||
```
|
||||
Idle ──(segnale + cancelli)──► Entering ──(A e B eseguite)──► Open ──(add)──► Adding ──► Open
|
||||
▲ │ (B rifiutata/timeout → chiudi A, leg_risk_unwind, basket disattivato 1 h)
|
||||
│ ▼
|
||||
└────────── Closed ◄──── Exiting ◄──(TP | z_out | stop | time-stop | manuale | forzata)── Open
|
||||
│ (una gamba non chiude dopo 3 tentativi)
|
||||
▼
|
||||
Error (blocco nuove entrate finché non risolto)
|
||||
```
|
||||
|
||||
### 2.4 Interfacce
|
||||
|
||||
- `IBroker`: `Environment`, `GetInstrumentsAsync`, `GetQuotesAsync(ids)`, `GetCandlesAsync(id, interval, count)`, `GetAccountAsync`, `GetPositionsAsync`, `OpenAsync(OrderRequest)`, `LookupOrderAsync`, `CloseAsync(positionId, instrumentId)`, `UpdateStopsAsync(positionId, sl, tp)`, `GetCostAsync(OrderRequest)`, `GetClosedTradesAsync`, `ClockSkew`.
|
||||
- `IContextProvider` (Bot): calendario, notizie e sentiment per basket (`FeedContextProvider`; `EmptyContextProvider` nei test).
|
||||
- `IModel`: `Predict(features)`, `Update(features, label)`, JSON, implementato da `OnlineLogistic` e `SmallMlp`.
|
||||
- `IEngine` (Bot): `RunAsync`, `CloseAllAsync`, `ExecuteAsync(EngineCommand)` con `Close`, `KillSwitch`, `SetPreset`, `ResetEquityStop(motivazione)`, `Snapshot()`.
|
||||
- `IUiActions` (Bot): ciò che le pagine possono chiedere alla finestra (chiudi basket, kill-switch, preset, reset, chiavi, file).
|
||||
|
||||
### 2.5 Vincoli e limiti scoperti in Fase 0
|
||||
|
||||
- L'endpoint candele di eToro accetta solo `count ≤ 1000` senza data di partenza: dà al massimo ~10 giorni di M15. Lo storico per il backtest viene dai tick MT5 forniti dall'utente (`A:\Download\Trading`, 2018-12 → 2026-09, UTC), convertiti in M15 bid/ask dallo strumento `backtest ticks`.
|
||||
- Quote di mercato (`/api/v2/market-data/rates`) in batch fino a 1000 strumenti per chiamata: un polling ogni 3 s costa 20 richieste/min sulla quota condivisa di 120/min.
|
||||
- Quota ordini: 20 richieste/min (demo e reale separate). Un basket costa 2 aperture + 2 chiusure.
|
||||
- Le quote di `rates` sono senza markup; il costo effettivo (markup + spread di mercato + overnight) arriva da `POST /trading/info/{demo/}costs` (20/min dedicate). Il cost gate somma i due.
|
||||
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (asincrono: esito con `orders:lookup` per `referenceId` = `x-request-id`); `sellShort` e leva > 1 richiedono `stopLossRate`. Chiusura: `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}`.
|
||||
- Esposizione minima per posizione: 1000 USD (`minPositionExposure`); leva ammessa 1-30 (majors) e 1-20 (minors). Il conto reale dell'utente vale 193,18 USD: con i limiti di rischio della strategia il reale non è praticabile oggi (vedi QUESTIONS D-05).
|
||||
@@ -0,0 +1,69 @@
|
||||
# Fonti dei dati
|
||||
|
||||
Aggiornato: 2026-09-16. Ogni fonte è stata verificata alla data indicata; se un feed cambia o sparisce, la voce va aggiornata e la decisione annotata in `docs/QUESTIONS.md`.
|
||||
|
||||
## 1. Mercato
|
||||
|
||||
| Fonte | Cosa | Formato | Frequenza | Limiti | Fallback |
|
||||
|---|---|---|---|---|---|
|
||||
| **eToro Public API** — `GET /api/v2/market-data/rates?instrumentIds=…` | bid/ask di tutti gli strumenti in una chiamata | JSON `{results:[{instrumentId,bid,ask,date,quoteType}]}` | polling ogni `run.pollSeconds` (3 s) | quota condivisa 120/min con le altre rotte di market data; il bot ne usa ~20/min | nessuno: senza quote il bot non decide |
|
||||
| **eToro Public API** — `GET /api/v1/market-data/instruments/{id}/history/candles/asc/FifteenMinutes/1000` | ultime 1000 candele M15 (mid, senza spread) | JSON | all'avvio, per il riscaldamento e il delta | **non pagina**: niente data di partenza, al massimo ~10 giorni | le barre locali salvate a ogni chiusura |
|
||||
| **eToro Public API** — `GET /api/v2/market-data/instruments?symbols=…`, `POST /trading/info/{demo/}eligibility` | id, nome, esposizione minima, leve, limiti di stop | JSON | all'avvio, scritti in `instruments.json` | 120/min e 20/min dedicate | valori prudenti incorporati (esposizione minima 1000 USD, leve 1-20) |
|
||||
| **eToro Public API** — `POST /trading/info/{demo/}costs` | markup, spread di mercato, commissione, overnight e weekend per un ordine ipotetico | JSON `{costs:[{costType, currency, value}]}` — il campo è **`value`** (verificato il 2026-09-16: EURUSD 10 000 unità → markup 0, marketSpread 0,1 USD, overnightFee 0,91 USD/giorno) | ogni 15 minuti per strumento | 20/min dedicate | markup 0 e overnight da `strategy.json` |
|
||||
| **Tick MetaTrader 5** — `A:\Download\Trading\<SYMBOL>_<da>_<a>.csv` | tick bid/ask 2018-12-12 → 2026-09-15, **UTC** (verificato sui fine settimana: chiusura venerdì 20:53 estate / 21:57 inverno, riapertura domenica 21:05 / 22:05) | tab-separato `<DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>`; flag 2 = solo bid, 4 = solo ask, 6 = entrambi | una tantum, `backtest ticks` | EURAUD copre solo parte del 2018, del 2021 e del 2026 (39 615 barre contro ~192 000 delle altre): il basket EURAUD/AUDCAD è misurabile solo su quei tratti | — |
|
||||
| Barre M15 derivate — `Documenti\Encelado\data\market\candles_<SYMBOL>_M15.csv` | OHLC bid e ask, spread medio, numero di tick, provenienza | CSV `;` (schema in `docs/LEDGER_SCHEMA.md`) | scritte dallo strumento e aggiornate dal bot a ogni barra chiusa | — | — |
|
||||
|
||||
Qualità (`data/market/data_quality.csv`, generato da `backtest ticks`, e `reports/data_quality.csv` dal bot): buchi > 1 h nei giorni feriali, salti > 2 % fra barre, duplicati. Una barra sospetta sospende le decisioni sul basket coinvolto per quella barra.
|
||||
|
||||
## 2. Calendario economico
|
||||
|
||||
| Fonte | URL | Formato | Aggiornamento | Note |
|
||||
|---|---|---|---|---|
|
||||
| Forex Factory via FairEconomy | `https://nfs.faireconomy.media/ff_calendar_thisweek.json` | JSON `[{title,country,date,impact,forecast,previous,actual}]`, `date` con offset (ora di New York) | il feed cambia più volte al giorno; il bot lo rilegge ogni 10 minuti, mai più di una richiesta al minuto | `country` è già il codice valuta (`USD, EUR, GBP, JPY, AUD, NZD, CAD, CHF, CNY`, `All`); `impact` ∈ {High, Medium, Low, Holiday} |
|
||||
| variante XML | `https://nfs.faireconomy.media/ff_calendar_thisweek.xml` | `<weeklyevents><event>` con `date` MM-DD-YYYY e `time` 8:15am **in UTC** (verificato contro il JSON: "10:30pm" del 09-13 = "18:30-04:00") | idem | usata solo come riserva |
|
||||
|
||||
Archivio: `data/calendar/events.jsonl` (append-only, una riga per evento, dedup per `title+date+country`; un `actual` che arriva dopo la pubblicazione è una riga nuova). Feature derivate per ogni valuta: `minutesToNextHigh`, `minutesSinceLastHigh`, `surpriseLast = (actual − forecast)/|forecast|`.
|
||||
|
||||
Limite: il feed copre **la settimana corrente**. Non esiste uno storico gratuito: il backtest non applica il blackout né le feature di calendario, e lo dice (`docs/STRATEGY.md`).
|
||||
|
||||
## 3. Notizie (RSS)
|
||||
|
||||
Tutte lette con `User-Agent: Encelado/4.0 (+correlation baskets; contact: operator)`, al massimo una richiesta al minuto per fonte, con backoff esponenziale sugli errori e rispetto di `robots.txt` (gruppo `User-agent: *`). Verifica del 2026-09-16:
|
||||
|
||||
| Fonte | URL | Formato | Esito |
|
||||
|---|---|---|---|
|
||||
| FXStreet | `https://www.fxstreet.com/rss/news` | RSS 2.0 | 200 |
|
||||
| ForexLive | `https://www.forexlive.com/feed/` | RSS 2.0 | 200 |
|
||||
| Federal Reserve | `https://www.federalreserve.gov/feeds/press_all.xml` | RSS 2.0 | 200 con lo User-Agent del bot; con uno User-Agent minimale risponde con una pagina HTML "not found" |
|
||||
| BCE | `https://www.ecb.europa.eu/rss/press.html` | RSS 2.0 | 200 |
|
||||
| Bank of England | `https://www.bankofengland.co.uk/rss/news` | RSS 2.0 | 200 |
|
||||
| RBA | `https://www.rba.gov.au/rss/rss-cb-media-releases.xml` | RSS 1.0 (RDF) | 200 alla prima verifica, poi "Access Denied" (Akamai) a richieste successive: tenuta con backoff, coperta anche da Google News `"Reserve Bank of Australia"` |
|
||||
| Bank of Canada | `https://www.bankofcanada.ca/content_type/press-releases/feed/` | RSS 1.0 (RDF) | 200 |
|
||||
| SNB | `https://www.snb.ch/en/rss/press-releases` | — | **404**: omessa (D-08); coperta da Google News `"Swiss National Bank"` |
|
||||
| RBNZ | `https://www.rbnz.govt.nz/rss/news` | — | **403** "website unavailable": omessa (D-08); coperta da Google News `RBNZ` |
|
||||
| Google News | `https://news.google.com/rss/search?q=<query>&hl=en-US&gl=US&ceid=US:en` per `EURUSD`, `"Swiss National Bank"`, `RBNZ`, `forex dollar` | RSS 2.0 | 200 |
|
||||
|
||||
Archivio: `data/news/news_YYYYMM.jsonl` (append-only, una riga per item, dedup per `hash(link)`), con `published, source, title, summary, link, currencies, scores{net, hawkish, riskOff}`.
|
||||
|
||||
Sentiment senza librerie (`Encelado.Core/News/SentimentLexicon.cs`, `SentimentEngine.cs`): lessico incorporato in tre dimensioni (tono positivo/negativo ~180 termini ciascuno, hawkish/dovish ~80, risk-on/risk-off ~50), negazione a finestra di tre parole, attribuzione alle valute per entità (`Fed, Powell, FOMC → USD; ECB, Lagarde → EUR; BoJ → JPY; RBA → AUD; RBNZ → NZD; BoC → CAD; SNB → CHF; BoE → GBP`), parole-paese e nomi di coppia. Per ogni valuta e finestra (1 h, 4 h, 24 h): `netSentiment`, `hawkishScore`, `riskOff` (globale), `newsCount`, con decadimento esponenziale a emivita 2 h. Le feature di un basket sono le differenze fra le sue due valute non comuni.
|
||||
|
||||
Copie dei feed usate dai test: `tests/fixtures/` (scaricate il 2026-09-16).
|
||||
|
||||
## 4. Schema dei file in `Documenti\Encelado`
|
||||
|
||||
```
|
||||
encelado.json, strategy.json, instruments.json
|
||||
data/market/candles_<SYMBOL>_M15.csv timeUtc;bidOpen;bidHigh;bidLow;bidClose;askOpen;askHigh;askLow;askClose;spreadMean;ticks;motivazione
|
||||
data/market/data_quality.csv simbolo;tick_letti;tick_scartati;barre;prima_barra;ultima_barra;buchi_feriali_oltre_1h;barre_spike;spread_mediano_pip;motivazione
|
||||
data/calendar/events.jsonl {title,country,date,impact,forecast,previous,actual}
|
||||
data/news/news_YYYYMM.jsonl {hash,published,source,title,summary,link,currencies[],scores{net,hawkish,riskOff}}
|
||||
data/cache/<fonte>.xml|json ultimo corpo buono di ogni feed
|
||||
data/ledger/decisions.jsonl vedi docs/LEDGER_SCHEMA.md (rotazione mensile in decisions_YYYYMM.jsonl)
|
||||
data/ledger/baskets.csv vedi docs/LEDGER_SCHEMA.md
|
||||
data/state/baskets_state.json posizioni aperte, picco di equity, blocchi (per ripartire dopo un riavvio)
|
||||
data/state/paper_state.json il conto del simulatore (solo Paper)
|
||||
data/models/*.json modelli (livelli 1-3) e stato del bandit
|
||||
knowledge/*.csv, *.md calibrazione, proposte, registri, insight settimanali
|
||||
reports/*.csv qualità dati, falsificazione
|
||||
logs/encelado.log log applicativo (;)
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# Glossario
|
||||
|
||||
| Termine | Significato in Encelado |
|
||||
|---|---|
|
||||
| **Basket** | Due posizioni (una per coppia forex) aperte insieme e chiuse insieme, trattate come una sola scommessa sul cross sintetico. |
|
||||
| **Cross sintetico** | La coppia implicita nelle due gambe: `X = ln A + s·ln B`, con `s = +1` se la valuta comune ha ruoli opposti (EURUSD/USDCHF → EURCHF) e `−1` se uguali. Tutti e cinque i basket della specifica hanno `s = +1`. |
|
||||
| **Gamba** | Una delle due posizioni del basket. |
|
||||
| **z-score** | `(X − media_W) / σ_W` del cross sintetico su una finestra di W barre M15. Ingresso a `|z| ≥ z_in`, uscita a `|z| ≤ z_out` o allo stop `|z| ≥ z_stop`. |
|
||||
| **ρ_W, ρ_20** | Correlazione rolling dei rendimenti delle due gambe su W e su 20 barre. Attesa negativa per i basket della specifica (`rho_min` −0,6). |
|
||||
| **Half-life (HL)** | Semiperiodo di mean reversion del cross, in barre, da un OLS di Δx su x(t−1). Ammesso fra `halfLifeMinBars` e `halfLifeMaxBars`. |
|
||||
| **Preset** | Conservative / Moderate / Aggressive: z_in, rischio per basket, numero massimo di basket, TP in pip, aggiunte, z_stop. |
|
||||
| **TP di basket** | Take-profit in pip, somma dei pip delle due gambe (come nell'interfaccia di riferimento). |
|
||||
| **Cost gate** | Il rifiuto di un ingresso se il TP non copre almeno `costMultiple` volte il costo stimato (spread reale + markup + commissioni + overnight atteso), o se lo spread è più del doppio della mediana delle ultime 24 ore. |
|
||||
| **Break-even** | Il costo in pip oltre il quale il P&L medio lordo di un basket diventa negativo: se è vicino a zero, il segnale non ha contenuto. |
|
||||
| **Vol-parity sizing** | Le unità di ogni gamba sono inversamente proporzionali alla sua volatilità (ATR), così le due gambe contribuiscono allo stesso rischio; il rischio totale è `riskPerBasketPct` dell'equity alla distanza dello stop. |
|
||||
| **Leg-risk** | Il rischio di restare con una sola gamba: se la seconda non viene eseguita entro `legTimeoutSec`, la prima viene chiusa subito (`leg_risk_unwind`). |
|
||||
| **Equity stop** | Chiusura di tutto e blocco a un drawdown del 9 % dal picco; riparte solo con un reset motivato. |
|
||||
| **Kill-switch** | Chiusura immediata di tutto e blocco delle nuove entrate: pulsante, comando o file `STOP`. |
|
||||
| **Paper / Demo / Live** | Simulatore locale / conto demo eToro / conto reale. Il bot opera da solo in tutte e tre (D-20). |
|
||||
| **Ledger** | `decisions.jsonl` (ogni decisione con le sue feature) e `baskets.csv` (ogni basket chiuso). Append-only: le correzioni sono righe nuove. |
|
||||
| **Meta-modello** | La regressione logistica (livello 1) che stima la probabilità che un basket finisca in utile. In ombra finché non supera i cancelli di attivazione. |
|
||||
| **Challenger** | L'MLP (livello 2) valutato accanto al campione. |
|
||||
| **Bandit** | Il campionamento di Thompson (livello 3) che propone il preset per terzile di volatilità. |
|
||||
| **Walk-forward** | Valutazione in cui ogni previsione usa solo dati precedenti; per la griglia del backtest: scegli il migliore dei 6 mesi passati, applicalo al mese successivo. |
|
||||
| **PSR / DSR** | Probabilistic e Deflated Sharpe Ratio: la probabilità che lo Sharpe osservato sia sopra zero, tenendo conto di asimmetria, curtosi, lunghezza e (DSR) del numero di configurazioni provate. |
|
||||
| **PBO** | Probabilità di overfitting del backtest (CSCV, 16 blocchi): quante volte la configurazione migliore in-sample finisce sotto la mediana out-of-sample. |
|
||||
| **Falsificazione** | I test di §9.2: ZScore contro PipDivergence, averaging on/off, con e senza stop, cost gate a 2×/3×/4×, segnale invertito. Servono a rompere il risultato, non a confermarlo. |
|
||||
| **Forward test** | Il periodo in Demo con metrica, soglia e durata scritte prima (`knowledge/preregistrazione.csv`). |
|
||||
| **Blackout** | Niente entrate 45 minuti prima e 30 dopo un evento ad alto impatto sulle valute del basket. |
|
||||
| **PSI** | Population Stability Index: misura la deriva della distribuzione di una feature rispetto all'addestramento. |
|
||||
| **HAR-RV** | Modello eterogeneo autoregressivo della varianza realizzata (medie a 1, 5, 22 giorni). |
|
||||
| **Run id** | Identificatore della sessione del bot, scritto in ogni riga del ledger con l'hash di `strategy.json`. |
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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.
|
||||
- **Il cancello di correlazione ρ_W ≤ −0,6 si apre di rado sulle barre M15**: nelle 4 ore di Demo del 2026-09-16 31 segnali su 31 sono stati rifiutati per quel solo motivo. Non è un bug: è la soglia della specifica; va misurata sul ledger prima di proporre un valore diverso (`knowledge/proposals.csv`).
|
||||
- I costi del backtest sono assunzioni (spread tipici pubblicati o spread dei tick, overnight 0,3 o 0,9 pip/gamba/giorno). Il costo vero si misura solo nel ledger del Demo.
|
||||
|
||||
## eToro
|
||||
|
||||
- L'endpoint delle candele non pagina: al massimo ~10 giorni di M15. Lo storico dipende dai tick forniti dall'utente.
|
||||
- L'API demo mostra spread di mercato di 0,1-0,7 pip senza markup e un overnight di 0,91 USD/giorno per 10 000 EURUSD. Se l'esecuzione reale applica uno spread diverso, lo si vedrà dallo slippage scritto nel ledger a ogni ingresso.
|
||||
- Il campo dei costi si chiama `value` (non `amount`, come si era scritto in prima battuta): corretto il 2026-09-16 pomeriggio; le righe del ledger della mattina hanno `markupA/B = 0` e `overnight` nullo per questo motivo.
|
||||
- Il conto reale dell'utente vale 193,18 USD: con l'esposizione minima di 1000 USD per gamba il Live non è praticabile a prescindere dai cancelli.
|
||||
|
||||
## Feed
|
||||
|
||||
- **Google News** vieta `/rss/search` nel `robots.txt`: le cinque query (EURUSD, SNB, RBNZ, RBA, forex) non vengono scaricate e restano vuote. SNB e RBNZ non hanno quindi nessuna fonte; RBA solo il feed ufficiale, che risponde 403 a intermittenza (Akamai). Il sentiment su CHF, NZD e in parte AUD è di fatto zero.
|
||||
- Il feed della Fed risponde 404 a tratti (osservato alle 15:16 UTC+2 del 2026-09-16): la cache copre i buchi.
|
||||
- Il calendario FairEconomy è settimanale: la settimana successiva compare solo da domenica.
|
||||
|
||||
## Bot
|
||||
|
||||
- **Una sola istanza** per cartella di lavoro: non c'è un lock; due bot sullo stesso conto si contendono le posizioni. Documentato nel runbook, non imposto dal codice.
|
||||
- Le posizioni salvate in `baskets_state.json` da una modalità diversa non vengono riprese (si riparte dalla riconciliazione del conto).
|
||||
- Il ciclo settimanale gira solo mentre il bot è acceso la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni).
|
||||
- La finestra e l'headless usano lo stesso log e lo stesso ledger: se si avviano insieme le righe si mescolano.
|
||||
|
||||
## Codice
|
||||
|
||||
- `BasketEngine.cs` è un file unico di ~1900 righe: funziona, ma un intervento vi costa più di quanto dovrebbe. Da spezzare (quote poller, riconciliazione, snapshot) in una sessione dedicata.
|
||||
- I test dell'interfaccia rendono le pagine in memoria (`UiRenderTests`, con `ENCELADO_RENDER_DIR`), non il comportamento della finestra vera (dialoghi, timer).
|
||||
- Il test (l) copre i blocchi nel decisore, non la simulazione completa dell'equity stop nel motore live; quella è coperta dal backtest (`EquityStops` in `BacktestResult`) e dal ledger.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Schema del ledger e delle tabelle
|
||||
|
||||
Regole comuni: UTC ovunque, `CultureInfo.InvariantCulture` per numeri e date, CSV con separatore `;` e ultima colonna `motivazione`, JSONL append-only. **Nessuna riga viene mai modificata**: le correzioni sono righe nuove con `evento = correzione`. Ogni riga porta `run_id` e, dove ha senso, `config_hash` (SHA-256 abbreviato di `strategy.json` canonicalizzato).
|
||||
|
||||
## `data/ledger/decisions.jsonl`
|
||||
|
||||
Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15 (ingresso, skip, aggiunta, posizione, uscita) più le uscite decise su una quotazione intermedia e gli esiti di esecuzione. Le feature sono quelle disponibili **al momento della decisione**: è la regola anti look-ahead, e il dataset di addestramento è questo file, non una ricostruzione.
|
||||
|
||||
| Campo | Tipo | Significato |
|
||||
|---|---|---|
|
||||
| `ts` | ISO 8601 UTC | istante della valutazione |
|
||||
| `run_id` | testo | `yyyyMMdd-HHmmss-xxxxxx` della sessione del bot |
|
||||
| `config_hash` | testo | hash di `strategy.json` in vigore |
|
||||
| `basket` | testo | `A/B`, es. `EURUSD/USDCHF` |
|
||||
| `basket_id` | testo | id del basket aperto (`B<yyyyMMddHHmmss>-<AB>`), vuoto se piatto; collega a `baskets.csv` |
|
||||
| `cross` | testo | cross sintetico (`EURCHF`) |
|
||||
| `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` |
|
||||
| `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` |
|
||||
| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione` |
|
||||
| `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` |
|
||||
| `buy_cross` | bool | verso deciso (compra il cross = compra entrambe le gambe nei cinque basket) |
|
||||
| `z`, `z_in_eff` | numero | z-score del cross e soglia effettiva (scalata dalla vol prevista) |
|
||||
| `D_pips` | numero | divergenza in pip dall'ancora (solo `PipDivergence`) |
|
||||
| `rho_W`, `rho_20` | numero | correlazione rolling dei rendimenti su `window` e `windowShort` |
|
||||
| `halfLife` | numero | emivita OLS in barre (null se λ ≥ 0) |
|
||||
| `atrA`, `atrB` | numero | ATR(14) in pip |
|
||||
| `sigmaX`, `ewmaVolX` | numero | deviazione standard del livello del cross sulla finestra; vol EWMA dei rendimenti del cross |
|
||||
| `sigmaForecast`, `sigmaAverage30d` | numero | vol prevista 1-4 h e media 30 g (null finché il livello 8.5 non è attivo) |
|
||||
| `costPips`, `breakEvenWinRate` | numero | costo stimato in pip-equivalenti di A; win rate di pareggio |
|
||||
| `spreadA`, `spreadB`, `markupA`, `markupB` | numero | spread correnti in pip; markup dell'API in pip |
|
||||
| `hourSin`, `hourCos`, `dow` | numero | ora sul cerchio; giorno della settimana (0 = domenica) |
|
||||
| `minutesToNextHigh`, `minutesSinceLastHigh`, `surpriseLast` | numero/null | calendario per le valute del basket |
|
||||
| `netSentDiff_1h/4h/24h`, `hawkishDiff`, `riskOff`, `newsCount` | numero | sentiment (valuta lunga − valuta corta del cross) |
|
||||
| `regimeTrend` | numero | forza di trend (ADX-like) del cross |
|
||||
| `lastNOutcomes` | numero | media degli ultimi esiti (null finché non c'è storia) |
|
||||
| `p_ML`, `mlActive` | numero, bool | probabilità del meta-modello e se è gate o ombra |
|
||||
| `equity`, `openBaskets` | numero | equity e basket aperti al momento |
|
||||
| `priceA`, `priceB`, `pipsOpen`, `pnlOpenUsd`, `barsHeld` | numero | stato della posizione (se aperta) |
|
||||
| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage` | numero | sizing (solo su `Enter`) |
|
||||
| `reasonCodes` | array | codici: `no_signal`, `rho_low`, `rho_short_low`, `half_life`, `blackout_before`, `blackout_after`, `weekend`, `just_opened`, `session`, `max_baskets`, `same_cross`, `ml_gate`, `cost_gate`, `sizing`, `kill_switch`, `equity_stop`, `daily_loss`, `entries_blocked`, `data_quality`, `warmup`, `not_bar_close`, `enter`, `add`, `hold`, `tp_pips`, `tp_z`, `stop_z`, `stop_max_loss`, `spread_anomaly`, `time_stop`, `rho_break` |
|
||||
| `motivazione` | testo | la frase, in italiano, con i numeri |
|
||||
|
||||
## `data/ledger/baskets.csv`
|
||||
|
||||
Una riga per basket chiuso. `label = 1` se `pnl_net_usd > 0`, altrimenti 0: è l'etichetta dei livelli 1-3.
|
||||
|
||||
```
|
||||
basket_id;run_id;basket;mode;preset;opened_utc;closed_utc;buy_cross;entry_z;exit_z;pnl_gross_usd;pnl_net_usd;pips_gross;cost_pips;cost_usd;slippage_pips;adds;bars_held;exit_reason;equity_at_entry;p_ml_at_entry;label;durata_min;motivazione
|
||||
```
|
||||
|
||||
`pips_gross` è la somma dei pip delle due gambe ai prezzi di esecuzione (la colonna "Pips" della UI), `cost_pips` il costo stimato all'ingresso, `slippage_pips` la differenza fra quotazione vista e prezzo eseguito sommata sulle gambe, `exit_reason` uno dei codici sopra più `manual`, `closed_by_broker`, `leg_closed_by_broker`, `end_of_data`.
|
||||
|
||||
## `results/trials.csv`
|
||||
|
||||
Una riga per configurazione provata nel backtest; N del Sharpe deflazionato = numero di righe.
|
||||
|
||||
```
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
```
|
||||
|
||||
`break_even_cost` = media per basket di (pip eseguiti + costo stimato), cioè i pip "mid-to-mid" catturati: il costo di giro che azzera il risultato. `p1_pnl`, `p5_pnl` = percentili 1 % e 5 % del P&L per basket (la coda che il win rate nasconde).
|
||||
|
||||
## `reports/falsificazione.csv`
|
||||
|
||||
```
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
```
|
||||
|
||||
## `knowledge/calibration.csv`
|
||||
|
||||
Win rate e P&L netto medio per bucket: `dimensione;bucket;n;win_rate;pnl_medio;pnl_totale;motivazione`, con dimensioni `|z|`, `rho_W`, `ora`, `giorno`, `minuti_evento`, `sentiment`, `preset`, `basket`.
|
||||
|
||||
## `knowledge/preregistrazione.csv`
|
||||
|
||||
Una riga per forward test: `data;config_hash;modalita;durata_minima;n_minimo_basket;sharpe_atteso;win_rate_atteso;dd_stop;stop_basket_consecutivi;esito;motivazione`.
|
||||
|
||||
## `knowledge/proposals.csv`
|
||||
|
||||
`data;origine;parametro;valore_attuale;valore_proposto;evidenza;stato;motivazione` — le proposte del ciclo settimanale; `stato` ∈ {proposta, in forward, accettata, respinta}. Nessuna proposta cambia i parametri live da sola.
|
||||
|
||||
## `knowledge/models_registry.csv`, `knowledge/forward_registry.csv`
|
||||
|
||||
`versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione` (stato ∈ shadow, challenger, champion, ritirato) e `data;config_hash;modalita;basket;pnl_net;sharpe;dd;stato;motivazione`.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Apprendimento: livelli 0-3
|
||||
|
||||
Aggiornato: 2026-09-16. Tutto è costruito da zero nel Core (`src/Encelado.Core/Baskets/Learning/`), senza pacchetti: regressione logistica online, un MLP a 16 unità ReLU con Adam, un bandit di Thompson, due previsori di volatilità e un indice di stabilità (PSI). Il codice del Bot che li usa a runtime è `src/Encelado.Bot/Baskets/LearningState.cs`.
|
||||
|
||||
**Regola che governa tutto**: nessun livello cambia un parametro live da solo. Il meta-modello può soltanto *rifiutare* un ingresso quando è attivo; il bandit *propone* un preset e lo applica solo in Paper/Demo; tutto il resto finisce in `knowledge/proposals.csv` e passa dal forward test pre-registrato.
|
||||
|
||||
## Il dataset
|
||||
|
||||
Una riga per basket **aperto**: le 28 feature scritte nel ledger nel momento della decisione (`decisions.jsonl`, evento `ingresso`), unite per `basket_id` all'esito scritto alla chiusura (`baskets.csv`: `label` = 1 se il P&L netto è positivo, `pnl_net`). Le feature non vengono mai ricostruite a posteriori: il dataset è il ledger (`LearningState.BuildDataset`).
|
||||
|
||||
| # | Feature | Origine |
|
||||
|---|---|---|
|
||||
| 0-3 | `z`, `abs_z`, `z_in_eff`, `d_pips` | z-score del cross sintetico, soglia effettiva dopo la scala di volatilità, divergenza in pip |
|
||||
| 4-6 | `rho_w`, `rho_20`, `half_life` | correlazione rolling (finestra W e 20 barre), semiperiodo OLS |
|
||||
| 7-10 | `atr_a`, `atr_b`, `sigma_x`, `ewma_vol_x` | volatilità delle gambe e del cross |
|
||||
| 11 | `trend_strength` | forza di trend (ADX-like) del cross |
|
||||
| 12-15 | `spread_a`, `spread_b`, `cost_pips`, `break_even_win_rate` | costi del momento |
|
||||
| 16-18 | `hour_sin`, `hour_cos`, `day_of_week` | ora UTC ciclica, giorno |
|
||||
| 19-20 | `minutes_to_high`, `minutes_since_high` | calendario (troncati a 24 h) |
|
||||
| 21-23 | `sent_1h`, `sent_4h`, `hawkish_diff` | sentiment (valuta lunga − valuta corta del cross) |
|
||||
| 24 | `risk_off` | sentiment risk-off |
|
||||
| 25 | `vol_ratio` | σ prevista / σ media 30 giorni |
|
||||
| 26 | `last_outcomes` | media degli ultimi 10 esiti |
|
||||
| 27 | `buy_cross` | direzione |
|
||||
|
||||
I nomi sono in `LearningFeatures.Names`; il test `LeakTests` verifica che nessun nome contenga l'esito e che un'etichetta presa dal futuro non sia apprendibile (AUC ≈ 0,5).
|
||||
|
||||
## Livello 0 — Calibrazione
|
||||
|
||||
`CalibrationTables.Build` raggruppa i basket chiusi per basket, preset, terzile di volatilità, ora del giorno, bucket di |z| e di costo, e scrive win rate e P&L medio per bucket in `knowledge/calibration.csv` (colonna `motivazione` con il conteggio). Serve a leggere dove la strategia paga e dove no, e a niente altro: non cambia soglie.
|
||||
|
||||
## Livello 1 — Logistica online (il campione)
|
||||
|
||||
`OnlineLogistic`: pesi su 28 feature standardizzate con statistiche rolling (`RollingStandardizer`, emivita 200 righe), SGD con L2 = 10⁻³ e tasso 0,01/√(1+n/100). Predice a ogni chiusura di barra (`p_ML` nella dashboard, "in ombra") e impara a ogni chiusura di basket. Stato in `data/models/logreg_current.json`; versioni datate `logreg_vN.json` con `trained_on_until` e hash del dataset.
|
||||
|
||||
**Valutazione walk-forward** (`ModelEvaluator.EvaluateLogistic`): sequenziale, predici-poi-aggiorna, con i primi 30 basket di burn-in esclusi dalle metriche. Metriche: AUC con intervallo bootstrap (1000 ricampionamenti), Brier, log-loss, curva di calibrazione in 10 bin, P&L di tutti i basket contro P&L dei soli basket con p ≥ `mlMinProbability`, Sharpe e DSR del filtrato.
|
||||
|
||||
**Attivazione** (§8.3 della specifica), tutte insieme:
|
||||
|
||||
1. almeno **300** basket chiusi;
|
||||
2. AUC walk-forward ≥ **0,55** con l'intervallo bootstrap che esclude 0,50;
|
||||
3. P&L filtrato migliore del P&L non filtrato **e** DSR del filtrato ≥ **0,95**.
|
||||
|
||||
Quando è attivo, un ingresso con p < `mlMinProbability` (0,55) viene rifiutato (`ml_gate` nel ledger). **Disattivazione**: se l'AUC mobile sugli ultimi 100 basket scende sotto **0,52** il modello torna in ombra e lo scrive in `models_registry.csv`.
|
||||
|
||||
Stato del 2026-09-16: **0 basket chiusi nel ledger** → il modello è in ombra e non è valutabile. Nessuna cifra qui è un risultato.
|
||||
|
||||
## Livello 2 — MLP challenger
|
||||
|
||||
`SmallMlp`: 28 → 16 ReLU → 1 sigmoide, inizializzazione Glorot con seme fisso, Adam (β 0,9/0,999), L2 = 10⁻⁴, mini-batch 8-64. Addestrato dal ciclo settimanale in **5 fold cronologici con purga ed embargo di 24 ore** attorno al fold di test, **5 semi** mediati, early stopping sull'ultimo 20 % (cronologico) dei dati di addestramento con pazienza 20 epoche. Lo standardizzatore viene adattato all'intero insieme di addestramento prima del fit (le statistiche rolling partono da zero e distorcono le prime righe: scoperto e corretto con il test sul cerchio, vedi `ModelTests`).
|
||||
|
||||
Il **gradient check** (`SmallMlp.GradientCheck`, test `TheMlpGradientMatchesTheNumericalOne`) confronta il gradiente analitico di ogni peso vivo del primo strato con la differenza centrale numerica: scarto relativo < 10⁻⁴.
|
||||
|
||||
Promozione a campione: solo se batte la logistica di almeno 0,01 di AUC walk-forward **e** supera gli stessi cancelli di attivazione, e comunque solo dopo il forward test. Fino ad allora è registrato come `challenger` in `models_registry.csv`.
|
||||
|
||||
## Livello 3 — Bandit sui preset
|
||||
|
||||
`ThompsonBandit`: una Beta(α, β) per braccio = preset × terzile di volatilità prevista (3 × 3). A ogni chiusura il braccio usato riceve 1 se il basket è positivo. La proposta campiona dalle posteriori con un **tetto del 10 %** alle scelte esplorative (`ExplorationCap`; test `TheBanditKeepsExplorationUnderTheCap`). In Paper e Demo la proposta viene applicata a caldo (i basket aperti non vengono toccati) e scritta nel ledger come correzione; in Live mai.
|
||||
|
||||
## Previsione della volatilità
|
||||
|
||||
`VolForecaster`: sui rendimenti a 15 minuti del cross calcola la varianza realizzata giornaliera e mantiene due previsori a 1-4 ore, **EWMA** (span 100) e **HAR-RV** (OLS sulle medie a 1, 5 e 22 giorni, rifittato ogni giorno). Ogni giorno confronta l'errore quadratico delle due previsioni sulla finestra mobile e usa quello migliore (`ActiveModel`). Il rapporto σ prevista / σ media 30 giorni scala la soglia `z_in` (`volScaleZIn`) e la size, ed è la feature `vol_ratio`.
|
||||
|
||||
## Deriva (PSI)
|
||||
|
||||
`Psi.Compute` confronta la distribuzione di ogni feature nelle ultime 50 decisioni con quella del dataset di addestramento (10 bin). Sopra 0,25 la feature è in deriva; con tre feature in deriva il meta-modello, se attivo, torna in ombra fino al ciclo successivo. Il ciclo settimanale scrive il PSI nel file degli insight.
|
||||
|
||||
## Ciclo settimanale
|
||||
|
||||
`LearningState.RunCycle`, la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni):
|
||||
|
||||
1. ricostruisce il dataset dal ledger;
|
||||
2. valuta e riaddestra logistica (walk-forward) e MLP (fold purgati);
|
||||
3. scrive `knowledge/calibration.csv`, `knowledge/insights_YYYYWW.md` (cosa ha funzionato, calibrazione, meta-modello, bandit, parametri suggeriti), `knowledge/models_registry.csv`, `knowledge/proposals.csv` (una riga per proposta, con evidenza e stato `proposta`);
|
||||
4. salva i modelli con versione.
|
||||
|
||||
**Le proposte non toccano niente.** Il percorso per cambiare un parametro live è: proposta → `knowledge/preregistrazione.csv` (metrica, soglia, periodo, N minimo, scritti prima) → forward test in Paper/Demo → `forward_registry.csv` → decisione umana.
|
||||
|
||||
## Cosa è stato escluso, e perché
|
||||
|
||||
- **LSTM / Transformer / RL profondo**: con qualche centinaio di basket l'anno per cinque coppie, un modello con migliaia di parametri impara il rumore del campione; la regressione logistica e un MLP minuscolo sono già al limite di ciò che il dataset può sostenere. Il costo (settimane di lavoro e di calcolo) non è giustificato da nessun indizio che un modello più ricco troverebbe struttura dove i test di falsificazione non ne trovano.
|
||||
- **Feature ricostruite a posteriori**: il ledger scrive ciò che il bot sapeva; ricostruire feature dopo è il modo più facile di introdurre look-ahead.
|
||||
- **Ottimizzazione automatica dei parametri**: la griglia del backtest serve a *sapere*, non a *scegliere*; ogni prova conta nel DSR.
|
||||
|
||||
## File
|
||||
|
||||
| File | Contenuto |
|
||||
|---|---|
|
||||
| `data/models/logreg_current.json`, `mlp_current.json`, `bandit.json` | stato corrente (ripreso all'avvio) |
|
||||
| `data/models/logreg_vN.json`, `mlp_vN.json` | versioni del ciclo settimanale con `trained_on_until`, righe, hash del dataset, nomi delle feature |
|
||||
| `data/models/learning_state.json` | attivo/ombra, campione, versione, ultimi 200 (p, esito) per l'AUC mobile, feature dei basket aperti |
|
||||
| `knowledge/calibration.csv`, `insights_YYYYWW.md`, `models_registry.csv`, `proposals.csv`, `forward_registry.csv`, `preregistrazione.csv` | vedi `docs/LEDGER_SCHEMA.md` |
|
||||
|
||||
Test: `tests/Encelado.Tests/LearningTests.cs` (i: gradient check, apprendimento walk-forward, MLP contro logistica su una regola non lineare, bandit, volatilità; j: leak; l: blocchi).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Domande e risposte
|
||||
|
||||
Ogni domanda è numerata per fase. Quando l'utente non ha risposto, è stato applicato il default più prudente e la scelta è segnata come **default applicato**: resta aperta finché non arriva una risposta.
|
||||
|
||||
## Fase 0 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-01 | Il bot è già in C#? Quale target framework? | quello del repo | **Risposto dal repo**: C#, `net10.0` (Bot e test `net10.0-windows`), SDK 10.0.301. Nessuna proposta di cambio. |
|
||||
| D-02 | UI attuale: console, WinForms o WPF? Posso aggiungere un progetto WPF? | nuovo progetto WPF + headless | **Risposto dal repo**: è già WPF (`Encelado.Bot`, tema scuro proprio). Non si aggiunge un progetto: si aggiungono pagine alla shell esistente e la modalità `--headless` nello stesso eseguibile. |
|
||||
| D-03 | Valuta del conto eToro e disponibilità di chiavi demo? | USD, demo | **Verificato via API** (collegamento MCP dell'utente, sola lettura): conto in **USD**; `demoCid` e `realCid` esistono. Le chiavi long-lived (`x-api-key` + `x-user-key`) non sono ancora state fornite al bot: la finestra di accesso le chiede e le salva cifrate (DPAPI). **Default applicato: USD, demo.** |
|
||||
| D-04 | Regola di approvazione: automatismo consentito già in demo? | `DemoApprove` | **Default applicato: `DemoApprove`**. `DemoAuto` richiede `etoro.allowDemoAuto = true` in `encelado.json` e la conferma all'avvio (finestra, o `--confirm-demo-auto` in headless). Le modalità Live richiedono `etoro.allowLive = true` e la frase `CONFERMO LIVE`. L'utente ha chiesto una lunga sessione di test "sperando di piazzare trade": senza risposta il test lungo gira in `Paper` (simulatore locale sopra le quote reali) o in `DemoApprove` con approvazione manuale. |
|
||||
| D-05 | Gli 8 strumenti sono disponibili sul conto? Spread tipici? | verifica via API | **Verificato via API il 2026-09-16 07:23 UTC**: tutti e 8 disponibili (id: EURUSD 1, USDCHF 6, AUDUSD 7, USDCAD 4, NZDUSD 3, EURNZD 49, EURAUD 12, AUDCAD 47; anche EURCHF 9 ed EURCAD 13 per `PreferDirectCross`). Spread di mercato osservati senza markup: 0,1 pip sulle majors, 0,3-0,7 pip sui cross. Il markup di eToro si legge dall'endpoint dei costi e viene sommato nel cost gate. Esposizione minima 1000 USD per posizione, leva fino a 30 (majors) / 20 (minors). **Attenzione**: il conto reale vale 193,18 USD; con `RiskPerBasket` 0,5 % e esposizione minima 1000 USD il reale non è operabile senza leva alta: il passaggio a `LiveApprove` resta comunque subordinato ai cancelli di §9.4. |
|
||||
| D-06 | Dove gira il bot (PC locale Windows, VPS Windows)? | PC locale + headless pronto per VPS | **Default applicato**: Windows 11 locale (questa macchina); `--headless` disponibile per un VPS Windows. |
|
||||
| D-07 | Esiste già uno storage/log da riusare? | nuovi file in `data/` | **Default applicato con una precisazione**: il log applicativo (`Log`, file `;`) e `CsvTable` vengono riusati; il ledger, i dati di mercato, i modelli e la base di conoscenza vanno in file (`data/`, `knowledge/`, `reports/`, `results/`) sotto `Documenti\Encelado\`, come richiesto. Il database SQLite esistente resta per il motore `proba` e non viene usato dal modulo basket. |
|
||||
| D-08 | Se una fonte news/calendario risulta irraggiungibile: sostituire o omettere? | omettere e annotare | **Verificato il 2026-09-16**: calendario FairEconomy (JSON e XML), FXStreet, ForexLive, Fed, ECB (`https://www.ecb.europa.eu/rss/press.html`), BoE (`https://www.bankofengland.co.uk/rss/news`), RBA (`https://www.rba.gov.au/rss/rss-cb-media-releases.xml`), BoC (`https://www.bankofcanada.ca/content_type/press-releases/feed/`) e Google News rispondono 200. **SNB** (`/en/rss/press-releases` → 404) e **RBNZ** (403 "website unavailable") no: **default applicato: omesse**, coperte da Google News con query mirate (`SNB`, `RBNZ`). Annotato in `docs/DATA_SOURCES.md`. |
|
||||
| D-09 | Che fare del motore cTrader/ProbaBot trovato a metà e non committato? | mantenerlo selezionabile | **Default applicato**: resta nel repo, rimesso in compilazione (riferimento di progetto e piccoli fix) e selezionabile con `engine.strategy = "proba"`; il predefinito diventa `"baskets"`. Nessun comportamento esistente viene cambiato. Se l'utente preferisce eliminarlo, basta rimuovere `src/Encelado.CTrader` e `Engine/ProbaEngine.cs`. |
|
||||
| D-10 | Dove stanno i parametri della strategia: in `encelado.json` o in un file separato? | `config/strategy.json` come da specifica | **Default applicato**: `strategy.json` separato (copia di fabbrica in `config/`, copia dell'utente in `Documenti\Encelado\`), letto con `JsonDocument`; `instruments.json` scritto dal bot all'avvio nella stessa cartella. `encelado.json` riceve solo le sezioni `etoro` e `engine.strategy`. |
|
||||
| D-11 | Fuso orario dei tick MT5 in `A:\Download\Trading`? | verificare sul fine settimana | **Verificato**: la chiusura del venerdì cade alle 20:53-20:57 in estate e alle 21:53-21:57 in inverno, la riapertura alle 21:05 (estate) / 22:05 (inverno) della domenica: è **UTC**. Nessuna conversione. Formato: tab-separato `<DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>`; le righe con solo bid o solo ask (flag 2/4) aggiornano un solo lato. |
|
||||
| D-12 | Lo storico M15 via API eToro si può scaricare paginando? | sì, 1000 barre per richiesta | **Verificato: no.** L'endpoint delle candele accetta solo `count ≤ 1000` e la direzione, senza data di partenza: fornisce al massimo ~10 giorni di M15. Il backtest usa i tick forniti dall'utente; l'API serve per riscaldamento (ultime 1000 barre) e riconciliazione. |
|
||||
| D-13 | Il TP di basket "in pip" con lotti diversi fra le gambe: pip lordi sommati come Titany, o P&L netto? | come da specifica | **Default applicato**: `Pips` di basket = somma dei pip delle due gambe (UI e `ExitMode = FixedPips`); ogni decisione di stop usa il P&L netto in USD; entrambi finiscono nel ledger. |
|
||||
| D-14 | Le credenziali eToro per il bot: quando? | attendere | L'utente ha scritto: «Aspetta l'input per le credenziali per la prima volta e poi potrai aprirlo in autonomia quando memorizzerò la password». La finestra di accesso chiede `x-api-key` e `x-user-key` e li salva in `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI). Finché non ci sono, il bot in headless resta in sola lettura e lo dice. |
|
||||
|
||||
## Fase 1 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-15 | Leva da usare su ogni gamba (l'API la richiede per ordine)? | 10 | **Default applicato**: `orderLeverage = 10` (ammessa su tutte le 8 coppie); l'esposizione complessiva resta comunque ≤ 10:1 sul nozionale (`MaxEffectiveLeverage`) e lo stop nativo di eToro viene messo alla distanza coerente con `MaxLossPerBasket%`, dentro i limiti di eligibility. |
|
||||
| D-16 | Overnight: usare il valore dell'endpoint dei costi o una tabella? | endpoint | **Default applicato**: l'endpoint dei costi (`overnightFee`, `overWeekendFee`) quando disponibile; in backtest una tabella configurabile per coppia (`overnightPipsPerDay`, default 0,3 pip/gamba/giorno, ×3 nel fine settimana). |
|
||||
|
||||
## Fasi 2-7 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-17 | Lo spread anomalo (> 3 × mediana) deve chiudere il basket alla prima barra o dopo una persistenza? | persistenza | **Default applicato**: chiusura forzata solo dopo **3 barre chiuse consecutive** sopra la soglia. Nel backtest la chiusura immediata scattava sui picchi di spread e perdeva sistematicamente (`BasketPosition.BarsWithSpreadAnomaly`). |
|
||||
| D-18 | Nel backtest l'equity stop blocca tutto per sempre o riparte? | riparte | **Default applicato**: dopo lo stop il picco riparte dall'equity corrente e il numero di stop viene contato (`EquityStops` nel riepilogo); altrimenti il primo stop del 2019 avrebbe fermato sette anni di prova. Dal vivo lo stop richiede il reset manuale. |
|
||||
| D-19 | Fed risponde 404 e RBA "Access Denied" con lo User-Agent minimale: cambiare UA? | UA esplicito del bot | **Applicato**: `Encelado/4.0 (+correlation baskets; contact: operator)`. La Fed risponde; RBA (Akamai) a intermittenza. Dopo due errori consecutivi il feed logga solo a debug e ritenta con attese crescenti. |
|
||||
| D-20 | Per il test lungo in demo: `DemoAuto` (bot autonomo) o `DemoApprove`? | DemoAuto | **Risposta dell'utente (2026-09-16 15:00)**: «tutti gli Approve devono sparire, almeno per il momento. Il bot deve girare in completa autonomia aprendo e chiudendo le posizioni senza il mio consenso». Modalità ridotte a `Paper`, `Demo`, `Live`; coda delle approvazioni rimossa (ADR-0005). Il Live conserva flag e frase `CONFERMO LIVE`. |
|
||||
| D-21 | La pulizia delle «vecchie gestioni» deve includere anche cTrader/proba e la pipeline di ricerca? | sì, tutto | **Risposta dell'utente**: «Tutto: resta solo eToro + basket». Rimossi `Encelado.CTrader`, `Encelado.Storage`, ricerca, indicatori, RL, TA-Lib e i test relativi (ADR-0004). |
|
||||
| D-22 | Versione del rilascio su Gitea? | 4.0.0 | **Risposta dell'utente**: 4.0.0 (nuovo broker, nuova strategia, configurazione incompatibile). |
|
||||
| D-23 | Fuso orario della finestra: quello del computer o selezionabile? | computer, selezionabile | **Applicato**: `ui.timeZone` = `computer` di fabbrica; elenco dei fusi di Windows in Impostazioni; `ENCELADO_TIME_ZONE` da ambiente. Solo la finestra cambia: il log porta l'offset, il ledger è UTC. |
|
||||
| D-24 | L'endpoint dei costi restituiva markup e overnight a zero: era davvero zero? | verificare | **Verificato via API il 2026-09-16 12:45 UTC**: il campo si chiama `value`, non `amount`. EURUSD 10 000 unità leva 10: markup 0,0, spread di mercato 0,1 USD (0,1 pip), overnight 0,91 USD/giorno (≈ 0,9 pip/gamba/giorno). Parser corretto; aggiunto lo scenario di costi `api` al backtest. |
|
||||
| D-25 | Google News vieta `/rss/search` nel robots.txt: forzare, sostituire o omettere? | omettere | **Default applicato: omettere** (il bot rispetta il robots.txt). SNB e RBNZ restano senza fonte; documentato in `KNOWN_ISSUES.md`. |
|
||||
@@ -0,0 +1,36 @@
|
||||
# Regole di sicurezza e approvazione
|
||||
|
||||
Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e chi può cambiarlo. "Operatore" è chi modifica i file in `Documenti\Encelado` o usa la finestra; "codice" vuol dire che non esiste una chiave di configurazione.
|
||||
|
||||
| Regola | Default | Dove | Chi la cambia |
|
||||
|---|---|---|---|
|
||||
| Modalità di esecuzione | `Demo` | `encelado.json` → `run.executionMode` (`Paper`, `Demo`, `Live`) | operatore; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` scritta all'avvio (o `--confirm-live "CONFERMO LIVE"` in headless) |
|
||||
| Approvazione dei singoli ordini | nessuna, in nessuna modalità (D-20, ADR-0005) | codice | nessuno. Il bot apre, aggiunge e chiude da solo; i gate umani sono l'avvio del reale, il kill-switch, il reset dopo un equity stop e il cambio di preset |
|
||||
| Equity stop | 9 % dal picco di equity | `strategy.json` → `equityStopPct` | operatore; scatta → chiude tutto, blocca, richiede reset con motivazione scritta (finestra o `reset <motivo>` in headless), che finisce nel ledger; il picco riparte dall'equity del reset |
|
||||
| Perdita giornaliera massima | 3 % dell'equity di inizio giornata (UTC) | `strategy.json` → `dailyLossPct` | operatore; blocca le nuove entrate fino al giorno dopo, non chiude |
|
||||
| Rischio per basket | 0,25 / 0,50 / 1,00 % (preset) | `strategy.json` → preset o `riskPerBasketPct` | operatore; il cambio di preset a caldo non tocca i basket aperti |
|
||||
| Perdita massima per basket | 1,5 % dell'equity all'ingresso | `strategy.json` → `maxLossPerBasketPct` | operatore; mai disattivabile |
|
||||
| Stop di basket su z | 3,0 / 3,5 / 4,0 (preset) | `strategy.json` → preset o `zStop` | operatore; mai disattivabile (solo il backtest lo spegne, nel test di falsificazione 3) |
|
||||
| Basket aperti | 2 / 3 / 5 (preset) | `strategy.json` → preset o `maxBaskets` | operatore |
|
||||
| Un solo basket per cross sintetico | `Exclusive` | `strategy.json` → `sameCrossPolicy` | operatore (`Half` dimezza la size di entrambi) |
|
||||
| Leva effettiva massima | 10:1 sul nozionale complessivo | `strategy.json` → `maxEffectiveLeverage` | operatore, tetto 30 |
|
||||
| Leva dichiarata per gamba | 10 | `strategy.json` → `orderLeverage` | operatore; la leva effettiva resta governata dal sizing |
|
||||
| Stop nativo su ogni gamba | sì, sempre (eToro lo richiede su short e leva > 1) | codice (`BasketExecutor.Request`) | nessuno; la distanza deriva da `maxLossPerBasketPct` entro i limiti di eligibility |
|
||||
| Cost gate | TP ≥ 3 × costo; spread ≤ 2 × mediana 24 h | `strategy.json` → `costMultiple`, `spreadMedianMultiple` | operatore |
|
||||
| Spread anomalo | > 3 × mediana per 3 barre chiuse consecutive → chiusura forzata | `strategy.json` → `spreadAnomalyMultiple` (persistenza: codice) | operatore (moltiplicatore) |
|
||||
| Blackout eventi | 45 min prima, 30 dopo, eventi High sulle valute del basket | `strategy.json` → `blackoutBeforeMin`, `blackoutAfterMin` | operatore |
|
||||
| Fine settimana | niente entrate dal venerdì 20:00 UTC alla riapertura, né nei primi 30 min | `strategy.json` → `fridayCutoffUtcHour`, `openDelayMinutes` | operatore |
|
||||
| Scarto orologio | > 5 s → banner e niente nuove entrate | `strategy.json` → `clockSkewMaxSeconds` | operatore; misurato sull'header `Date` di ogni risposta |
|
||||
| API in errore | 5 letture consecutive fallite → niente nuove entrate finché non risponde | codice | nessuno |
|
||||
| Quotazione vecchia | > 15 s → niente nuove entrate | codice (`BasketEngine.MaxQuoteAgeSeconds`) | nessuno |
|
||||
| Qualità dati | buco > 2 h feriale o salto > 8 σ → decisioni sospese su quella barra | codice | nessuno |
|
||||
| Leg-risk | seconda gamba non eseguita entro `legTimeoutSec` (5 s) → chiudi subito la prima, basket in pausa 1 h | `strategy.json` → `legTimeoutSec` (pausa: codice) | operatore (timeout) |
|
||||
| Gamba orfana | una gamba sparisce dal conto → l'altra viene chiusa alla riconciliazione successiva | codice | nessuno |
|
||||
| Chiusura incompleta | una gamba non chiude dopo 3 tentativi → stato `Error`, entrate bloccate, allarme | codice | nessuno; si risolve a mano sul conto e con la riconciliazione |
|
||||
| Kill-switch | pulsante con conferma; file `STOP` in `Documenti\Encelado` (controllato ogni 5 s) | codice | operatore; il reset richiede di rimuovere il file e una motivazione |
|
||||
| Posizioni sconosciute sul conto | segnalate una volta nel log, **mai toccate** | codice | nessuno |
|
||||
| Chiavi API | solo `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o `ETORO_API_KEY`/`ETORO_USER_KEY`; mai nel repo (`.gitignore`: `*.local.json`, `.env`) | codice | operatore |
|
||||
| Ambiente visibile | badge `PAPER/DEMO/LIVE` nella barra, nel log e nel ledger (`mode`) | codice | nessuno |
|
||||
| Controlli all'avvio | chiavi (profilo), orologio, strumenti e limiti, conto, riconciliazione, calendario | codice | nessuno; se falliscono il bot resta in sola lettura o non parte |
|
||||
| Averaging | `Off` in live; `AddOnce` ammesso in paper; moltiplicatore di lotto 1,0 | `strategy.json` → `averagingMode`, `lotMultiplier` (max 1,5, solo backtest) | operatore |
|
||||
| Parametri cambiati dal bot | mai. Le proposte vanno in `knowledge/proposals.csv` e passano dal forward test | codice | operatore |
|
||||
@@ -0,0 +1,92 @@
|
||||
# Runbook
|
||||
|
||||
Aggiornato: 2026-09-16. Come si avvia, si ferma, si sblocca e si ripara il bot. I file dell'operatore stanno in `Documenti\Encelado\`; le chiavi in `%LOCALAPPDATA%\Encelado\etoro.dat`.
|
||||
|
||||
## Prima volta
|
||||
|
||||
1. Avvia `Encelado.exe`. Vengono creati `Documenti\Encelado\encelado.json` (configurazione) e `strategy.json` (strategia) dalle copie di fabbrica.
|
||||
2. La finestra chiede le due chiavi di eToro Public API (`x-api-key` e `x-user-key`, dal portale sviluppatori; demo e reale hanno chiavi diverse). Le verifica con due letture (profilo e conto) e le salva cifrate con DPAPI. Da quel momento il bot parte da solo, anche in `--headless`.
|
||||
3. Controlla in **Impostazioni**: ambiente `demo`, modalità `Demo`, fuso orario.
|
||||
4. Premi **AVVIA**.
|
||||
|
||||
In alternativa alle chiavi salvate: variabili d'ambiente `ETORO_API_KEY` e `ETORO_USER_KEY` (hanno la precedenza), utili su un VPS.
|
||||
|
||||
## Modalità
|
||||
|
||||
| Modalità | Ordini | Conferma all'avvio |
|
||||
|---|---|---|
|
||||
| `Paper` | simulatore locale sopra le quotazioni reali (`data/state/paper_state.json`) | nessuna |
|
||||
| `Demo` (default) | conto demo eToro, denaro virtuale | nessuna |
|
||||
| `Live` | conto reale | `run.allowLive = true` **e** la frase `CONFERMO LIVE` (finestra) o `--confirm-live "CONFERMO LIVE"` (headless) |
|
||||
|
||||
In ogni modalità il bot apre e chiude da solo (decisione D-20). Il badge in alto a destra dice sempre in che ambiente sei.
|
||||
|
||||
## Headless (VPS, test lunghi)
|
||||
|
||||
```powershell
|
||||
Encelado.exe --headless [--minutes 240] [--confirm-live "CONFERMO LIVE"]
|
||||
```
|
||||
|
||||
Log sulla console e nel file; una riga di stato ogni `run.statusSeconds`. Comandi da tastiera: `status`, `close <basket>`, `kill`, `preset <nome>`, `reset <motivazione>`, `stop`. Variabile `ENCELADO_EXECUTION_MODE` per forzare la modalità senza toccare il file.
|
||||
|
||||
**Una sola istanza per cartella di lavoro**: due bot sullo stesso conto e sullo stesso ledger si contendono le posizioni. Prima di aprire la finestra mentre gira l'headless, fermalo.
|
||||
|
||||
## Fermare
|
||||
|
||||
- **FERMA** nella finestra, `stop` in headless, Ctrl+C. I basket aperti **restano sul conto** con gli stop nativi (`run.closeOnShutdown = false`): nessuno applica TP e stop di basket finché il bot non riparte, che li riprende dallo stato salvato e dalla riconciliazione.
|
||||
- Con `run.closeOnShutdown = true` la fermata chiude tutto a mercato.
|
||||
|
||||
## Kill-switch
|
||||
|
||||
Chiude tutte le gambe a mercato e blocca le nuove entrate. Tre modi: il pulsante **KILL-SWITCH** nella dashboard (chiede conferma), `kill` in headless, oppure un file chiamato `STOP` nella cartella `Documenti\Encelado` (controllato a ogni ciclo; utile da remoto). Il blocco resta finché non fai un **reset**.
|
||||
|
||||
## Equity stop e reset
|
||||
|
||||
Quando l'equity scende del 9 % dal picco (`equityStopPct`) il bot chiude tutto e si blocca: banner rosso nella dashboard, riga `equity_stop` nel ledger. Per ripartire: **Sblocca…** nel banner, oppure `reset <motivazione>` in headless. La motivazione (almeno dieci caratteri) finisce nel ledger come riga `correzione`; il picco riparte dall'equity corrente. Non si sblocca senza scrivere perché.
|
||||
|
||||
La perdita giornaliera del 3 % (`dailyLossPct`) blocca solo le nuove entrate fino alla mezzanotte UTC e non richiede reset.
|
||||
|
||||
## Riconciliazione
|
||||
|
||||
Ogni 20 secondi il bot rilegge conto e posizioni. Una gamba sparita dal conto (chiusa a mano, stop nativo) fa chiudere l'altra; una posizione sconosciuta viene segnalata e ignorata; una chiusura incompleta dopo tre tentativi mette il basket in stato `Error` e blocca le nuove entrate (banner giallo) finché non è risolta sul conto: chiudi la gamba a mano su eToro, la riconciliazione successiva la vede e sblocca.
|
||||
|
||||
## Errori API
|
||||
|
||||
| Sintomo | Cosa fa il bot | Cosa fare |
|
||||
|---|---|---|
|
||||
| 401/403 | avvio fallito, "eToro ha rifiutato le chiavi" | rigenera le chiavi sul portale, reinseriscile da Impostazioni |
|
||||
| 429 | rispetta `Retry-After`, rallenta | niente; se persiste alza `run.pollSeconds` |
|
||||
| 5 letture consecutive fallite | banner "entrate bloccate", uscite attive | aspetta; controlla rete e stato di eToro |
|
||||
| scarto orologio > 5 s | banner, entrate bloccate | sincronizza l'ora di Windows |
|
||||
| quotazioni più vecchie di 15 s | niente nuove entrate | come sopra |
|
||||
|
||||
## Feed
|
||||
|
||||
Calendario e notizie sono in cache su disco (`data/cache`) e vengono riletti ogni 10 minuti con attese crescenti dopo un errore. Un feed che non risponde non ferma il bot: senza calendario non c'è blackout, senza notizie il sentiment è 0, e il log lo dice. Google News blocca via `robots.txt` le ricerche RSS: quelle fonti vivono solo di cache (vedi `docs/DATA_SOURCES.md`).
|
||||
|
||||
## File utili
|
||||
|
||||
| Cosa | Dove |
|
||||
|---|---|
|
||||
| log | `Documenti\Encelado\logs\encelado.log` (CSV `;`) |
|
||||
| ledger | `data\ledger\decisions.jsonl`, `data\ledger\baskets.csv` |
|
||||
| stato | `data\state\baskets_state.json` (ripreso all'avvio) |
|
||||
| barre | `data\market\candles_<SYMBOL>_M15.csv` |
|
||||
| modelli | `data\models\` |
|
||||
| conoscenza | `knowledge\` |
|
||||
| strumenti | `instruments.json` accanto alla configurazione |
|
||||
|
||||
## Checklist prima del Live (§9.4 della specifica)
|
||||
|
||||
Tutte vere, altrimenti no:
|
||||
|
||||
- [ ] il forward test in Demo ha almeno 60 basket chiusi e 90 giorni;
|
||||
- [ ] il P&L netto del forward test è positivo con PSR ≥ 0,95 sulla metrica pre-registrata in `knowledge/preregistrazione.csv`;
|
||||
- [ ] nessun test di falsificazione contraddice il risultato (oggi `reports/falsificazione.csv` dice il contrario: vedi `docs/STRATEGY.md`);
|
||||
- [ ] `run.allowLive = true`, ambiente `real`, chiavi del reale inserite e verificate;
|
||||
- [ ] conto reale capiente rispetto a `riskPerBasketPct` e all'esposizione minima di 1000 USD per gamba (con 193 USD non lo è);
|
||||
- [ ] la frase `CONFERMO LIVE` scritta all'avvio.
|
||||
|
||||
## Aggiornare
|
||||
|
||||
L'installatore conserva `Documenti\Encelado` e le chiavi. Se dopo un aggiornamento il log segnala "chiavi di una versione precedente", da Impostazioni → **Ripristina i valori predefiniti** (backup automatico con la data accanto al file).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Stato del lavoro
|
||||
|
||||
Aggiornato: 2026-09-16 (fine della seconda sessione, rilascio 4.0.0).
|
||||
|
||||
## Fase in corso
|
||||
|
||||
**Forward test in Demo.** Il codice copre le fasi 0-7 della specifica; la strategia è in esercizio autonomo sul conto demo di eToro per accumulare basket nel ledger. Il backtest è negativo (`docs/STRATEGY.md`): il Demo misura, non guadagna.
|
||||
|
||||
## Fatto nell'ultima sessione (2026-09-16, pomeriggio)
|
||||
|
||||
- **Rework completo del codice**: rimossi Binance, Alpaca, cTrader/proba, SQLite, GBDT, RL, TA-Lib, indicatori e backtest a coppie (ADR-0004). Restano Core (basket, broker, notizie, statistica), Etoro, Bot, strumento di ricerca. Nessun pacchetto NuGet nell'applicazione. Test da 322 a 172, tutti verdi.
|
||||
- **Niente approvazioni manuali** (decisione dell'utente, D-20, ADR-0005): modalità `Paper` / `Demo` (default) / `Live`; coda delle approvazioni rimossa; il Live conserva `run.allowLive` e la frase `CONFERMO LIVE`.
|
||||
- **Interfaccia rifatta**: barra in alto con tre schede (Dashboard, Log, Impostazioni), stato, ambiente, ora nel fuso scelto, AVVIA; dashboard con i cinque numeri, la tabella dei basket, tre riquadri di contesto e l'attività. Tema nuovo. Test di rendering in PNG (`UiRenderTests`).
|
||||
- **Fuso orario** selezionabile (`ui.timeZone`, default `computer`, elenco dei fusi di Windows in Impostazioni, `ENCELADO_TIME_ZONE`).
|
||||
- **Bug corretto**: l'endpoint dei costi di eToro usa il campo `value`; markup e overnight risultavano 0 (D-24). Overnight osservato 0,9 pip/gamba/giorno.
|
||||
- **Apprendimento collegato al motore**: `LearningState` (logistica in ombra, MLP challenger, bandit, ciclo settimanale, `knowledge/`), previsione di volatilità per basket, feature dal ledger. Standardizzatore adattato all'insieme di addestramento prima del fit dell'MLP (difetto trovato dal test sul cerchio).
|
||||
- **Backtest completato**: test di falsificazione 5 (segnale invertito) e scenario di costi `api`; `docs/STRATEGY.md` con i numeri e il verdetto negativo.
|
||||
- Documenti: `STRATEGY.md`, `ML_AND_LEARNING.md`, `RUNBOOK.md`, `GLOSSARY.md`, `KNOWN_ISSUES.md`, ADR-0004, ADR-0005; aggiornati `ARCHITECTURE.md`, `RISK_RULES.md`, `QUESTIONS.md` (D-17…D-25), `DATA_SOURCES.md`, `LEDGER_SCHEMA.md`, `CLAUDE.md`, catena di rilascio.
|
||||
- Sessione di test autonoma in Demo dalle 12:56 alle 16:56 UTC (4 ore, `--headless`, preset Moderate): collegamento stabile, nessun errore, **31 segnali (|z| ≥ 2) tutti rifiutati dal solo cancello di correlazione** (ρ_W fra +0,14 e −0,42 contro la soglia −0,6), 0 basket aperti, 112 righe nel ledger delle decisioni. Il cancello ρ ≤ −0,6 è la prima cosa da misurare sul ledger nelle prossime settimane prima di proporre qualsiasi cambiamento.
|
||||
|
||||
## Prossimi passi
|
||||
|
||||
1. Lasciare girare il Demo per settimane; leggere `data/ledger/baskets.csv` e `knowledge/insights_*.md` prima di toccare qualsiasi parametro.
|
||||
2. Se il ledger mostra che ρ_W ≤ −0,6 non si verifica mai, proporre in `proposals.csv` una soglia diversa **con** una pre-registrazione, non cambiarla a mano.
|
||||
3. Spezzare `BasketEngine.cs` (~1900 righe) in quote poller, riconciliazione, snapshot.
|
||||
4. Aggiungere un lock di istanza (un solo bot per cartella di lavoro).
|
||||
5. Valutare una fonte per SNB e RBNZ che non sia Google News.
|
||||
|
||||
## Problemi aperti
|
||||
|
||||
- Backtest negativo: la strategia non regge i costi (`docs/STRATEGY.md`, `docs/KNOWN_ISSUES.md`).
|
||||
- Il conto reale vale 193,18 USD: il Live non è praticabile a prescindere.
|
||||
- Google News blocca le ricerche RSS via robots.txt; RBA risponde 403 a intermittenza; Fed 404 a tratti.
|
||||
- Il file di configurazione dell'utente porta ancora `allowDemoAuto` (avviso all'avvio; il ripristino dei valori di fabbrica lo toglie).
|
||||
@@ -0,0 +1,130 @@
|
||||
# Strategia: Correlation Baskets
|
||||
|
||||
Aggiornato: 2026-09-16. Questo documento dice come funziona la strategia e, con i numeri, **se regge**. La risposta sui dati disponibili è **no**: nessuna configurazione è profittevole al netto dei costi di eToro. Il modulo resta uno strumento di forward test in Demo; non c'è nessun risultato che giustifichi il reale.
|
||||
|
||||
## 1. Logica
|
||||
|
||||
Cinque basket di due coppie forex con una valuta in comune:
|
||||
|
||||
| Basket | Comune | Cross sintetico | Gambe |
|
||||
|---|---|---|---|
|
||||
| EURUSD / USDCHF | USD | EURCHF | stesso verso |
|
||||
| AUDUSD / USDCAD | USD | AUDCAD | stesso verso |
|
||||
| NZDUSD / EURNZD | NZD | EURUSD | stesso verso |
|
||||
| USDCAD / EURUSD | USD | EURCAD | stesso verso |
|
||||
| EURAUD / AUDCAD | AUD | EURCAD | stesso verso |
|
||||
|
||||
In tutti e cinque la valuta comune ha ruoli opposti nelle due coppie, quindi `X = ln A + ln B` è il logaritmo del cross e le due gambe si comprano (o si vendono) insieme; la correlazione attesa dei rendimenti è negativa.
|
||||
|
||||
**Segnale** (`ZScoreSynthetic`): `z = (X − media_W) / σ_W` su W = 100 barre M15. Ingresso quando `|z| ≥ z_in` (2,0 nel preset Moderate), venduto il cross se z > 0, comprato se z < 0. Modalità alternativa `PipDivergence`: divergenza in pip fra le due gambe dall'ultimo punto di allineamento.
|
||||
|
||||
**Cancelli all'ingresso** (§5.3): correlazione rolling `ρ_W ≤ −0,6`, semiperiodo fra 5 e 120 barre, forza di trend sotto soglia, blackout del calendario, fine settimana, cost gate (`TP ≥ 3 × costo`, spread ≤ 2 × mediana 24 h), massimo di basket aperti, un solo basket per cross sintetico, quote fresche, orologio allineato, nessun blocco attivo.
|
||||
|
||||
**Sizing** (vol-parity): unità inversamente proporzionali all'ATR di ogni gamba, rischio totale alla distanza dello stop = `riskPerBasketPct` dell'equity (0,5 % Moderate), esposizione minima di eToro 1000 USD per gamba, leva effettiva ≤ 10.
|
||||
|
||||
**Uscite** (§5.4): take-profit di basket in pip (10 nel Moderate) **oppure** rientro dello z sotto `z_out` (0,25), a seconda di `exitMode`; stop di basket a `|z| ≥ z_stop` (3,5) o perdita ≥ 1,5 % dell'equity; time-stop dopo 4 giorni; spread anomalo per 3 barre consecutive; correlazione rotta; kill-switch ed equity stop.
|
||||
|
||||
**Averaging** (§5.5): spento di fabbrica; `AddOnce` e `Grid` esistono solo per il test di falsificazione 2.
|
||||
|
||||
I preset (`strategy.json`):
|
||||
|
||||
| Preset | z_in | rischio/basket | basket max | TP pip | aggiunte | z_stop |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Conservative | 2,5 | 0,25 % | 2 | 8 | 0 | 3,0 |
|
||||
| Moderate | 2,0 | 0,50 % | 3 | 10 | 1 | 3,5 |
|
||||
| Aggressive | 1,5 | 1,00 % | 5 | 12 | 2 | 4,0 |
|
||||
|
||||
## 2. Dati e costi del backtest
|
||||
|
||||
- Tick MetaTrader 5 (UTC) dal 2018-12-12 al 2026-09-15, convertiti in barre M15 bid/ask (`backtest ticks`); ~192 000 barre per coppia, EURAUD solo 39 700 (parti del 2018, 2021, 2026).
|
||||
- Decisione alla chiusura della barra, esecuzione all'apertura della successiva sul lato giusto del book più 0,3 pip di slippage per gamba.
|
||||
- Due scenari di costo, entrambi assunzioni:
|
||||
- **etoro**: spread minimo per coppia = spread tipico pubblicato da eToro (EURUSD 1,0, USDCHF 1,5, AUDUSD 1,0, USDCAD 1,5, NZDUSD 2,5, EURNZD 5,0, EURAUD 3,0, AUDCAD 3,0 pip), overnight 0,3 pip/gamba/giorno;
|
||||
- **api**: spread dei tick senza pavimento (0,1-0,7 pip, come mostra l'API demo), overnight **0,9 pip/gamba/giorno** (0,91 USD/giorno per 10 000 EURUSD letti dall'endpoint dei costi il 2026-09-16).
|
||||
- Capitale iniziale 10 000 USD; equity stop al 9 % con ripartenza del picco (D-18), contando gli stop.
|
||||
- Niente calendario né notizie nel backtest: blackout e sentiment agiscono solo dal vivo.
|
||||
|
||||
## 3. Risultati
|
||||
|
||||
### 3.1 Baseline (strategy.json di fabbrica), costi etoro
|
||||
|
||||
| Preset | Basket | Win rate | Netto | Sharpe | Max DD | Costo medio | Break-even | Equity stop |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Conservative | 0 | — | 0 | — | — | — | — | 0 |
|
||||
| Moderate | 2 224 | 50 % | **−9 608 USD** | −3,38 | 96 % | 3,1 pip | −0,4 pip | 37 |
|
||||
| Aggressive | 2 219 | 53 % | **−9 802 USD** | −3,61 | 98 % | 3,4 pip | −0,2 pip | — |
|
||||
|
||||
Il Conservative non apre mai: con TP 8 pip il cost gate a 3× non passa mai (3 × 3,1 > 8). Il Moderate perde quasi tutto il capitale in 7,75 anni: 2 224 basket × ~4 USD di costo = il conto. Il break-even (il costo per basket che azzererebbe il P&L medio) è **−0,4 pip**: il segnale non produce nemmeno un pip lordo per basket.
|
||||
|
||||
### 3.2 Griglia (§9.1): 57 configurazioni, costi etoro
|
||||
|
||||
3 preset × W ∈ {60, 100, 150} × ρ_min ∈ {−0,5, −0,6, −0,7} × z_out ∈ {0,25, 0,5}, più le tre baseline. Risultato in `results/trials.csv` e `results/riepilogo_baskets.csv`:
|
||||
|
||||
- **nessuna configurazione con P&L netto positivo**;
|
||||
- la "migliore" per Sharpe è quella che non apre nulla (Sharpe 0);
|
||||
- PBO (CSCV, 16 blocchi) = 0,000 solo perché la selezione in-sample sceglie sempre la configurazione vuota: un numero degenere, non una prova di robustezza;
|
||||
- walk-forward "scegli il migliore degli ultimi 6 mesi, applicalo un mese" su 88 mesi: Sharpe −0,75, drawdown 13 %, PSR 0,005;
|
||||
- DSR di ogni prova: 0.
|
||||
|
||||
Motivi di non ingresso, in ordine: `no_signal`, `cost_gate`, `rho_low`, `half_life`. Il cancello ρ ≤ −0,6 è raro sulle barre M15: dal vivo il 2026-09-16 ρ_W è rimasta fra −0,13 e −0,42 per tutta la sessione.
|
||||
|
||||
### 3.3 Falsificazione (§9.2), entrambi gli scenari
|
||||
|
||||
`reports/falsificazione.csv` (etoro) e `reports/falsificazione_costi_api.csv` (api), preset Moderate:
|
||||
|
||||
| Test | Variante | Basket (etoro / api) | Win rate | Netto etoro | Netto api | Break-even etoro / api |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 segnale | ZScoreSynthetic | 2 224 / 2 076 | 50 % / 48 % | −9 608 | −9 802 | −0,4 / −0,5 pip |
|
||||
| 1 segnale | PipDivergence | 3 124 / 2 877 | 42 % / 43 % | −9 406 | −9 784 | −0,6 / −0,4 pip |
|
||||
| 2 averaging | AddOnce ×1,0 | 2 123 / 1 821 | 51 % / 48 % | −9 689 | −9 800 | +0,3 / +0,4 pip |
|
||||
| 2 averaging | AddOnce ×1,5 | 1 980 / 1 673 | 51 % / 47 % | −9 741 | −9 802 | +0,2 / +0,3 pip |
|
||||
| 3 stop | senza stop | 1 844 / 1 949 | 55 % / 54 % | −9 228 | −9 796 | −0,2 / −0,4 pip |
|
||||
| 4 cost gate | 2× | 2 135 / 2 052 | 48 % / 48 % | −9 800 | −9 800 | −0,7 / −0,6 pip |
|
||||
| 4 cost gate | 4× | 0 / 3 | — / 33 % | 0 | −81 | — / +3,8 pip |
|
||||
| 5 inverso | segnale invertito | 1 824 / 1 537 | 43 % / 39 % | −9 735 | −9 801 | **−2,5 / −2,9 pip** |
|
||||
|
||||
Letture:
|
||||
|
||||
- **Il segnale ha un contenuto, ma piccolo.** Invertirlo peggiora il break-even di circa 2-2,5 pip per basket (da −0,4 a −2,5). Quindi il verso del segnale vale ~2 pip; il costo medio di un basket è 3,1-3,2 pip. Non basta, in nessuno dei due scenari.
|
||||
- **L'averaging alza il break-even di ~0,7 pip** (compra i rientri) ma allunga la coda: il percentile 1 % delle perdite passa da −98 a −115 USD e il drawdown sale. Non cambia il segno del risultato.
|
||||
- **Senza stop** il win rate sale al 55 % e il netto migliora di 380 USD nello scenario etoro, ma la coda (1 %: −136 USD) e il drawdown restano quelli di un sistema che tiene le perdite aperte. Lo stop resta obbligatorio.
|
||||
- **Il cost gate non salva la strategia**: a 4× non apre quasi nulla, a 2× apre di più e perde di più. Il costo è il problema, ma non è l'unico: anche con lo spread a 0,1 pip (scenario api) l'overnight riporta il costo a 3,2 pip.
|
||||
- **PipDivergence** apre di più e perde di più (win rate 42 %).
|
||||
|
||||
### 3.4 Griglia con costi api
|
||||
|
||||
`results/trials_costi_api.csv` e `results/riepilogo_baskets_costi_api.csv` (57 prove):
|
||||
|
||||
- baseline Moderate: 2 076 basket, win rate 48 %, netto **−9 802 USD**, Sharpe −3,88, costo medio 3,2 pip;
|
||||
- **nessuna prova con P&L netto positivo**; la migliore per Sharpe (T013: Conservative, W 150, ρ_min −0,5) apre 8 basket in 7,75 anni e perde 45 USD (Sharpe −0,10, DSR 0);
|
||||
- PBO 0,001, di nuovo degenere (la selezione in-sample sceglie configurazioni quasi vuote);
|
||||
- walk-forward 6 m / 1 m: Sharpe −0,91, drawdown 13,5 %, PSR 0,000.
|
||||
|
||||
Lo scenario api sposta il costo dallo spread all'overnight senza cambiarne l'ordine di grandezza, perché un basket resta aperto in media più di un giorno (0,9 pip/gamba/giorno × 2 gambe × ~1,5 giorni ≈ 2,7 pip).
|
||||
|
||||
## 4. Verdetto
|
||||
|
||||
**Negativo.** Sui 7,75 anni disponibili la strategia perde in ogni configurazione provata, con entrambi i modelli di costo, e i test di falsificazione non trovano una variante che inverta il segno. Il segnale contiene circa 2 pip di informazione per basket contro 3 pip di costo.
|
||||
|
||||
Cosa ne segue:
|
||||
|
||||
1. Il bot **non va sul reale**. `run.allowLive` resta `false`; i cancelli di §9.4 non sono raggiungibili con questi numeri.
|
||||
2. Il Demo serve a **misurare** (spread reale in esecuzione, slippage, overnight effettivo, quanto spesso i cancelli si aprono), non a guadagnare. Ogni basket chiuso finisce nel ledger e nel dataset del meta-modello.
|
||||
3. Se qualcuno vuole cambiare un parametro, lo fa attraverso `knowledge/proposals.csv` e un forward test pre-registrato (`knowledge/preregistrazione.csv`), non ritoccando `strategy.json` dopo aver guardato i risultati: ogni prova in più abbassa il DSR di tutte le altre.
|
||||
|
||||
## 5. Cosa non è stato misurato
|
||||
|
||||
- L'effetto del blackout del calendario e del sentiment (assenti nel backtest).
|
||||
- Lo spread effettivo di esecuzione su eToro: l'API demo mostra 0,1 pip di mercato e markup 0; il ledger del Demo dirà se le esecuzioni lo confermano (slippage per gamba scritto a ogni ingresso).
|
||||
- EURAUD/AUDCAD su tutto il periodo (dati parziali).
|
||||
- Timeframe diversi da M15 e finestre oltre 150 barre.
|
||||
|
||||
## 6. Come rieseguire
|
||||
|
||||
```powershell
|
||||
backtest ticks --data "A:\Download\Trading" --out "%USERPROFILE%\Documents\Encelado\data\market"
|
||||
backtest baskets --data "%USERPROFILE%\Documents\Encelado\data\market" --out results [--costs api]
|
||||
backtest falsify --data "%USERPROFILE%\Documents\Encelado\data\market" --out reports [--costs api]
|
||||
```
|
||||
|
||||
Ogni esecuzione riscrive le tabelle; i numeri di questo documento vengono da quelle del 2026-09-16.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-0001 — Broker: eToro
|
||||
|
||||
Data: 2026-09-16. Stato: accettata.
|
||||
|
||||
## Contesto
|
||||
|
||||
La strategia opera otto coppie forex (EURUSD, USDCHF, AUDUSD, USDCAD, NZDUSD, EURNZD, EURAUD, AUDCAD) come CFD. Il bot aveva un adattatore Binance (ritirato: dal 1° luglio 2026 l'utente non può operare in USDT) e un adattatore cTrader mai collegato. Alpaca è esclusa perché non offre forex.
|
||||
|
||||
## Decisione
|
||||
|
||||
Il modulo basket opera su **eToro Public API** (`https://public-api.etoro.com`), autenticazione con `x-api-key` + `x-user-key` (chiavi diverse per demo e reale, ambiente sempre visibile in UI), `x-request-id` obbligatorio (usato anche come `referenceId` idempotente degli ordini).
|
||||
|
||||
Fatti verificati il 2026-09-16 sulla specifica OpenAPI servita dall'API (v1.379.0):
|
||||
|
||||
- Quote: `GET /api/v2/market-data/rates?instrumentIds=…` (batch, quota condivisa 120/min).
|
||||
- Candele: `GET /api/v1/market-data/instruments/{id}/history/candles/{asc|desc}/{FifteenMinutes}/{≤1000}` — senza data di partenza: **non pagina lo storico**.
|
||||
- Strumenti: `GET /api/v2/market-data/instruments?symbols=…`; eligibility `POST /api/v2/trading/info/{demo/}eligibility`; costi what-if `POST /api/v2/trading/info/{demo/}costs`.
|
||||
- Conto e posizioni: `GET /api/v1/trading/info/{demo/}pnl` (posizioni con P&L non realizzato, `credit`); saldi `GET /api/v1/balances`.
|
||||
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (20/min), esito con `GET /api/v2/trading/info/{demo/}orders:lookup?referenceId=…`; chiusura `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}` con esito in `GET /api/v1/trading/info/{demo|real}/close-orders/{orderId}`; SL/TP `PATCH /api/v2/trading/{demo/}positions/{id}`.
|
||||
- Storico chiusure: `GET /api/v1/trading/info/trade/{demo/}history?minDate=…`.
|
||||
|
||||
## Alternative
|
||||
|
||||
- **IC Markets / cTrader**: spread ECN più stretti e Open API con streaming, ma l'utente ha chiesto eToro e usa già l'API; l'adattatore resta nel repo per il motore `proba`.
|
||||
- **Alpaca**: niente forex.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Nessun order book, spread con markup, esecuzione solo a mercato (o MIT) con SL/TP nativi: il cost gate deve leggere spread e markup reali a ogni decisione.
|
||||
- Lo storico per il backtest viene dai tick MT5 dell'utente, non dall'API.
|
||||
- Ogni ordine nasce con uno stop nativo (richiesto per `sellShort` e per leva > 1); il bot gestisce comunque lo stop di basket.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-0002 — Storage su file per il modulo basket
|
||||
|
||||
Data: 2026-09-16. Stato: accettata.
|
||||
|
||||
## Contesto
|
||||
|
||||
Il repository ha già un database SQLite (`Encelado.Storage`, unica dipendenza NuGet a runtime) usato dal motore `proba` per barre, dataset, modelli e journal. La specifica del modulo basket chiede storage su file: CSV `;` con `motivazione`, JSONL append-only per ledger e notizie, JSON per modelli e stato, scritture atomiche, rotazione mensile, nessuna riga del ledger modificata.
|
||||
|
||||
## Decisione
|
||||
|
||||
Il modulo basket **non usa SQLite**. Tutto vive in file sotto `Documenti\Encelado\`:
|
||||
|
||||
```
|
||||
data/market/candles_<SYMBOL>_M15.csv barre M15 bid/ask (dallo strumento ticks e dal delta API)
|
||||
data/calendar/events.jsonl eventi economici (dedup title+date+country)
|
||||
data/news/news_YYYYMM.jsonl notizie RSS (dedup hash(link))
|
||||
data/ledger/decisions.jsonl ogni valutazione di ogni basket (append-only, rotazione mensile in decisions_YYYYMM.jsonl)
|
||||
data/ledger/baskets.csv una riga per basket chiuso (label, P&L, costi, slippage)
|
||||
data/models/logreg_v<N>.json, mlp_v<N>.json, bandit.json, state.json
|
||||
knowledge/calibration.csv, insights_YYYYWW.md, proposals.csv, forward_registry.csv, models_registry.csv, preregistrazione.csv
|
||||
reports/*.csv, results/trials.csv
|
||||
```
|
||||
|
||||
## Alternative
|
||||
|
||||
- Riusare SQLite: comodo per query, ma introduce un binario nativo nel percorso del modulo, contraddice la specifica e rende il ledger modificabile per errore.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Le tabelle si aprono in un foglio di calcolo così come sono; il ledger è verificabile riga per riga.
|
||||
- L'analisi (ricostruzione del dataset, calibrazione) rilegge i file: costa qualche secondo per centinaia di migliaia di righe, accettabile per un ciclo settimanale.
|
||||
@@ -0,0 +1,15 @@
|
||||
# ADR-0003 — Motore cTrader mantenuto selezionabile
|
||||
|
||||
Data: 2026-09-16 (mattina). Stato: **superata da ADR-0004** (stesso giorno, pomeriggio).
|
||||
|
||||
## Contesto
|
||||
|
||||
All'inizio del lavoro sui Correlation Baskets l'albero conteneva un motore probabilistico su cTrader (`proba`) non committato e non compilante. La regola «non toccare i comportamenti esistenti se non richiesto» suggeriva di rimetterlo in compilazione e lasciarlo selezionabile con `engine.strategy = "proba"`, con `"baskets"` come predefinito.
|
||||
|
||||
## Decisione (originaria)
|
||||
|
||||
Tenere entrambi i motori dietro `IEngine`, con la finestra che sceglie le pagine in base al motore configurato.
|
||||
|
||||
## Esito
|
||||
|
||||
Nel pomeriggio l'utente ha chiesto la rimozione di tutto ciò che riguarda le gestioni precedenti e, alla domanda esplicita, ha incluso cTrader e la ricerca (D-21). La decisione è registrata in ADR-0004; questo documento resta per la cronologia.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-0004 — Rimozione dei motori precedenti (Binance, cTrader/proba, ricerca)
|
||||
|
||||
Data: 2026-09-16. Stato: accettata. Sostituisce ADR-0003.
|
||||
|
||||
## Contesto
|
||||
|
||||
Il repository portava tre generazioni di codice: l'arbitraggio statistico su Binance Futures (con adattatore già rimosso), il motore probabilistico su cTrader con la sua pipeline di ricerca (SQLite, GBDT, RL, TA-Lib, backtest a coppie) e il modulo Correlation Baskets su eToro. ADR-0003 aveva tenuto il motore cTrader selezionabile per non toccare comportamenti esistenti. L'utente ha chiesto un rework completo che elimini «qualsiasi cosa legata a vecchie gestioni (binance, alpaca, ecc ecc)» e, alla domanda esplicita, ha scelto di rimuovere anche cTrader e la ricerca (D-21).
|
||||
|
||||
## Decisione
|
||||
|
||||
Restano solo `Encelado.Core` (basket, broker, notizie, statistica condivisa), `Encelado.Etoro`, `Encelado.Bot` e lo strumento `tools/Encelado.Backtest` con i tre comandi `ticks`, `baskets`, `falsify`. Sono stati eliminati i progetti `Encelado.CTrader` e `Encelado.Storage`, le cartelle `Core/Backtest`, `Indicators`, `Journal`, `Market`, `Portfolio`, `Research`, `Risk`, `Rl`, `Strategies`, quasi tutto `Ml` (restano `Classification` e `Pbo`) e `Statistics` (restano `Performance`, `Ols`, `Distributions`), il motore `ProbaEngine`, le pagine e i test relativi, il selettore `engine.strategy`, le sezioni di configurazione `ctrader`, `engine`, `strategy`, `risk`, `storage`, `symbols`. Nessun pacchetto NuGet resta nei progetti dell'applicazione.
|
||||
|
||||
Il codice rimosso è nella storia git (tag `v3.5.0` e commit `b39e08b`).
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Una sola strategia, una sola configurazione, una sola finestra: meno codice da capire e da testare (da 322 a 172 test, tutti sul modulo che gira).
|
||||
- Le conclusioni delle ricerche precedenti (StatArb su BTC non valida fuori campione, ProbaBot) restano solo nei documenti e nella memoria di lavoro; non sono più riproducibili da questo albero.
|
||||
- Un file di configurazione della versione precedente viene letto con avvisi mirati («sezione di una versione precedente») e il ripristino dei valori di fabbrica lo riscrive.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-0005 — Nessuna approvazione manuale dei singoli ordini
|
||||
|
||||
Data: 2026-09-16. Stato: accettata (decisione dell'utente, D-20).
|
||||
|
||||
## Contesto
|
||||
|
||||
La specifica prevedeva cinque modalità (`Paper`, `DemoApprove`, `DemoAuto`, `LiveApprove`, `LiveAuto`) con `DemoApprove` predefinita: ogni apertura, aggiunta e take-profit era una proposta che aspettava una persona per quindici minuti. Nella sessione di prova del 2026-09-16 il bot in `DemoApprove` non ha mai potuto operare senza qualcuno alla finestra, e il test lungo richiesto dall'utente («sperando di piazzare trade») non è possibile in quel modo. Alla domanda «posso usare DemoAuto per il test lungo?» l'utente ha risposto: «tutti gli Approve devono sparire, almeno per il momento. Il bot deve girare in completa autonomia aprendo e chiudendo le posizioni senza il mio consenso».
|
||||
|
||||
## Decisione
|
||||
|
||||
Le modalità diventano tre: `Paper`, `Demo` (predefinita) e `Live`. In tutte il bot esegue da solo le decisioni del decisore. La coda delle approvazioni (`ApprovalQueue`), i comandi `approve`/`reject`, il flag `run.allowDemoAuto` e la conferma all'avvio del demo automatico sono rimossi. I nomi precedenti (`DemoApprove`, `DemoAuto`, `LiveApprove`, `LiveAuto`) vengono ancora letti dal file di configurazione, mappati su `Demo`/`Live` con un avviso.
|
||||
|
||||
Restano i gate umani che non riguardano il singolo ordine: la frase `CONFERMO LIVE` all'avvio del reale (con `run.allowLive = true`), il kill-switch, il reset motivato dopo un equity stop, il cambio di preset a caldo.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Le regole «mai un ordine reale senza flag e conferma» restano vere a livello di sessione: il reale non parte senza flag e frase.
|
||||
- Le uscite protettive erano già automatiche in ogni modalità; ora lo sono anche le aperture e i take-profit.
|
||||
- Se in futuro servisse una revisione umana, va reintrodotta come modalità esplicita e non come default: la specifica originaria resta documentata qui.
|
||||
@@ -0,0 +1,14 @@
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
1_segnale;ZScoreSynthetic;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
1_segnale;PipDivergence;3124;0.4238;-9406.4611;-3.7746;0.9412;-75.4011;-31.0045;-0.6061;3.1477;0;0;3124 basket, win rate 42 %, netto -9406 USD, Sharpe -3.77, DD 94.1 %, 1% -75 USD, 5% -31 USD: perde al netto dei costi assunti
|
||||
2_averaging;Off x1.0;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
|
||||
2_averaging;Grid x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
2_averaging;Grid x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
3_stop;con stop;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
3_stop;senza stop;1844;0.5542;-9228.1048;-2.3634;0.9251;-136.115;-67.6782;-0.1898;3.1101;0;0;1844 basket, win rate 55 %, netto -9228 USD, Sharpe -2.36, DD 92.5 %, 1% -136 USD, 5% -68 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
|
||||
4_cost_gate;2x;2135;0.4843;-9800.3487;-3.7111;0.9803;-93.8542;-45.5499;-0.7129;3.5108;0;0;2135 basket, win rate 48 %, netto -9800 USD, Sharpe -3.71, DD 98.0 %, 1% -94 USD, 5% -46 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;3x;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;4x;0;;0;0;0;;;;;0.5;0;0 basket, win rate NaN, netto 0 USD, Sharpe 0.00, DD 0.0 %, 1% NaN USD, 5% NaN USD: perde al netto dei costi assunti
|
||||
5_inverso;segnale invertito;1824;0.4331;-9735.0255;-4.0007;0.974;-82.2238;-45.0301;-2.5283;3.1747;0;0;1824 basket, win rate 43 %, netto -9735 USD, Sharpe -4.00, DD 97.4 %, 1% -82 USD, 5% -45 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
|
||||
|
@@ -0,0 +1,14 @@
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
1_segnale;ZScoreSynthetic;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
1_segnale;PipDivergence;2877;0.4282;-9783.9713;-4.2544;0.9786;-74.479;-33.8654;-0.4263;3.1921;0;0;2877 basket, win rate 43 %, netto -9784 USD, Sharpe -4.25, DD 97.9 %, 1% -74 USD, 5% -34 USD: perde al netto dei costi assunti
|
||||
2_averaging;Off x1.0;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
|
||||
2_averaging;Grid x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
2_averaging;Grid x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
3_stop;con stop;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
3_stop;senza stop;1949;0.5387;-9795.9761;-3.054;0.9799;-107.4238;-51.7478;-0.3737;3.1928;0;0;1949 basket, win rate 54 %, netto -9796 USD, Sharpe -3.05, DD 98.0 %, 1% -107 USD, 5% -52 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
|
||||
4_cost_gate;2x;2052;0.4771;-9800.0224;-3.7234;0.9802;-93.1449;-44.9303;-0.6448;3.4791;0;0;2052 basket, win rate 48 %, netto -9800 USD, Sharpe -3.72, DD 98.0 %, 1% -93 USD, 5% -45 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;3x;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;4x;3;0.3333;-81.1762;-0.3256;0.0081;-72.0723;-72.0723;3.8259;2.4592;0.0228;0;3 basket, win rate 33 %, netto -81 USD, Sharpe -0.33, DD 0.8 %, 1% -72 USD, 5% -72 USD: perde al netto dei costi assunti
|
||||
5_inverso;segnale invertito;1537;0.3852;-9800.9519;-4.3499;0.9801;-83.2267;-48.4287;-2.8935;3.2095;0;0;1537 basket, win rate 39 %, netto -9801 USD, Sharpe -4.35, DD 98.0 %, 1% -83 USD, 5% -48 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
voce;valore;motivazione
|
||||
periodo;2018-12-12 → 2026-09-15;barre M15 dai tick MT5, spread minimo = tipico eToro, slippage 0.3 pip/gamba, overnight 0.3 pip/gamba/giorno
|
||||
prove;57;ogni configurazione provata conta nel Sharpe deflazionato
|
||||
baseline Moderate;Sharpe -3.38, netto -9608 USD, 2224 basket, win rate 50 %;la configurazione di fabbrica così com'è
|
||||
migliore;BASE-CON: Sharpe 0.00, DSR 0.000;non distinguibile dalla selezione fra le prove
|
||||
PBO;0.000;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
|
||||
walk-forward;Sharpe -0.75, DD 13.2 %, PSR 0.005;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
|
||||
verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
|
||||
|
@@ -0,0 +1,8 @@
|
||||
voce;valore;motivazione
|
||||
periodo;2018-12-12 → 2026-09-16;barre M15 dai tick MT5, spread minimo = nessuno (spread dei tick), slippage 0.3 pip/gamba, overnight 0.9 pip/gamba/giorno
|
||||
prove;57;ogni configurazione provata conta nel Sharpe deflazionato
|
||||
baseline Moderate;Sharpe -3.88, netto -9802 USD, 2076 basket, win rate 48 %;la configurazione di fabbrica così com'è
|
||||
migliore;T013: Sharpe -0.10, DSR 0.000;non distinguibile dalla selezione fra le prove
|
||||
PBO;0.001;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
|
||||
walk-forward;Sharpe -0.91, DD 13.5 %, PSR 0.000;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
|
||||
verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
|
||||
|
@@ -0,0 +1,58 @@
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
BASE-CON;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
BASE-MOD;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2224;0.5018;-9608.2555;-3.38;0.9612;-0.3966;3.1231;-98.1631;-50.2921;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
BASE-AGG;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;2219;0.5273;-9801.7437;-3.605;0.9808;-0.154;3.4299;-107.4078;-54.9455;0;0;2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T001;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T002;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T003;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T004;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T005;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T006;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T007;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T008;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T009;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T010;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T011;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T012;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T013;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T014;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T015;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T016;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T017;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T018;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T019;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.5;3;1;2273;0.5095;-9776.971;-3.676;0.9777;-0.5181;3.1978;-100.8336;-48.8858;0;0;2273 basket, win rate 51 %, netto -9777 USD, Sharpe -3.68, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T020;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.5;3;1;2361;0.5078;-9780.3682;-3.8412;0.9781;-0.4561;3.1972;-93.6221;-45.8502;0;0;2361 basket, win rate 51 %, netto -9780 USD, Sharpe -3.84, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T021;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.6;3;1;2114;0.5019;-9777.7272;-3.7077;0.9778;-0.9027;3.1945;-97.6587;-50.0165;0;0;2114 basket, win rate 50 %, netto -9778 USD, Sharpe -3.71, DD 97.8 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T022;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.6;3;1;2186;0.4973;-9775.8854;-3.9248;0.9777;-0.7664;3.1947;-91.6099;-48.0941;0;0;2186 basket, win rate 50 %, netto -9776 USD, Sharpe -3.92, DD 97.8 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T023;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.7;3;1;1904;0.4806;-9758.7378;-3.9609;0.9759;-1.3315;3.1716;-104.8435;-53.09;0;0;1904 basket, win rate 48 %, netto -9759 USD, Sharpe -3.96, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
|
||||
T024;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.7;3;1;1943;0.4668;-9761.2755;-4.0828;0.9761;-1.3364;3.1729;-99.5743;-52.3411;0;0;1943 basket, win rate 47 %, netto -9761 USD, Sharpe -4.08, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
|
||||
T025;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.5;3;1;2386;0.4987;-9698.3332;-3.4922;0.9703;-0.5028;3.1298;-97.1191;-49.2531;0;0;2386 basket, win rate 50 %, netto -9698 USD, Sharpe -3.49, DD 97.0 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T026;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.5;3;1;2325;0.4886;-9733.1429;-3.7736;0.9737;-0.716;3.1401;-89.9765;-47.1863;0;0;2325 basket, win rate 49 %, netto -9733 USD, Sharpe -3.77, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T027;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2224;0.5018;-9608.2555;-3.38;0.9612;-0.3966;3.1231;-98.1631;-50.2921;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T028;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.6;3;1;2232;0.4928;-9626.41;-3.5471;0.963;-0.4681;3.1283;-92.7272;-48.5099;0;0;2232 basket, win rate 49 %, netto -9626 USD, Sharpe -3.55, DD 96.3 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T029;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.7;3;1;1620;0.4969;-9097.1212;-3.0336;0.9104;-0.7071;3.1249;-110.3729;-57.6683;0;0;1620 basket, win rate 50 %, netto -9097 USD, Sharpe -3.03, DD 91.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T030;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.7;3;1;1659;0.4846;-9151.1206;-3.1928;0.9158;-0.6745;3.125;-105.9072;-54.6317;0;0;1659 basket, win rate 48 %, netto -9151 USD, Sharpe -3.19, DD 91.6 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T031;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.5;3;1;1724;0.518;-8946.7157;-2.9015;0.8957;-0.8274;3.1056;-96.5951;-58.2571;0;0;1724 basket, win rate 52 %, netto -8947 USD, Sharpe -2.90, DD 89.6 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T032;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.5;3;1;1750;0.5114;-8984.9522;-3.0214;0.8994;-0.7697;3.1053;-94.7592;-56.1468;0;0;1750 basket, win rate 51 %, netto -8985 USD, Sharpe -3.02, DD 89.9 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T033;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.6;3;1;1524;0.523;-8546.383;-2.6559;0.856;-0.5673;3.1088;-108.0414;-60.4233;0;0;1524 basket, win rate 52 %, netto -8546 USD, Sharpe -2.66, DD 85.6 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T034;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.6;3;1;1555;0.5125;-8644.9455;-2.7834;0.8655;-0.5618;3.1095;-104.0531;-58.2323;0;0;1555 basket, win rate 51 %, netto -8645 USD, Sharpe -2.78, DD 86.5 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T035;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.7;3;1;1057;0.5232;-7365.987;-2.1831;0.7384;-0.7041;3.1237;-120.7347;-71.8169;0;0;1057 basket, win rate 52 %, netto -7366 USD, Sharpe -2.18, DD 73.8 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T036;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.7;3;1;1080;0.513;-7548.9926;-2.318;0.7562;-0.8988;3.124;-116.8562;-67.6427;0;0;1080 basket, win rate 51 %, netto -7549 USD, Sharpe -2.32, DD 75.6 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T037;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.5;3;1;2324;0.503;-9801.8989;-3.7439;0.9802;0.1247;3.453;-94.1066;-44.0792;0;0;2324 basket, win rate 50 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T038;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.5;3;1;2505;0.5022;-9801.8568;-3.8586;0.9802;0.2991;3.4527;-85.3644;-37.9877;0;0;2505 basket, win rate 50 %, netto -9802 USD, Sharpe -3.86, DD 98.0 %, costo medio 3.5 pip, break-even 0.3 pip: perde al netto dei costi
|
||||
T039;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.6;3;1;2206;0.4946;-9801.6714;-3.7449;0.9802;-0.122;3.4445;-95.2361;-46.4834;0;0;2206 basket, win rate 49 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T040;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.6;3;1;2382;0.5008;-9801.8364;-3.8898;0.9802;0.1249;3.4433;-91.5065;-39.2958;0;0;2382 basket, win rate 50 %, netto -9802 USD, Sharpe -3.89, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T041;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.7;3;1;2066;0.4782;-9800.3225;-3.9569;0.9801;-0.3695;3.4129;-106.8203;-50.7168;0;0;2066 basket, win rate 48 %, netto -9800 USD, Sharpe -3.96, DD 98.0 %, costo medio 3.4 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T042;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.7;3;1;2228;0.478;-9800.5097;-4.0478;0.9802;-0.0551;3.4114;-99.1796;-46.4296;0;0;2228 basket, win rate 48 %, netto -9801 USD, Sharpe -4.05, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T043;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.5;3;1;2333;0.5362;-9800.5583;-3.6212;0.9807;-0.1462;3.455;-114.2527;-52.729;0;0;2333 basket, win rate 54 %, netto -9801 USD, Sharpe -3.62, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T044;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.5;3;1;2418;0.5434;-9801.6954;-3.7353;0.9807;-0.0542;3.4505;-106.0593;-47.7018;0;0;2418 basket, win rate 54 %, netto -9802 USD, Sharpe -3.74, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T045;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;2219;0.5273;-9801.7437;-3.605;0.9808;-0.154;3.4299;-107.4078;-54.9455;0;0;2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T046;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.6;3;1;2299;0.5385;-9800.1165;-3.8043;0.9805;-0.0498;3.4277;-102.5053;-49.1087;0;0;2299 basket, win rate 54 %, netto -9800 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T047;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.7;3;1;2034;0.5103;-9801.176;-3.5533;0.9805;-0.7064;3.3793;-110.5845;-56.0264;0;0;2034 basket, win rate 51 %, netto -9801 USD, Sharpe -3.55, DD 98.1 %, costo medio 3.4 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T048;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.7;3;1;2111;0.5216;-9801.5062;-3.6808;0.9805;-0.5194;3.3807;-102.1236;-50.7364;0;0;2111 basket, win rate 52 %, netto -9802 USD, Sharpe -3.68, DD 98.1 %, costo medio 3.4 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T049;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.5;3;1;2535;0.5582;-9800.4562;-3.2691;0.9804;-0.1898;3.4255;-122.7526;-63.8002;0;0;2535 basket, win rate 56 %, netto -9800 USD, Sharpe -3.27, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T050;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.5;3;1;2652;0.5618;-9800.054;-3.34;0.9804;-0.12;3.4243;-117.3288;-56.7329;0;0;2652 basket, win rate 56 %, netto -9800 USD, Sharpe -3.34, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T051;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.6;3;1;2611;0.558;-9800.3025;-3.3139;0.9804;0.0259;3.3951;-122.3324;-61.2879;0;0;2611 basket, win rate 56 %, netto -9800 USD, Sharpe -3.31, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
T052;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.6;3;1;2722;0.5643;-9800.3917;-3.4336;0.9804;0.1646;3.3925;-117.9914;-53.3356;0;0;2722 basket, win rate 56 %, netto -9800 USD, Sharpe -3.43, DD 98.0 %, costo medio 3.4 pip, break-even 0.2 pip: perde al netto dei costi
|
||||
T053;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.7;3;1;2549;0.5457;-9799.5834;-3.5684;0.9806;-0.0172;3.3539;-130.1295;-56.5296;0;0;2549 basket, win rate 55 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T054;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.7;3;1;2602;0.5473;-9802.9899;-3.6937;0.9808;-0.0068;3.3538;-125.0607;-49.82;0;0;2602 basket, win rate 55 %, netto -9803 USD, Sharpe -3.69, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
|
@@ -0,0 +1,58 @@
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
BASE-CON;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
BASE-MOD;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2076;0.4769;-9801.9491;-3.8807;0.9804;-0.4892;3.2085;-96.1599;-51.6818;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
BASE-AGG;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;1995;0.5143;-9800.4836;-3.7229;0.9806;-0.1782;3.4269;-113.7756;-55.2381;0;0;1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T001;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.5;3;1;8;0.5;-69.5629;-0.2771;0.0086;4.9254;2.5879;-80.2036;-80.2036;0.1196;0;8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
|
||||
T002;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.5;3;1;8;0.375;-110.4189;-0.3764;0.0127;3.8879;2.5879;-80.2036;-80.2036;0.0455;0;8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
|
||||
T003;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.6;3;1;8;0.5;-69.5629;-0.2771;0.0086;4.9254;2.5879;-80.2036;-80.2036;0.1196;0;8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
|
||||
T004;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.6;3;1;8;0.375;-110.4189;-0.3764;0.0127;3.8879;2.5879;-80.2036;-80.2036;0.0455;0;8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
|
||||
T005;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.7;3;1;3;0.3333;-16.5569;-0.2092;0.0024;10.6949;2.5283;-24.5014;-24.5014;0.1936;0;3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
|
||||
T006;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.7;3;1;3;0.3333;-16.5569;-0.2092;0.0024;10.6949;2.5283;-24.5014;-24.5014;0.1936;0;3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
|
||||
T007;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.5;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T008;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.5;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T009;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T010;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T011;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.7;3;1;3;0.3333;-31.767;-0.2049;0.0048;5.9618;2.5284;-38.4174;-38.4174;0.2014;0;3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
|
||||
T012;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.7;3;1;3;0.3333;-31.767;-0.2049;0.0048;5.9618;2.5284;-38.4174;-38.4174;0.2014;0;3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
|
||||
T013;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.5;3;1;8;0.625;-44.5609;-0.1017;0.0143;0.1426;2.3801;-111.7539;-111.7539;0.3732;0;8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T014;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.5;3;1;8;0.625;-44.5609;-0.1017;0.0143;0.1426;2.3801;-111.7539;-111.7539;0.3732;0;8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T015;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.6;3;1;6;0.5;-113.611;-0.2917;0.0152;-4.221;2.629;-111.7539;-111.7539;0.1381;0;6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
|
||||
T016;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.6;3;1;6;0.5;-113.611;-0.2917;0.0152;-4.221;2.629;-111.7539;-111.7539;0.1381;0;6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
|
||||
T017;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.7;3;1;4;0.5;-100.5194;-0.2632;0.0139;-8.8621;2.6129;-111.7035;-111.7035;0.1631;0;4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
|
||||
T018;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.7;3;1;4;0.5;-100.5194;-0.2632;0.0139;-8.8621;2.6129;-111.7035;-111.7035;0.1631;0;4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
|
||||
T019;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.5;3;1;2018;0.4792;-9800.6546;-4.1396;0.9801;-0.7742;3.2141;-103.5455;-51.4822;0;0;2018 basket, win rate 48 %, netto -9801 USD, Sharpe -4.14, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T020;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.5;3;1;2192;0.4886;-9803.1597;-4.2704;0.9803;-0.5484;3.2139;-97.7234;-46.9665;0;0;2192 basket, win rate 49 %, netto -9803 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T021;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.6;3;1;1972;0.4787;-9801.7849;-4.0332;0.9802;-0.9003;3.2155;-100.0295;-49.8798;0;0;1972 basket, win rate 48 %, netto -9802 USD, Sharpe -4.03, DD 98.0 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T022;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.6;3;1;2069;0.4819;-9803.6976;-4.2507;0.9804;-0.782;3.2167;-93.6889;-48.8346;0;0;2069 basket, win rate 48 %, netto -9804 USD, Sharpe -4.25, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T023;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.7;3;1;2055;0.4818;-9801.1624;-4.0098;0.9801;-0.5217;3.2143;-102.1531;-50.7697;0;0;2055 basket, win rate 48 %, netto -9801 USD, Sharpe -4.01, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T024;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.7;3;1;2132;0.4855;-9801.1437;-4.1302;0.9801;-0.4595;3.2148;-98.965;-47.3869;0;0;2132 basket, win rate 49 %, netto -9801 USD, Sharpe -4.13, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T025;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.5;3;1;2101;0.4731;-9801.082;-3.7918;0.9804;-0.5503;3.2078;-94.4597;-50.6244;0;0;2101 basket, win rate 47 %, netto -9801 USD, Sharpe -3.79, DD 98.0 %, costo medio 3.2 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T026;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.5;3;1;2140;0.4738;-9801.717;-3.9388;0.9804;-0.5081;3.2089;-80.9489;-48.5339;0;0;2140 basket, win rate 47 %, netto -9802 USD, Sharpe -3.94, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T027;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2076;0.4769;-9801.9491;-3.8807;0.9804;-0.4892;3.2085;-96.1599;-51.6818;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T028;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.6;3;1;2108;0.4782;-9801.8954;-4.0225;0.9803;-0.4629;3.2091;-87.7027;-50.0178;0;0;2108 basket, win rate 48 %, netto -9802 USD, Sharpe -4.02, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T029;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.7;3;1;1984;0.4582;-9797.6241;-4.1086;0.9799;-0.798;3.1973;-92.7908;-47.5743;0;0;1984 basket, win rate 46 %, netto -9798 USD, Sharpe -4.11, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T030;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.7;3;1;2040;0.4574;-9797.1369;-4.2728;0.9799;-0.7345;3.1987;-87.6021;-45.6211;0;0;2040 basket, win rate 46 %, netto -9797 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T031;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.5;3;1;2238;0.4915;-9733.8654;-3.7948;0.9738;-0.5971;3.1445;-90.112;-47.5574;0;0;2238 basket, win rate 49 %, netto -9734 USD, Sharpe -3.79, DD 97.4 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T032;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.5;3;1;2247;0.4887;-9738.5622;-3.9701;0.9742;-0.6768;3.1479;-89.2127;-45.6445;0;0;2247 basket, win rate 49 %, netto -9739 USD, Sharpe -3.97, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T033;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.6;3;1;2090;0.4856;-9697.0682;-3.7782;0.97;-0.7223;3.1429;-95.5498;-51.6315;0;0;2090 basket, win rate 49 %, netto -9697 USD, Sharpe -3.78, DD 97.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T034;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.6;3;1;2120;0.4863;-9681.6883;-3.8261;0.9684;-0.5759;3.1425;-96.173;-48.981;0;0;2120 basket, win rate 49 %, netto -9682 USD, Sharpe -3.83, DD 96.8 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T035;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.7;3;1;1616;0.4901;-9304.7978;-3.3946;0.9309;-0.9725;3.1466;-103.2808;-55.1427;0;0;1616 basket, win rate 49 %, netto -9305 USD, Sharpe -3.39, DD 93.1 %, costo medio 3.1 pip, break-even -1.0 pip: perde al netto dei costi
|
||||
T036;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.7;3;1;1650;0.4818;-9315.3955;-3.4957;0.9319;-0.9219;3.1469;-103.8865;-53.903;0;0;1650 basket, win rate 48 %, netto -9315 USD, Sharpe -3.50, DD 93.2 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T037;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.5;3;1;2222;0.5018;-9800.1789;-3.493;0.9801;0.1139;3.4884;-95.5877;-43.0006;0;0;2222 basket, win rate 50 %, netto -9800 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T038;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.5;3;1;2319;0.5071;-9801.3643;-3.6642;0.9802;0.1721;3.4888;-90.1871;-38.1818;0;0;2319 basket, win rate 51 %, netto -9801 USD, Sharpe -3.66, DD 98.0 %, costo medio 3.5 pip, break-even 0.2 pip: perde al netto dei costi
|
||||
T039;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.6;3;1;2068;0.4971;-9802.4311;-3.6774;0.9803;-0.3555;3.4622;-96.564;-45.9072;0;0;2068 basket, win rate 50 %, netto -9802 USD, Sharpe -3.68, DD 98.0 %, costo medio 3.5 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T040;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.6;3;1;2261;0.5042;-9801.8015;-3.7059;0.9802;-0.0458;3.4572;-87.7666;-43.2972;0;0;2261 basket, win rate 50 %, netto -9802 USD, Sharpe -3.71, DD 98.0 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T041;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.7;3;1;2032;0.4818;-9803.4939;-3.8824;0.9804;-0.2081;3.3942;-102.0523;-51.4273;0;0;2032 basket, win rate 48 %, netto -9803 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T042;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.7;3;1;2223;0.4876;-9799.9736;-4.122;0.9801;0.1232;3.3884;-96.1687;-46.1443;0;0;2223 basket, win rate 49 %, netto -9800 USD, Sharpe -4.12, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T043;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.5;3;1;2161;0.5298;-9800.4196;-3.5742;0.9806;-0.0636;3.461;-110.6802;-51.9967;0;0;2161 basket, win rate 53 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T044;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.5;3;1;2289;0.5382;-9801.341;-3.7496;0.9806;-0.0314;3.4567;-99.7287;-47.5057;0;0;2289 basket, win rate 54 %, netto -9801 USD, Sharpe -3.75, DD 98.1 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T045;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;1995;0.5143;-9800.4836;-3.7229;0.9806;-0.1782;3.4269;-113.7756;-55.2381;0;0;1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T046;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.6;3;1;2092;0.5268;-9801.2355;-3.8187;0.9806;-0.1043;3.4231;-103.851;-49.8169;0;0;2092 basket, win rate 53 %, netto -9801 USD, Sharpe -3.82, DD 98.1 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T047;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.7;3;1;1790;0.4955;-9801.26;-3.7117;0.9805;-1.011;3.3543;-107.8876;-56.2754;0;0;1790 basket, win rate 50 %, netto -9801 USD, Sharpe -3.71, DD 98.1 %, costo medio 3.4 pip, break-even -1.0 pip: perde al netto dei costi
|
||||
T048;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.7;3;1;1972;0.5137;-9802.9285;-3.7993;0.9806;-0.6154;3.3518;-97.0256;-50.7654;0;0;1972 basket, win rate 51 %, netto -9803 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T049;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.5;3;1;2267;0.5483;-9800.1872;-3.3236;0.9804;-0.2425;3.4352;-127.609;-57.2805;0;0;2267 basket, win rate 55 %, netto -9800 USD, Sharpe -3.32, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T050;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.5;3;1;2289;0.547;-9800.6111;-3.4919;0.9804;-0.2567;3.4319;-121.7571;-55.2384;0;0;2289 basket, win rate 55 %, netto -9801 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.4 pip, break-even -0.3 pip: perde al netto dei costi
|
||||
T051;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.6;3;1;2280;0.55;-9799.948;-3.3521;0.9804;-0.177;3.383;-116.3317;-59.2741;0;0;2280 basket, win rate 55 %, netto -9800 USD, Sharpe -3.35, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T052;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.6;3;1;2412;0.5547;-9800.0702;-3.4091;0.9804;0.0354;3.3835;-114.6115;-56.6657;0;0;2412 basket, win rate 55 %, netto -9800 USD, Sharpe -3.41, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
T053;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.7;3;1;2298;0.5361;-9802.0629;-3.5954;0.9808;-0.121;3.3223;-127.3343;-57.6739;0;0;2298 basket, win rate 54 %, netto -9802 USD, Sharpe -3.60, DD 98.1 %, costo medio 3.3 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T054;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.7;3;1;2441;0.5453;-9800.0711;-3.6641;0.9805;0.0417;3.3249;-123.3318;-50.6626;0;0;2441 basket, win rate 55 %, netto -9800 USD, Sharpe -3.66, DD 98.1 %, costo medio 3.3 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
|
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Bot.Engine;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.News;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>One feed: where it lives, what it is, how often it may be asked.</summary>
|
||||
public sealed record FeedSource(string Name, Uri Url, string Kind, string Currency = "")
|
||||
{
|
||||
public string CacheName => Name.Replace(' ', '_').ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The free feeds of §7, fetched politely: an explicit User-Agent, one request per minute
|
||||
/// per source at most, <c>robots.txt</c> honoured, exponential backoff on errors, and a
|
||||
/// copy of the last good body on disk so a restart does not start blind.
|
||||
/// </summary>
|
||||
public sealed class FeedFetcher : IDisposable
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly string _cacheDir;
|
||||
private readonly Dictionary<string, DateTime> _lastRequest = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _failures = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, (DateTime At, bool Allowed)> _robots = new(StringComparer.Ordinal);
|
||||
|
||||
public FeedFetcher(string cacheDir, string userAgent)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDir);
|
||||
_cacheDir = cacheDir;
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
_client = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
|
||||
_client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
_client.DefaultRequestHeaders.Accept.ParseAdd("application/rss+xml, application/xml, text/xml, application/json;q=0.9, */*;q=0.5");
|
||||
}
|
||||
|
||||
public TimeSpan MinInterval { get; init; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>Fetches the feed if its interval has passed; returns the body (fresh or cached) or null.</summary>
|
||||
public async Task<(string? Body, bool Fresh)> FetchAsync(FeedSource source, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
string cachePath = Path.Combine(_cacheDir, source.CacheName + (source.Kind == "calendar-json" ? ".json" : ".xml"));
|
||||
|
||||
if (_lastRequest.TryGetValue(source.Name, out DateTime last))
|
||||
{
|
||||
int failures = _failures.GetValueOrDefault(source.Name);
|
||||
TimeSpan wait = failures == 0 ? MinInterval : TimeSpan.FromMinutes(Math.Min(120, 2 << Math.Min(6, failures)));
|
||||
if (nowUtc - last < wait)
|
||||
{
|
||||
return (ReadCache(cachePath), false);
|
||||
}
|
||||
}
|
||||
|
||||
_lastRequest[source.Name] = nowUtc;
|
||||
|
||||
if (!await AllowedByRobotsAsync(source.Url, ct).ConfigureAwait(false))
|
||||
{
|
||||
Log.Warn($"feed {source.Name}: robots.txt non consente {source.Url.AbsolutePath}; uso solo la cache");
|
||||
_failures[source.Name] = 10;
|
||||
return (ReadCache(cachePath), false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _client.GetAsync(source.Url, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
int n = _failures.GetValueOrDefault(source.Name) + 1;
|
||||
_failures[source.Name] = n;
|
||||
|
||||
// The first two failures are worth a warning; after that the source is
|
||||
// backing off (up to two hours) and the line would only repeat itself.
|
||||
if (n <= 2)
|
||||
{
|
||||
Log.Warn($"feed {source.Name}: HTTP {(int)response.StatusCode}{(n == 2 ? " (secondo errore: ritento con attese crescenti, uso la cache)" : string.Empty)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug($"feed {source.Name}: HTTP {(int)response.StatusCode} (errore {n}, cache)");
|
||||
}
|
||||
return (ReadCache(cachePath), false);
|
||||
}
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
_failures[source.Name] = 0;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(cachePath + ".tmp", body, new UTF8Encoding(false));
|
||||
File.Move(cachePath + ".tmp", cachePath, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"feed {source.Name}: cache non scritta ({ex.Message})");
|
||||
}
|
||||
|
||||
return (body, true);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
_failures[source.Name] = _failures.GetValueOrDefault(source.Name) + 1;
|
||||
Log.Warn($"feed {source.Name}: {ex.Message}");
|
||||
return (ReadCache(cachePath), false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadCache(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? File.ReadAllText(path) : null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A minimal robots.txt reader: the <c>User-agent: *</c> group's Disallow lines, cached a day per host.</summary>
|
||||
private async Task<bool> AllowedByRobotsAsync(Uri url, CancellationToken ct)
|
||||
{
|
||||
string host = url.GetLeftPart(UriPartial.Authority);
|
||||
if (_robots.TryGetValue(host + url.AbsolutePath, out (DateTime At, bool Allowed) cached) && DateTime.UtcNow - cached.At < TimeSpan.FromDays(1))
|
||||
{
|
||||
return cached.Allowed;
|
||||
}
|
||||
|
||||
bool allowed = true;
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _client.GetAsync(new Uri(host + "/robots.txt"), ct).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string text = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
allowed = RobotsAllows(text, url.AbsolutePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||
{
|
||||
// No robots.txt reachable: assume allowed, like every crawler does.
|
||||
}
|
||||
|
||||
_robots[host + url.AbsolutePath] = (DateTime.UtcNow, allowed);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/// <summary>Applies the <c>User-agent: *</c> group: the longest matching Allow/Disallow wins.</summary>
|
||||
public static bool RobotsAllows(string robots, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(robots);
|
||||
bool inStar = false;
|
||||
string? bestRule = null;
|
||||
bool bestAllow = true;
|
||||
foreach (string raw in robots.Split('\n'))
|
||||
{
|
||||
string line = raw.Split('#')[0].Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int colon = line.IndexOf(':', StringComparison.Ordinal);
|
||||
if (colon < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = line[..colon].Trim().ToLowerInvariant();
|
||||
string value = line[(colon + 1)..].Trim();
|
||||
if (key == "user-agent")
|
||||
{
|
||||
inStar = value == "*";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inStar || value.Length == 0 || (key != "disallow" && key != "allow"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string pattern = value.TrimEnd('*');
|
||||
if (path.StartsWith(pattern, StringComparison.Ordinal) && (bestRule is null || pattern.Length > bestRule.Length))
|
||||
{
|
||||
bestRule = pattern;
|
||||
bestAllow = key == "allow";
|
||||
}
|
||||
}
|
||||
|
||||
return bestAllow;
|
||||
}
|
||||
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The calendar and the news, kept on disk (append-only, deduplicated) and in memory,
|
||||
/// and turned into the features the decider and the panel read.
|
||||
/// </summary>
|
||||
public sealed class FeedContextProvider : IContextProvider, IDisposable
|
||||
{
|
||||
private readonly FeedFetcher _fetcher;
|
||||
private readonly string _calendarDir;
|
||||
private readonly string _newsDir;
|
||||
private readonly List<FeedSource> _sources;
|
||||
private readonly SentimentEngine _sentiment = new();
|
||||
private readonly Dictionary<string, CalendarEvent> _events = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _newsHashes = new(StringComparer.Ordinal);
|
||||
private readonly Lock _gate = new();
|
||||
private DateTime _calendarUpdatedUtc = DateTime.MinValue;
|
||||
private DateTime _newsUpdatedUtc = DateTime.MinValue;
|
||||
private int _newsToday;
|
||||
private string _calendarState = "non ancora letto";
|
||||
private string _newsState = "non ancora letti";
|
||||
|
||||
public FeedContextProvider(string dataDir, string userAgent)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDir);
|
||||
_calendarDir = Path.Combine(dataDir, "calendar");
|
||||
_newsDir = Path.Combine(dataDir, "news");
|
||||
Directory.CreateDirectory(_calendarDir);
|
||||
Directory.CreateDirectory(_newsDir);
|
||||
_fetcher = new FeedFetcher(Path.Combine(dataDir, "cache"), userAgent);
|
||||
_sources = DefaultSources();
|
||||
LoadFromDisk();
|
||||
}
|
||||
|
||||
/// <summary>The feeds verified on 2026-09-16 (see docs/DATA_SOURCES.md). SNB and RBNZ have no reachable feed and are covered by Google News queries.</summary>
|
||||
public static List<FeedSource> DefaultSources() =>
|
||||
[
|
||||
new("FairEconomy calendar", new Uri("https://nfs.faireconomy.media/ff_calendar_thisweek.json"), "calendar-json"),
|
||||
new("FXStreet", new Uri("https://www.fxstreet.com/rss/news"), "rss"),
|
||||
new("ForexLive", new Uri("https://www.forexlive.com/feed/"), "rss"),
|
||||
new("Fed", new Uri("https://www.federalreserve.gov/feeds/press_all.xml"), "rss", "USD"),
|
||||
new("ECB", new Uri("https://www.ecb.europa.eu/rss/press.html"), "rss", "EUR"),
|
||||
new("BoE", new Uri("https://www.bankofengland.co.uk/rss/news"), "rss", "GBP"),
|
||||
new("RBA", new Uri("https://www.rba.gov.au/rss/rss-cb-media-releases.xml"), "rss", "AUD"),
|
||||
new("BoC", new Uri("https://www.bankofcanada.ca/content_type/press-releases/feed/"), "rss", "CAD"),
|
||||
new("Google News EURUSD", new Uri("https://news.google.com/rss/search?q=EURUSD&hl=en-US&gl=US&ceid=US:en"), "rss"),
|
||||
new("Google News SNB", new Uri("https://news.google.com/rss/search?q=%22Swiss+National+Bank%22&hl=en-US&gl=US&ceid=US:en"), "rss", "CHF"),
|
||||
new("Google News RBNZ", new Uri("https://news.google.com/rss/search?q=RBNZ&hl=en-US&gl=US&ceid=US:en"), "rss", "NZD"),
|
||||
new("Google News RBA", new Uri("https://news.google.com/rss/search?q=%22Reserve+Bank+of+Australia%22&hl=en-US&gl=US&ceid=US:en"), "rss", "AUD"),
|
||||
new("Google News forex", new Uri("https://news.google.com/rss/search?q=forex+dollar&hl=en-US&gl=US&ceid=US:en"), "rss"),
|
||||
];
|
||||
|
||||
public IReadOnlyList<CalendarEvent> Events
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return [.. _events.Values.OrderBy(static e => e.TimeUtc)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshAsync(DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
foreach (FeedSource source in _sources)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
(string? body, bool fresh) = await _fetcher.FetchAsync(source, nowUtc, ct).ConfigureAwait(false);
|
||||
if (body is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (source.Kind == "calendar-json")
|
||||
{
|
||||
int added = AddEvents(CalendarParser.ParseJson(body));
|
||||
if (fresh)
|
||||
{
|
||||
_calendarUpdatedUtc = nowUtc;
|
||||
_calendarState = string.Create(CultureInfo.InvariantCulture, $"aggiornato {nowUtc:HH:mm} UTC, {_events.Count} eventi in memoria, {added} nuovi");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
List<NewsItem> items = RssParser.Parse(body, source.Name);
|
||||
int added = AddNews(items, source, nowUtc);
|
||||
if (fresh)
|
||||
{
|
||||
_newsUpdatedUtc = nowUtc;
|
||||
_newsToday += added;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Xml.XmlException or JsonException or FormatException)
|
||||
{
|
||||
Log.Warn($"feed {source.Name}: contenuto non leggibile ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
_sentiment.Forget(nowUtc);
|
||||
_newsState = string.Create(CultureInfo.InvariantCulture, $"{_sentiment.Count} notizie nelle ultime 30 h da {_sources.Count - 1} feed, ultimo aggiornamento {(_newsUpdatedUtc == DateTime.MinValue ? "—" : _newsUpdatedUtc.ToString("HH:mm", CultureInfo.InvariantCulture) + " UTC")}");
|
||||
}
|
||||
|
||||
private int AddEvents(List<CalendarEvent> events)
|
||||
{
|
||||
int added = 0;
|
||||
List<CalendarEvent> fresh = [];
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (CalendarEvent e in events)
|
||||
{
|
||||
if (_events.TryGetValue(e.Key, out CalendarEvent? old))
|
||||
{
|
||||
// Actuals arrive after the release: keep the newest picture, same key.
|
||||
if (old.Actual != e.Actual || old.Forecast != e.Forecast)
|
||||
{
|
||||
_events[e.Key] = e;
|
||||
fresh.Add(e);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
_events[e.Key] = e;
|
||||
fresh.Add(e);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
AppendJsonl(Path.Combine(_calendarDir, "events.jsonl"), fresh.Select(static e => SerializeEvent(e)));
|
||||
return added;
|
||||
}
|
||||
|
||||
private int AddNews(List<NewsItem> items, FeedSource source, DateTime nowUtc)
|
||||
{
|
||||
List<string> lines = [];
|
||||
int added = 0;
|
||||
foreach (NewsItem item in items)
|
||||
{
|
||||
if (item.PublishedUtc > nowUtc.AddHours(1) || nowUtc - item.PublishedUtc > TimeSpan.FromDays(3))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
NewsItem tagged = source.Currency.Length > 0 && !item.Summary.Contains(source.Currency, StringComparison.Ordinal)
|
||||
? item with { Summary = item.Summary + " " + source.Currency }
|
||||
: item;
|
||||
|
||||
if (_sentiment.Add(tagged) is { } scored && _newsHashes.Add(tagged.Hash))
|
||||
{
|
||||
added++;
|
||||
lines.Add(SerializeNews(scored));
|
||||
}
|
||||
}
|
||||
|
||||
AppendJsonl(Path.Combine(_newsDir, $"news_{nowUtc:yyyyMM}.jsonl"), lines);
|
||||
return added;
|
||||
}
|
||||
|
||||
private static void AppendJsonl(string path, IEnumerable<string> lines)
|
||||
{
|
||||
try
|
||||
{
|
||||
using StreamWriter w = new(path, append: true, new UTF8Encoding(false));
|
||||
foreach (string line in lines)
|
||||
{
|
||||
w.WriteLine(line);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"{Path.GetFileName(path)} non scritto: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeEvent(CalendarEvent e)
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("title", e.Title);
|
||||
w.WriteString("country", e.Currency);
|
||||
w.WriteString("date", e.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("impact", e.Impact.ToString());
|
||||
w.WriteString("forecast", e.Forecast);
|
||||
w.WriteString("previous", e.Previous);
|
||||
w.WriteString("actual", e.Actual);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
private static string SerializeNews(ScoredItem s)
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("hash", s.Item.Hash);
|
||||
w.WriteString("published", s.Item.PublishedUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("source", s.Item.Source);
|
||||
w.WriteString("title", s.Item.Title);
|
||||
w.WriteString("summary", s.Item.Summary.Length > 600 ? s.Item.Summary[..600] : s.Item.Summary);
|
||||
w.WriteString("link", s.Item.Link);
|
||||
w.WriteStartArray("currencies");
|
||||
foreach (string c in s.Currencies)
|
||||
{
|
||||
w.WriteStringValue(c);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WriteStartObject("scores");
|
||||
w.WriteNumber("net", Math.Round(s.Net, 4));
|
||||
w.WriteNumber("hawkish", Math.Round(s.Hawkish, 4));
|
||||
w.WriteNumber("riskOff", Math.Round(s.RiskOff, 4));
|
||||
w.WriteEndObject();
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Restores this week's events and the last 30 hours of news from the append-only files.</summary>
|
||||
private void LoadFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
string eventsPath = Path.Combine(_calendarDir, "events.jsonl");
|
||||
if (File.Exists(eventsPath))
|
||||
{
|
||||
foreach (string line in File.ReadLines(eventsPath))
|
||||
{
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(line);
|
||||
JsonElement r = doc.RootElement;
|
||||
DateTime t = DateTime.Parse(r.GetProperty("date").GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
if (DateTime.UtcNow - t > TimeSpan.FromDays(14))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CalendarEvent e = new(r.GetProperty("title").GetString() ?? string.Empty, r.GetProperty("country").GetString() ?? string.Empty, t,
|
||||
Enum.TryParse(r.GetProperty("impact").GetString(), out EventImpact impact) ? impact : EventImpact.Unknown,
|
||||
r.GetProperty("forecast").GetString() ?? string.Empty, r.GetProperty("previous").GetString() ?? string.Empty, r.GetProperty("actual").GetString() ?? string.Empty);
|
||||
_events[e.Key] = e;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
foreach (string month in new[] { now.ToString("yyyyMM", CultureInfo.InvariantCulture), now.AddMonths(-1).ToString("yyyyMM", CultureInfo.InvariantCulture) })
|
||||
{
|
||||
string newsPath = Path.Combine(_newsDir, $"news_{month}.jsonl");
|
||||
if (!File.Exists(newsPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string line in File.ReadLines(newsPath))
|
||||
{
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(line);
|
||||
JsonElement r = doc.RootElement;
|
||||
DateTime t = DateTime.Parse(r.GetProperty("published").GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
if (now - t > TimeSpan.FromHours(30))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
NewsItem item = new(t, r.GetProperty("source").GetString() ?? string.Empty, r.GetProperty("title").GetString() ?? string.Empty, r.GetProperty("summary").GetString() ?? string.Empty, r.GetProperty("link").GetString() ?? string.Empty);
|
||||
_newsHashes.Add(item.Hash);
|
||||
_sentiment.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
_calendarState = $"{_events.Count} eventi ripresi da disco";
|
||||
_newsState = $"{_sentiment.Count} notizie riprese da disco";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException or FormatException)
|
||||
{
|
||||
Log.Warn($"feed: archivio su disco non leggibile ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IContextProvider
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public BasketContextFeatures For(string longCurrency, string shortCurrency, string commonCurrency, DateTime nowUtc)
|
||||
{
|
||||
HashSet<string> currencies = new(StringComparer.Ordinal) { longCurrency, shortCurrency, commonCurrency };
|
||||
IReadOnlyList<CalendarEvent> events = Events;
|
||||
int toNext = CalendarFeatures.MinutesToNextHigh(events, currencies, nowUtc, out CalendarEvent? next);
|
||||
int sinceLast = CalendarFeatures.MinutesSinceLastHigh(events, currencies, nowUtc, out CalendarEvent? last);
|
||||
double surprise = last?.Surprise ?? double.NaN;
|
||||
|
||||
CurrencySentiment l = _sentiment.For(longCurrency, nowUtc);
|
||||
CurrencySentiment s = _sentiment.For(shortCurrency, nowUtc);
|
||||
string label = next is null ? "nessun evento ad alto impatto noto"
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{next.Currency} {next.TimeUtc:HH:mm} {next.Title} ({(toNext < 90 ? $"fra {toNext} min" : $"fra {toNext / 60.0:0.0} h")})");
|
||||
|
||||
return new BasketContextFeatures(toNext, sinceLast, surprise,
|
||||
l.Net1h - s.Net1h, l.Net4h - s.Net4h, l.Net24h - s.Net24h, l.Hawkish4h - s.Hawkish4h, _sentiment.RiskOff(nowUtc), l.Count24h + s.Count24h, label);
|
||||
}
|
||||
|
||||
public bool JustOpened(DateTime nowUtc, int openDelayMinutes)
|
||||
{
|
||||
if (nowUtc.DayOfWeek != DayOfWeek.Sunday || nowUtc.Hour < 21)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTime open = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, 21, 0, 0, DateTimeKind.Utc);
|
||||
return (nowUtc - open).TotalMinutes < openDelayMinutes + 60;
|
||||
}
|
||||
|
||||
public ContextRow Row(DateTime nowUtc)
|
||||
{
|
||||
List<SentimentRow> rows = [];
|
||||
foreach (string c in new[] { "USD", "EUR", "CHF", "AUD", "NZD", "CAD", "GBP", "JPY" })
|
||||
{
|
||||
CurrencySentiment s = _sentiment.For(c, nowUtc);
|
||||
rows.Add(new SentimentRow(c, s.Net1h, s.Net4h, s.Net24h, s.Hawkish4h, s.RiskOff4h, s.Count24h));
|
||||
}
|
||||
|
||||
List<CalendarRow> next = [.. Events.Where(e => e.Impact == EventImpact.High && e.TimeUtc >= nowUtc.AddMinutes(-30)).OrderBy(static e => e.TimeUtc).Take(5)
|
||||
.Select(static e => new CalendarRow(e.TimeUtc, e.Currency, e.Title, e.Impact.ToString(), e.Forecast, e.Previous))];
|
||||
|
||||
return new ContextRow(rows, next, "non disponibile", "non disponibile", "non disponibile", _calendarState, _newsState);
|
||||
}
|
||||
|
||||
public void Dispose() => _fetcher.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using Encelado.Bot.Configuration;
|
||||
using Encelado.Bot.Engine;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Baskets;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>
|
||||
/// The bot without a window: the same supervisor and engine, the log on the console,
|
||||
/// a status line every minute and commands from standard input. For a VPS, a service,
|
||||
/// or a long unattended test.
|
||||
/// <para>
|
||||
/// Commands: <c>status</c>, <c>close <basket></c>, <c>kill</c>, <c>preset <nome></c>,
|
||||
/// <c>reset <motivazione></c>, <c>stop</c>. Arguments: <c>--headless</c>,
|
||||
/// <c>--confirm-live "CONFERMO LIVE"</c>, <c>--minutes N</c> (stop by itself after N minutes).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class HeadlessRunner
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool AttachConsole(int processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool AllocConsole();
|
||||
|
||||
private const int AttachParentProcess = -1;
|
||||
|
||||
public static async Task<int> RunAsync(BotConfig config, string[] args)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(args);
|
||||
|
||||
if (OperatingSystem.IsWindows() && !AttachConsole(AttachParentProcess))
|
||||
{
|
||||
AllocConsole();
|
||||
}
|
||||
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Encelado headless — configurazione {App.ConfigPath}");
|
||||
|
||||
foreach (string warning in App.ConfigWarnings)
|
||||
{
|
||||
Log.Warn($"configurazione: {warning}");
|
||||
}
|
||||
|
||||
if (App.SeedNote is { } seeded)
|
||||
{
|
||||
Log.Warn(seeded);
|
||||
}
|
||||
|
||||
// Keys: environment, then the encrypted store. Never asked for on the console.
|
||||
if (!EtoroKeyStore.Resolve(config, out string origin))
|
||||
{
|
||||
Log.Error("nessuna chiave eToro: inseriscile una volta dalla finestra (avvio senza --headless) oppure con ETORO_API_KEY e ETORO_USER_KEY", null);
|
||||
return 3;
|
||||
}
|
||||
|
||||
Log.Info($"chiavi eToro: {origin}");
|
||||
|
||||
ExecutionMode mode = config.Run.Mode;
|
||||
if (mode.IsLive() && !HasLivePhrase(args))
|
||||
{
|
||||
Log.Error($"la modalità Live richiede l'argomento --confirm-live \"{Ui.PromptWindow.LivePhrase}\"", null);
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (mode.IsLive())
|
||||
{
|
||||
Log.Warn("avvio in Live sul conto REALE confermato da riga di comando");
|
||||
}
|
||||
|
||||
int minutes = 0;
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals("--minutes", StringComparison.OrdinalIgnoreCase) && int.TryParse(args[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int m))
|
||||
{
|
||||
minutes = m;
|
||||
}
|
||||
}
|
||||
|
||||
await using BotSupervisor supervisor = new(config) { StartConfirmed = true };
|
||||
using CancellationTokenSource stopping = new();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
Log.Info("Ctrl+C: arresto");
|
||||
stopping.Cancel();
|
||||
};
|
||||
|
||||
CommandResult started = await supervisor.StartAsync().ConfigureAwait(false);
|
||||
if (!started.Ok)
|
||||
{
|
||||
Log.Error($"avvio fallito: {started.Message}", null);
|
||||
return 5;
|
||||
}
|
||||
|
||||
Log.Info($"bot avviato in {mode}. Comandi: status, close <basket>, kill, preset <nome>, reset <motivazione>, stop");
|
||||
if (minutes > 0)
|
||||
{
|
||||
Log.Info($"arresto automatico fra {minutes} minuti");
|
||||
stopping.CancelAfter(TimeSpan.FromMinutes(minutes));
|
||||
}
|
||||
|
||||
Task input = Task.Run(() => ReadCommandsAsync(supervisor, stopping), stopping.Token);
|
||||
DateTime lastStatus = DateTime.MinValue;
|
||||
|
||||
try
|
||||
{
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(1000, stopping.Token).ConfigureAwait(false);
|
||||
if (supervisor.State is BotState.Faulted or BotState.Stopped)
|
||||
{
|
||||
Log.Warn("il motore si è fermato");
|
||||
break;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - lastStatus >= TimeSpan.FromSeconds(config.Run.StatusSeconds))
|
||||
{
|
||||
lastStatus = DateTime.UtcNow;
|
||||
PrintStatus(supervisor.Snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Stop requested.
|
||||
}
|
||||
|
||||
await supervisor.StopAsync().ConfigureAwait(false);
|
||||
await Log.FlushAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool HasLivePhrase(string[] args)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals("--confirm-live", StringComparison.OrdinalIgnoreCase) && args[i + 1] == Ui.PromptWindow.LivePhrase)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task ReadCommandsAsync(BotSupervisor supervisor, CancellationTokenSource stopping)
|
||||
{
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
string? line;
|
||||
try
|
||||
{
|
||||
line = await Console.In.ReadLineAsync(stopping.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (line is null)
|
||||
{
|
||||
// No console attached (a service): keep running until cancelled.
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stopping.Token).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
string[] parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string arg = parts.Length > 1 ? parts[1].Trim() : string.Empty;
|
||||
CommandResult result;
|
||||
switch (parts[0].ToLowerInvariant())
|
||||
{
|
||||
case "stop" or "quit" or "exit":
|
||||
stopping.Cancel();
|
||||
return;
|
||||
case "status":
|
||||
PrintStatus(supervisor.Snapshot());
|
||||
continue;
|
||||
case "close":
|
||||
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Close, arg, "chiusura manuale da console"), CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case "kill":
|
||||
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, string.Empty, "kill-switch da console"), CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case "preset":
|
||||
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.SetPreset, arg, "cambio preset da console"), CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
case "reset":
|
||||
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.ResetEquityStop, string.Empty, arg), CancellationToken.None).ConfigureAwait(false);
|
||||
break;
|
||||
default:
|
||||
Console.WriteLine("comandi: status, close <basket>, kill, preset <nome>, reset <motivazione>, stop");
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine((result.Ok ? "ok: " : "NO: ") + result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintStatus(BotSnapshot s)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
|
||||
$"── {DateTime.UtcNow:HH:mm:ss} UTC · {s.Mode} · preset {s.Preset} · API {s.ApiState} {(double.IsFinite(s.ApiLatencyMs) ? s.ApiLatencyMs.ToString("0") + " ms" : "—")} · skew {s.ClockSkewSeconds:+0.0;-0.0} s"));
|
||||
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
|
||||
$" BALANCE {s.Balance:N2} EQUITY {s.Equity:N2} TOTAL {s.OpenPnl:+0.00;-0.00} ({s.OpenPnlPct:P2}) TODAY {s.TodayPnl:+0.00;-0.00} ({s.TodayPnlPct:P2}) DD {s.DrawdownPct:P2} basket {s.OpenBaskets}/{s.MaxBaskets}") +
|
||||
(s.Halted ? $" BLOCCO: {s.HaltReason}" : string.Empty) +
|
||||
(s.EntriesBlockedReason is { Length: > 0 } blocked ? $" entrate bloccate: {blocked}" : string.Empty));
|
||||
Console.WriteLine($" {"Coppie",-14} {"(n)",3} {"$",9} {"%",7} {"Pips",6} {"TP",3} {"ρ",6} {"z",6} {"HL",4} {"Costo",5} {"p_ML",6} Stato");
|
||||
foreach (BasketRow b in s.Baskets)
|
||||
{
|
||||
Console.WriteLine($" {b.Name,-14} {b.OpenLegs,3} {b.PnlDisplay,9} {b.PnlPctDisplay,7} {b.PipsDisplay,6} {b.TpDisplay,3} {b.RhoDisplay,6} {b.ZDisplay,6} {b.HalfLifeDisplay,4} {b.CostDisplay,5} {b.PMlDisplay,6} {(b.Enabled ? b.State : "OFF")} {b.Tooltip}");
|
||||
}
|
||||
|
||||
if (s.Quotes.Count > 0)
|
||||
{
|
||||
Console.WriteLine(" " + string.Join(" ", s.Quotes.Select(static q => $"{q.Symbol} {q.BidDisplay}/{q.AskDisplay} ({q.SpreadDisplay})")));
|
||||
}
|
||||
|
||||
if (s.Context is { } c)
|
||||
{
|
||||
Console.WriteLine($" vol: {c.VolForecast} · ML: {c.MlState} · bandit: {c.BanditProposal} · calendario: {c.CalendarState} · notizie: {c.NewsState}");
|
||||
foreach (CalendarRow ev in c.NextEvents.Take(5))
|
||||
{
|
||||
Console.WriteLine($" evento {ev.TimeLocal} {ev.Currency} {ev.Title} ({ev.InMinutes})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Baskets;
|
||||
using Encelado.Core.Baskets.Learning;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>
|
||||
/// The learning stack at runtime (§8): the shadow logistic model that scores every
|
||||
/// entry and learns from every close, the challenger MLP, the preset bandit, the
|
||||
/// activation state, and the weekly cycle that rebuilds everything from the ledger and
|
||||
/// writes the knowledge base. Nothing here changes a live parameter: proposals go to
|
||||
/// <c>knowledge/proposals.csv</c>.
|
||||
/// </summary>
|
||||
public sealed class LearningState : IDisposable
|
||||
{
|
||||
private readonly string _modelsDir;
|
||||
private readonly string _knowledgeDir;
|
||||
private readonly Ledger _ledger;
|
||||
private readonly BasketStrategyConfig _cfg;
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Dictionary<string, double[]> _entryFeatures = new(StringComparer.Ordinal);
|
||||
private readonly List<(double P, int Label)> _recent = [];
|
||||
private readonly List<int> _lastOutcomes = [];
|
||||
private OnlineLogistic _logistic;
|
||||
private SmallMlp? _challenger;
|
||||
private ThompsonBandit _bandit;
|
||||
private bool _active;
|
||||
private string _champion = "logistica";
|
||||
private int _version;
|
||||
private DateTime _lastCycleUtc = DateTime.MinValue;
|
||||
private string _lastReport = "nessun ciclo eseguito";
|
||||
private string _banditProposal = "—";
|
||||
private double _rollingAuc = double.NaN;
|
||||
|
||||
public LearningState(string dataDir, string knowledgeDir, Ledger ledger, BasketStrategyConfig cfg)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDir);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(knowledgeDir);
|
||||
ArgumentNullException.ThrowIfNull(ledger);
|
||||
ArgumentNullException.ThrowIfNull(cfg);
|
||||
_modelsDir = Path.Combine(dataDir, "models");
|
||||
_knowledgeDir = knowledgeDir;
|
||||
_ledger = ledger;
|
||||
_cfg = cfg;
|
||||
Directory.CreateDirectory(_modelsDir);
|
||||
Directory.CreateDirectory(knowledgeDir);
|
||||
_logistic = new OnlineLogistic(LearningFeatures.Count);
|
||||
_bandit = new ThompsonBandit(3, 42);
|
||||
Load();
|
||||
SeedOutcomesFromLedger();
|
||||
}
|
||||
|
||||
public bool Active => _active;
|
||||
|
||||
public int Version => _version;
|
||||
|
||||
public DateTime LastCycleUtc => _lastCycleUtc;
|
||||
|
||||
/// <summary>Mean of the last ten labels, or NaN before there are any.</summary>
|
||||
public double LastOutcomes
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _lastOutcomes.Count == 0 ? double.NaN : _lastOutcomes.TakeLast(10).Average();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The champion's probability for a candidate entry. NaN before any basket has been seen.</summary>
|
||||
public double Predict(double[] features)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(features);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_logistic.Seen == 0 && _challenger is null)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
return _champion == "mlp16" && _challenger is not null ? _challenger.Predict(features) : _logistic.Predict(features);
|
||||
}
|
||||
}
|
||||
|
||||
public void RememberEntry(string basketId, double[] features)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_entryFeatures[basketId] = features;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A basket closed: the shadow model learns, the rolling AUC is refreshed, the bandit is rewarded.</summary>
|
||||
public void Observe(string basketId, int label, int volContext, PresetName preset)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lastOutcomes.Add(label);
|
||||
if (_lastOutcomes.Count > 100)
|
||||
{
|
||||
_lastOutcomes.RemoveAt(0);
|
||||
}
|
||||
|
||||
_bandit.Reward(volContext, preset, label == 1);
|
||||
if (!_entryFeatures.Remove(basketId, out double[]? features))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double p = _logistic.Predict(features);
|
||||
_recent.Add((p, label));
|
||||
if (_recent.Count > 200)
|
||||
{
|
||||
_recent.RemoveAt(0);
|
||||
}
|
||||
|
||||
_logistic.Update(features, label);
|
||||
_challenger?.Update(features, label);
|
||||
|
||||
_rollingAuc = ModelEvaluator.RollingAuc(_recent, 100);
|
||||
if (_active && double.IsFinite(_rollingAuc) && _rollingAuc < ModelEvaluator.DeactivationAuc && _recent.Count >= 100)
|
||||
{
|
||||
_active = false;
|
||||
Log.Warn(string.Create(CultureInfo.InvariantCulture, $"meta-modello: AUC mobile {_rollingAuc:F3} sotto {ModelEvaluator.DeactivationAuc:F2}: torna in shadow mode"));
|
||||
AppendRegistry("models_registry.csv", "versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione",
|
||||
string.Create(CultureInfo.InvariantCulture, $"v{_version};{DateTime.UtcNow:O};{_champion};{_logistic.Seen};{_rollingAuc:F3};;;shadow;AUC mobile su 100 basket sotto {ModelEvaluator.DeactivationAuc:F2}"));
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The bandit's proposal for the volatility context, as text and as a preset.</summary>
|
||||
public (PresetName Preset, string Text) Propose(int volContext)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
(PresetName preset, bool explored, double[] means) = _bandit.Propose(volContext);
|
||||
_banditProposal = string.Create(CultureInfo.InvariantCulture,
|
||||
$"propone {preset.ToString().ToUpperInvariant()}{(explored ? " (esplorazione)" : string.Empty)} nel terzile di vol {volContext} — medie CONS {means[0]:0.00}, MOD {means[1]:0.00}, AGG {means[2]:0.00}; {_bandit.Choices} scelte");
|
||||
return (preset, _banditProposal);
|
||||
}
|
||||
}
|
||||
|
||||
public string BanditText => _banditProposal;
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
string auc = double.IsFinite(_rollingAuc) ? _rollingAuc.ToString("0.000", CultureInfo.InvariantCulture) : "n/d";
|
||||
return $"{_champion} v{_version} {(_active ? "ATTIVA (gate)" : "in ombra")}: {_logistic.Seen} basket visti, AUC mobile {auc}; {_lastReport}";
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Weekly cycle (§8.7)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public bool CycleDue(DateTime nowUtc) => nowUtc - _lastCycleUtc >= TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Rebuilds the dataset from the ledger, retrains and evaluates walk-forward, refreshes the knowledge base.</summary>
|
||||
public void RunCycle(DateTime nowUtc)
|
||||
{
|
||||
List<LabelledBasket> rows = BuildDataset();
|
||||
lock (_gate)
|
||||
{
|
||||
_lastCycleUtc = nowUtc;
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
_lastReport = "ledger senza basket chiusi: niente da addestrare";
|
||||
Save();
|
||||
return;
|
||||
}
|
||||
|
||||
(ModelReport l1, OnlineLogistic retrained) = ModelEvaluator.EvaluateLogistic(rows, _cfg.MlMinProbability);
|
||||
(ModelReport l2, SmallMlp? mlp) = ModelEvaluator.EvaluateMlp(rows, _cfg.MlMinProbability);
|
||||
|
||||
_version++;
|
||||
_logistic = retrained;
|
||||
_challenger = mlp;
|
||||
bool wasActive = _active;
|
||||
_active = l1.PassesActivation;
|
||||
_lastReport = l1.Summary;
|
||||
|
||||
File.WriteAllText(Path.Combine(_modelsDir, $"logreg_v{_version}.json"), Wrap(retrained.ToJson(), rows, nowUtc));
|
||||
if (mlp is not null)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_modelsDir, $"mlp_v{_version}.json"), Wrap(mlp.ToJson(), rows, nowUtc));
|
||||
}
|
||||
|
||||
AppendRegistry("models_registry.csv", "versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione",
|
||||
string.Create(CultureInfo.InvariantCulture, $"v{_version};{nowUtc:O};logistica;{rows.Count};{l1.Auc:F3};{l1.Brier:F3};{l1.LogLoss:F3};{(_active ? "champion attivo" : "champion in ombra")};{l1.Motivazione}"));
|
||||
if (mlp is not null)
|
||||
{
|
||||
bool challengerWins = double.IsFinite(l2.Auc) && double.IsFinite(l1.Auc) && l2.Auc >= l1.Auc + 0.01;
|
||||
AppendRegistry("models_registry.csv", "versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione",
|
||||
string.Create(CultureInfo.InvariantCulture, $"v{_version};{nowUtc:O};mlp16;{rows.Count};{l2.Auc:F3};{l2.Brier:F3};{l2.LogLoss:F3};challenger;{(challengerWins ? "batte la logistica per AUC di almeno 0,01: promozione solo con il P&L del forward test" : l2.Motivazione)}"));
|
||||
}
|
||||
|
||||
WriteCalibration(rows);
|
||||
WriteInsights(nowUtc, rows, l1, l2);
|
||||
WriteProposals(nowUtc, rows, l1);
|
||||
if (_active != wasActive)
|
||||
{
|
||||
Log.Warn(_active ? "meta-modello ATTIVATO come gate degli ingressi" : "meta-modello in shadow mode");
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
Log.Info($"ciclo settimanale di apprendimento eseguito su {rows.Count} basket: {_lastReport}");
|
||||
}
|
||||
|
||||
private static string Wrap(string modelJson, List<LabelledBasket> rows, DateTime nowUtc)
|
||||
{
|
||||
using JsonDocument model = JsonDocument.Parse(modelJson);
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("trained_on_until", rows.Count > 0 ? rows.Max(static r => r.ClosedUtc).ToString("O", CultureInfo.InvariantCulture) : string.Empty);
|
||||
w.WriteString("trained_at", nowUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteNumber("rows", rows.Count);
|
||||
w.WriteString("dataset_hash", DatasetHash(rows));
|
||||
w.WriteStartArray("features");
|
||||
foreach (string f in LearningFeatures.Names)
|
||||
{
|
||||
w.WriteStringValue(f);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WritePropertyName("model");
|
||||
model.RootElement.WriteTo(w);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
private static string DatasetHash(List<LabelledBasket> rows)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
foreach (LabelledBasket r in rows)
|
||||
{
|
||||
sb.Append(r.BasketId).Append(':').Append(r.Label).Append(';');
|
||||
}
|
||||
|
||||
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())))[..16].ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Joins the <c>ingresso</c> rows of the decisions ledger (features at entry) with the closed baskets (labels).</summary>
|
||||
public List<LabelledBasket> BuildDataset()
|
||||
{
|
||||
Dictionary<string, BasketOutcomeRow> outcomes = new(StringComparer.Ordinal);
|
||||
foreach (BasketOutcomeRow r in _ledger.ReadBaskets())
|
||||
{
|
||||
outcomes[r.BasketId] = r;
|
||||
}
|
||||
|
||||
Dictionary<string, (double[] Features, DateTime Ts)> entries = new(StringComparer.Ordinal);
|
||||
string dir = Path.GetDirectoryName(_ledger.DecisionsPath)!;
|
||||
foreach (string file in Directory.GetFiles(dir, "decisions*.jsonl").OrderBy(static f => f, StringComparer.Ordinal))
|
||||
{
|
||||
foreach (string line in Ledger.ReadLines(file))
|
||||
{
|
||||
try
|
||||
{
|
||||
double[]? f = LearningFeatures.FromLedgerLine(line, out string id, out DateTime ts);
|
||||
if (f is not null && id.Length > 0)
|
||||
{
|
||||
entries[id] = (f, ts);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A malformed line is skipped, never repaired in place.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<LabelledBasket> rows = [];
|
||||
foreach ((string id, BasketOutcomeRow o) in outcomes)
|
||||
{
|
||||
if (!entries.TryGetValue(id, out (double[] Features, DateTime Ts) e))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new LabelledBasket(id, o.OpenedUtc, o.ClosedUtc, e.Features, o.Label, o.PnlNetUsd, o.Basket, o.Preset, e.Features[25]));
|
||||
}
|
||||
|
||||
rows.Sort(static (a, b) => a.OpenedUtc.CompareTo(b.OpenedUtc));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private void WriteCalibration(List<LabelledBasket> rows)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine(CalibrationTables.Header);
|
||||
foreach (CalibrationTables.Row r in CalibrationTables.Build(rows))
|
||||
{
|
||||
sb.AppendLine(r.ToCsv());
|
||||
}
|
||||
|
||||
Atomic(Path.Combine(_knowledgeDir, "calibration.csv"), sb.ToString());
|
||||
}
|
||||
|
||||
private void WriteInsights(DateTime nowUtc, List<LabelledBasket> rows, ModelReport l1, ModelReport l2)
|
||||
{
|
||||
int week = System.Globalization.ISOWeek.GetWeekOfYear(nowUtc);
|
||||
string path = Path.Combine(_knowledgeDir, $"insights_{nowUtc.Year}{week:00}.md");
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"# Insight settimanali — {nowUtc:yyyy-MM-dd} (settimana {week})");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"Basket chiusi nel ledger: **{rows.Count}**, win rate {rows.Average(static r => r.Label):P0}, P&L netto {rows.Sum(static r => r.PnlNetUsd):F2} USD.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Cosa ha funzionato e cosa no (calibrazione, livello 0)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| dimensione | bucket | n | win rate | P&L medio |");
|
||||
sb.AppendLine("|---|---|---|---|---|");
|
||||
foreach (CalibrationTables.Row r in CalibrationTables.Build(rows).Where(static r => r.Count >= 5))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"| {r.Dimension} | {r.Bucket} | {r.Count} | {r.WinRate:P0} | {r.MeanPnl:F2} |");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Meta-modello (livelli 1 e 2, walk-forward)");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("- " + l1.Summary);
|
||||
sb.AppendLine("- " + l2.Summary);
|
||||
if (l1.Calibration.Count > 0)
|
||||
{
|
||||
sb.AppendLine("- calibrazione logistica: " + ModelEvaluator.DescribeCalibration(l1.Calibration));
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Bandit (livello 3)");
|
||||
sb.AppendLine();
|
||||
for (int c = 0; c < 3; c++)
|
||||
{
|
||||
sb.AppendLine("- " + _bandit.Describe(c));
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Parametri suggeriti");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Nessun parametro live viene cambiato da questo file: le proposte sono in `proposals.csv` e passano dal forward test pre-registrato.");
|
||||
Atomic(path, sb.ToString());
|
||||
}
|
||||
|
||||
private void WriteProposals(DateTime nowUtc, List<LabelledBasket> rows, ModelReport l1)
|
||||
{
|
||||
string path = Path.Combine(_knowledgeDir, "proposals.csv");
|
||||
bool isNew = !File.Exists(path);
|
||||
StringBuilder sb = new();
|
||||
if (isNew)
|
||||
{
|
||||
sb.AppendLine("data;origine;parametro;valore_attuale;valore_proposto;evidenza;stato;motivazione");
|
||||
}
|
||||
|
||||
// The one proposal the evidence can support at this stage: whether the meta-model gate is worth turning on.
|
||||
sb.AppendLine(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{nowUtc:O};ciclo settimanale;mlMinProbability gate;{(_active ? "attivo" : "ombra")};{(l1.PassesActivation ? "attivo" : "ombra")};AUC {l1.Auc:F3} su {rows.Count} basket, P&L filtrato {l1.PnlFiltered:F0} contro {l1.PnlAll:F0};proposta;{l1.Motivazione}"));
|
||||
|
||||
// Per-basket evidence: a basket that loses over 30+ closes is a candidate for disabling.
|
||||
foreach (IGrouping<string, LabelledBasket> g in rows.GroupBy(static r => r.Basket))
|
||||
{
|
||||
int n = g.Count();
|
||||
double pnl = g.Sum(static r => r.PnlNetUsd);
|
||||
if (n >= 30 && pnl < 0)
|
||||
{
|
||||
sb.AppendLine(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{nowUtc:O};ciclo settimanale;baskets[{g.Key}].enabled;true;false;{n} basket, P&L netto {pnl:F0} USD, win rate {g.Average(static r => r.Label):P0};proposta;il basket perde in modo persistente: da valutare nel forward test prima di disattivarlo"));
|
||||
}
|
||||
}
|
||||
|
||||
File.AppendAllText(path, sb.ToString(), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private void AppendRegistry(string file, string header, string line)
|
||||
{
|
||||
string path = Path.Combine(_knowledgeDir, file);
|
||||
bool isNew = !File.Exists(path);
|
||||
File.AppendAllText(path, (isNew ? header + Environment.NewLine : string.Empty) + line + Environment.NewLine, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static void Atomic(string path, string content)
|
||||
{
|
||||
File.WriteAllText(path + ".tmp", content, new UTF8Encoding(false));
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private string StatePath => Path.Combine(_modelsDir, "learning_state.json");
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_modelsDir, "logreg_current.json"), _logistic.ToJson());
|
||||
if (_challenger is not null)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_modelsDir, "mlp_current.json"), _challenger.ToJson());
|
||||
}
|
||||
|
||||
File.WriteAllText(Path.Combine(_modelsDir, "bandit.json"), _bandit.ToJson());
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteBoolean("active", _active);
|
||||
w.WriteString("champion", _champion);
|
||||
w.WriteNumber("version", _version);
|
||||
w.WriteString("lastCycleUtc", _lastCycleUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("lastReport", _lastReport);
|
||||
w.WriteStartArray("recent");
|
||||
foreach ((double p, int label) in _recent)
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteNumber("p", Math.Round(p, 6));
|
||||
w.WriteNumber("label", label);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WriteStartObject("entries");
|
||||
foreach ((string id, double[] f) in _entryFeatures)
|
||||
{
|
||||
w.WriteStartArray(id);
|
||||
foreach (double v in f)
|
||||
{
|
||||
w.WriteNumberValue(double.IsFinite(v) ? v : 0);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
}
|
||||
|
||||
w.WriteEndObject();
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
File.WriteAllBytes(StatePath + ".tmp", ms.ToArray());
|
||||
File.Move(StatePath + ".tmp", StatePath, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"stato dell'apprendimento non salvato: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
string logreg = Path.Combine(_modelsDir, "logreg_current.json");
|
||||
if (File.Exists(logreg))
|
||||
{
|
||||
_logistic = OnlineLogistic.FromJson(File.ReadAllText(logreg));
|
||||
}
|
||||
|
||||
string mlp = Path.Combine(_modelsDir, "mlp_current.json");
|
||||
if (File.Exists(mlp))
|
||||
{
|
||||
_challenger = SmallMlp.FromJson(File.ReadAllText(mlp));
|
||||
}
|
||||
|
||||
string bandit = Path.Combine(_modelsDir, "bandit.json");
|
||||
if (File.Exists(bandit))
|
||||
{
|
||||
_bandit = ThompsonBandit.FromJson(File.ReadAllText(bandit));
|
||||
}
|
||||
|
||||
if (File.Exists(StatePath))
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(File.ReadAllBytes(StatePath));
|
||||
JsonElement r = doc.RootElement;
|
||||
_active = r.TryGetProperty("active", out JsonElement a) && a.GetBoolean();
|
||||
_champion = r.TryGetProperty("champion", out JsonElement c) ? c.GetString() ?? "logistica" : "logistica";
|
||||
_version = r.TryGetProperty("version", out JsonElement v) ? v.GetInt32() : 0;
|
||||
_lastReport = r.TryGetProperty("lastReport", out JsonElement lr) ? lr.GetString() ?? string.Empty : string.Empty;
|
||||
if (r.TryGetProperty("lastCycleUtc", out JsonElement lc) && DateTime.TryParse(lc.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t))
|
||||
{
|
||||
_lastCycleUtc = t;
|
||||
}
|
||||
|
||||
if (r.TryGetProperty("recent", out JsonElement recent))
|
||||
{
|
||||
foreach (JsonElement e in recent.EnumerateArray())
|
||||
{
|
||||
_recent.Add((e.GetProperty("p").GetDouble(), e.GetProperty("label").GetInt32()));
|
||||
}
|
||||
}
|
||||
|
||||
if (r.TryGetProperty("entries", out JsonElement entries))
|
||||
{
|
||||
foreach (JsonProperty p in entries.EnumerateObject())
|
||||
{
|
||||
_entryFeatures[p.Name] = [.. p.Value.EnumerateArray().Select(static x => x.GetDouble())];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException or InvalidOperationException)
|
||||
{
|
||||
Log.Warn($"stato dell'apprendimento non leggibile ({ex.Message}): riparto da zero");
|
||||
_logistic = new OnlineLogistic(LearningFeatures.Count);
|
||||
_challenger = null;
|
||||
_bandit = new ThompsonBandit(3, 42);
|
||||
_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SeedOutcomesFromLedger()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (BasketOutcomeRow r in _ledger.ReadBaskets().TakeLast(100))
|
||||
{
|
||||
_lastOutcomes.Add(r.Label);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The ledger may be absent on a first run.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Save();
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Encelado.Bot.Logging;
|
||||
using Encelado.Core.Baskets;
|
||||
|
||||
namespace Encelado.Bot.Baskets;
|
||||
|
||||
/// <summary>One closed basket as <c>baskets.csv</c> records it.</summary>
|
||||
public sealed record BasketOutcomeRow(
|
||||
string BasketId,
|
||||
string RunId,
|
||||
string Basket,
|
||||
string Mode,
|
||||
string Preset,
|
||||
DateTime OpenedUtc,
|
||||
DateTime ClosedUtc,
|
||||
bool BuyCross,
|
||||
double EntryZ,
|
||||
double ExitZ,
|
||||
double PnlGrossUsd,
|
||||
double PnlNetUsd,
|
||||
double PipsGross,
|
||||
double CostPips,
|
||||
double CostUsd,
|
||||
double SlippagePips,
|
||||
int Adds,
|
||||
int BarsHeld,
|
||||
string ExitReason,
|
||||
double EquityAtEntry,
|
||||
double PMlAtEntry,
|
||||
string Motivazione)
|
||||
{
|
||||
public int Label => PnlNetUsd > 0 ? 1 : 0;
|
||||
|
||||
public const string Header =
|
||||
"basket_id;run_id;basket;mode;preset;opened_utc;closed_utc;buy_cross;entry_z;exit_z;pnl_gross_usd;pnl_net_usd;pips_gross;cost_pips;cost_usd;slippage_pips;adds;bars_held;exit_reason;equity_at_entry;p_ml_at_entry;label;durata_min;motivazione";
|
||||
|
||||
public string ToCsv() => string.Join(';',
|
||||
[
|
||||
BasketId, RunId, Basket, Mode, Preset,
|
||||
OpenedUtc.ToString("O", CultureInfo.InvariantCulture), ClosedUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
BuyCross ? "1" : "0", N(EntryZ), N(ExitZ), N(PnlGrossUsd), N(PnlNetUsd), N(PipsGross), N(CostPips), N(CostUsd), N(SlippagePips),
|
||||
Adds.ToString(CultureInfo.InvariantCulture), BarsHeld.ToString(CultureInfo.InvariantCulture), ExitReason, N(EquityAtEntry), N(PMlAtEntry),
|
||||
Label.ToString(CultureInfo.InvariantCulture), ((ClosedUtc - OpenedUtc).TotalMinutes).ToString("0", CultureInfo.InvariantCulture),
|
||||
Motivazione.Replace(';', ',').Replace('\n', ' ').Replace('\r', ' '),
|
||||
]);
|
||||
|
||||
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.######", CultureInfo.InvariantCulture) : string.Empty;
|
||||
|
||||
public static BasketOutcomeRow? Parse(string line)
|
||||
{
|
||||
string[] f = line.Split(';');
|
||||
if (f.Length < 24 || f[0] == "basket_id")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new BasketOutcomeRow(f[0], f[1], f[2], f[3], f[4], T(f[5]), T(f[6]), f[7] == "1", D(f[8]), D(f[9]), D(f[10]), D(f[11]), D(f[12]), D(f[13]), D(f[14]), D(f[15]),
|
||||
int.Parse(f[16], CultureInfo.InvariantCulture), int.Parse(f[17], CultureInfo.InvariantCulture), f[18], D(f[19]), D(f[20]), f[23]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
static DateTime T(string s) => DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
static double D(string s) => s.Length == 0 ? double.NaN : double.Parse(s, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The history of information and decisions (§8.1): <c>decisions.jsonl</c> gets one
|
||||
/// line for <b>every</b> evaluation of every basket, with the features as they were at
|
||||
/// that moment; <c>baskets.csv</c> gets one row per closed basket. Both are append-only:
|
||||
/// a correction is a new line with <c>evento = correzione</c>, never an edit.
|
||||
/// </summary>
|
||||
public sealed class Ledger : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly Lock _gate = new();
|
||||
private StreamWriter? _decisions;
|
||||
private StreamWriter? _baskets;
|
||||
private string _decisionsMonth = string.Empty;
|
||||
|
||||
public Ledger(string directory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
_directory = directory;
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
public string DecisionsPath => Path.Combine(_directory, "decisions.jsonl");
|
||||
|
||||
public string BasketsPath => Path.Combine(_directory, "baskets.csv");
|
||||
|
||||
/// <summary>Appends one evaluation. Never throws into the engine.</summary>
|
||||
public void Decision(
|
||||
string runId, string mode, string preset, string configHash, BasketContext ctx, BasketDecision d,
|
||||
string evento, string? basketId, string? motivazioneExtra = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string line = Serialize(runId, mode, preset, configHash, ctx, d, evento, basketId, motivazioneExtra);
|
||||
lock (_gate)
|
||||
{
|
||||
RotateIfNeeded(ctx.TimeUtc);
|
||||
_decisions ??= Open(DecisionsPath);
|
||||
_decisions.WriteLine(line);
|
||||
_decisions.Flush();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"ledger: riga di decisione non scritta ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
public void Basket(BasketOutcomeRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
try
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
bool isNew = !File.Exists(BasketsPath) || new FileInfo(BasketsPath).Length == 0;
|
||||
_baskets ??= Open(BasketsPath);
|
||||
if (isNew)
|
||||
{
|
||||
_baskets.WriteLine(BasketOutcomeRow.Header);
|
||||
}
|
||||
|
||||
_baskets.WriteLine(row.ToCsv());
|
||||
_baskets.Flush();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"ledger: riga di basket non scritta ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A correction is a new line, never an edit of an old one.</summary>
|
||||
public void Correction(string runId, string basketId, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("ts", DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("run_id", runId);
|
||||
w.WriteString("evento", "correzione");
|
||||
w.WriteString("basket_id", basketId);
|
||||
w.WriteString("motivazione", what);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_decisions ??= Open(DecisionsPath);
|
||||
_decisions.WriteLine(Encoding.UTF8.GetString(ms.ToArray()));
|
||||
_decisions.Flush();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"ledger: correzione non scritta ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads every closed basket, oldest first: the training set.</summary>
|
||||
public List<BasketOutcomeRow> ReadBaskets()
|
||||
{
|
||||
List<BasketOutcomeRow> rows = [];
|
||||
foreach (string line in ReadLines(BasketsPath))
|
||||
{
|
||||
if (BasketOutcomeRow.Parse(line) is { } r)
|
||||
{
|
||||
rows.Add(r);
|
||||
}
|
||||
}
|
||||
|
||||
rows.Sort(static (a, b) => a.OpenedUtc.CompareTo(b.OpenedUtc));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>The current month's decision lines, oldest first.</summary>
|
||||
public List<string> ReadDecisionLines() => ReadLines(DecisionsPath);
|
||||
|
||||
/// <summary>Reads a file the ledger may still hold open for appending.</summary>
|
||||
public static List<string> ReadLines(string path)
|
||||
{
|
||||
List<string> lines = [];
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return lines;
|
||||
}
|
||||
|
||||
using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using StreamReader reader = new(stream, Encoding.UTF8);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
if (line.Length > 0)
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>Monthly rotation: the current file is moved to <c>decisions_YYYYMM.jsonl</c> when the month changes.</summary>
|
||||
private void RotateIfNeeded(DateTime now)
|
||||
{
|
||||
string month = now.ToString("yyyyMM", CultureInfo.InvariantCulture);
|
||||
if (_decisionsMonth.Length == 0)
|
||||
{
|
||||
_decisionsMonth = File.Exists(DecisionsPath) ? MonthOfFirstLine() ?? month : month;
|
||||
}
|
||||
|
||||
if (_decisionsMonth == month)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_decisions?.Dispose();
|
||||
_decisions = null;
|
||||
if (File.Exists(DecisionsPath))
|
||||
{
|
||||
string aside = Path.Combine(_directory, $"decisions_{_decisionsMonth}.jsonl");
|
||||
File.Move(DecisionsPath, aside, overwrite: false);
|
||||
}
|
||||
|
||||
_decisionsMonth = month;
|
||||
}
|
||||
|
||||
private string? MonthOfFirstLine()
|
||||
{
|
||||
try
|
||||
{
|
||||
using StreamReader r = new(DecisionsPath);
|
||||
string? first = r.ReadLine();
|
||||
if (first is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(first);
|
||||
string ts = doc.RootElement.GetProperty("ts").GetString() ?? string.Empty;
|
||||
return DateTime.TryParse(ts, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t)
|
||||
? t.ToString("yyyyMM", CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static StreamWriter Open(string path)
|
||||
{
|
||||
FileStream stream = new(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 1 << 14);
|
||||
return new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = false };
|
||||
}
|
||||
|
||||
/// <summary>The JSON line of one evaluation: every feature named in §8.1, plus the decision.</summary>
|
||||
public static string Serialize(string runId, string mode, string preset, string configHash, BasketContext ctx, BasketDecision d, string evento, string? basketId, string? extra)
|
||||
{
|
||||
BasketEvaluation e = d.Evaluation;
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("ts", ctx.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("run_id", runId);
|
||||
w.WriteString("config_hash", configHash);
|
||||
w.WriteString("basket", ctx.Name);
|
||||
w.WriteString("basket_id", basketId ?? string.Empty);
|
||||
w.WriteString("cross", ctx.Cross.Symbol);
|
||||
w.WriteString("mode", mode);
|
||||
w.WriteString("preset", preset);
|
||||
w.WriteString("evento", evento);
|
||||
w.WriteString("decision", d.Kind.ToString());
|
||||
w.WriteBoolean("buy_cross", d.BuyCross);
|
||||
Num(w, "z", e.Z);
|
||||
Num(w, "z_in_eff", e.ZInEffective);
|
||||
Num(w, "D_pips", e.DPips);
|
||||
Num(w, "rho_W", e.RhoW);
|
||||
Num(w, "rho_20", e.RhoShort);
|
||||
Num(w, "halfLife", e.HalfLife);
|
||||
Num(w, "atrA", e.AtrPipsA);
|
||||
Num(w, "atrB", e.AtrPipsB);
|
||||
Num(w, "sigmaX", e.SigmaX);
|
||||
Num(w, "ewmaVolX", e.EwmaVolX);
|
||||
Num(w, "sigmaForecast", ctx.SigmaForecast);
|
||||
Num(w, "sigmaAverage30d", ctx.SigmaAverage30d);
|
||||
Num(w, "costPips", e.CostPips);
|
||||
Num(w, "breakEvenWinRate", e.BreakEvenWinRate);
|
||||
Num(w, "spreadA", e.SpreadPipsA);
|
||||
Num(w, "spreadB", e.SpreadPipsB);
|
||||
Num(w, "markupA", ctx.MarkupPipsA);
|
||||
Num(w, "markupB", ctx.MarkupPipsB);
|
||||
Num(w, "hourSin", e.HourSin);
|
||||
Num(w, "hourCos", e.HourCos);
|
||||
w.WriteNumber("dow", e.DayOfWeek);
|
||||
IntOrNull(w, "minutesToNextHigh", ctx.MinutesToNextHigh);
|
||||
IntOrNull(w, "minutesSinceLastHigh", ctx.MinutesSinceLastHigh);
|
||||
Num(w, "surpriseLast", ctx.SurpriseLast);
|
||||
Num(w, "netSentDiff_1h", ctx.NetSentimentDiff1h);
|
||||
Num(w, "netSentDiff_4h", ctx.NetSentimentDiff4h);
|
||||
Num(w, "netSentDiff_24h", ctx.NetSentimentDiff24h);
|
||||
Num(w, "hawkishDiff", ctx.HawkishDiff);
|
||||
Num(w, "riskOff", ctx.RiskOff);
|
||||
w.WriteNumber("newsCount", ctx.NewsCount);
|
||||
Num(w, "regimeTrend", e.TrendStrength);
|
||||
Num(w, "lastNOutcomes", ctx.LastOutcomes);
|
||||
Num(w, "p_ML", ctx.PMl);
|
||||
w.WriteBoolean("mlActive", ctx.MlActive);
|
||||
Num(w, "equity", ctx.Equity);
|
||||
w.WriteNumber("openBaskets", ctx.OpenBaskets);
|
||||
Num(w, "priceA", e.PriceA);
|
||||
Num(w, "priceB", e.PriceB);
|
||||
Num(w, "pipsOpen", e.PipsOpen);
|
||||
Num(w, "pnlOpenUsd", e.PnlOpenUsd);
|
||||
w.WriteNumber("barsHeld", e.BarsHeld);
|
||||
if (d.Sizing is { Ok: true } s)
|
||||
{
|
||||
Num(w, "unitsA", s.UnitsA);
|
||||
Num(w, "unitsB", s.UnitsB);
|
||||
Num(w, "notionalUsd", s.NotionalUsdA + s.NotionalUsdB);
|
||||
Num(w, "lossAtStopUsd", s.LossAtStopUsd);
|
||||
Num(w, "effectiveLeverage", s.EffectiveLeverage);
|
||||
}
|
||||
|
||||
w.WriteStartArray("reasonCodes");
|
||||
foreach (string c in d.ReasonCodes)
|
||||
{
|
||||
w.WriteStringValue(c);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WriteString("motivazione", extra is null ? d.Motivazione : $"{d.Motivazione} — {extra}");
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
static void Num(Utf8JsonWriter w, string name, double v)
|
||||
{
|
||||
if (double.IsFinite(v))
|
||||
{
|
||||
w.WriteNumber(name, Math.Round(v, 8));
|
||||
}
|
||||
else
|
||||
{
|
||||
w.WriteNull(name);
|
||||
}
|
||||
}
|
||||
|
||||
static void IntOrNull(Utf8JsonWriter w, string name, int v)
|
||||
{
|
||||
if (v == int.MaxValue)
|
||||
{
|
||||
w.WriteNull(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
w.WriteNumber(name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_decisions?.Dispose();
|
||||
_baskets?.Dispose();
|
||||
_decisions = null;
|
||||
_baskets = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 < local overlay < environment variables. Credentials live in
|
||||
/// the encrypted store or in the environment, never in the committed config.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ConfigLoader
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 = "[0m";
|
||||
|
||||
/// <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 => "[90m",
|
||||
LogLevel.Debug => "[36m",
|
||||
LogLevel.Info => AnsiReset,
|
||||
LogLevel.Warn => "[33m",
|
||||
LogLevel.Error => "[31m",
|
||||
_ => AnsiReset,
|
||||
};
|
||||
|
||||
internal readonly record struct Entry(
|
||||
DateTime Timestamp,
|
||||
LogLevel Level,
|
||||
string Message,
|
||||
Exception? Exception,
|
||||
string? EventCode,
|
||||
string Source);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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&L non realizzato. È il numero su cui si calcolano rischio per basket, equity stop e perdita giornaliera.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Equity" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding Equity, StringFormat=N2}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding Balance, StringFormat='saldo {0:N2}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="P&L chiuso di oggi (giornata UTC), dal ledger dei basket, e in percentuale dell'equity di inizio giornata. Al 3 % di perdita il bot non apre più fino a domani.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L oggi" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding TodayPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding TodayPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding TodayPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="P&L aperto complessivo dei basket, netto dei costi già maturati, e in percentuale dell'equity.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="P&L aperto" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}"
|
||||
Text="{Binding OpenPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
|
||||
Foreground="{Binding OpenPnl, Converter={StaticResource PnlBrush}}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding OpenPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="Distanza dell'equity dal suo massimo storico. All'equity stop il bot chiude tutto e si blocca finché non lo sblocchi con una motivazione.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Drawdown" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding DrawdownPct, StringFormat='{}{0:0.00%}'}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding DrawdownSub}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource Kpi}" ToolTip="Basket aperti sul massimo consentito dal preset in vigore.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Basket aperti" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Value}" Text="{Binding BasketsDisplay}"/>
|
||||
<TextBlock Style="{StaticResource Sub}" Text="{Binding Preset, StringFormat='preset {0}'}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UniformGrid>
|
||||
|
||||
<!-- ==================== basket ==================== -->
|
||||
<Border Style="{StaticResource Card}" Padding="0" Margin="0,0,0,14">
|
||||
<DataGrid ItemsSource="{Binding Baskets}" MinHeight="120" ColumnHeaderHeight="36" HorizontalScrollBarVisibility="Disabled">
|
||||
<DataGrid.RowStyle>
|
||||
<Style TargetType="DataGridRow" BasedOn="{StaticResource {x:Type DataGridRow}}">
|
||||
<Setter Property="ToolTip" Value="{Binding Tooltip}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Enabled}" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</DataGrid.RowStyle>
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Header="Basket" Width="190">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Cross, StringFormat='cross {0}'}" FontSize="10.5" Foreground="{StaticResource Faint}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTemplateColumn Header="Stato" Width="110">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource Chip}" HorizontalAlignment="Left" Padding="9,2">
|
||||
<TextBlock Text="{Binding StateLabel}" FontSize="11">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsOpen}" Value="True">
|
||||
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding State}" Value="Error">
|
||||
<Setter Property="Foreground" Value="{StaticResource Down}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
<DataGridTextColumn Header="z" Binding="{Binding ZDisplay}" Width="72" ElementStyle="{StaticResource Cell}"/>
|
||||
<DataGridTextColumn Header="ρ" Binding="{Binding RhoDisplay}" Width="66" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="HL" Binding="{Binding HalfLifeDisplay}" Width="54" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="Pips" Binding="{Binding PipsDisplay}" Width="70" ElementStyle="{StaticResource Cell}"/>
|
||||
<DataGridTextColumn Header="TP" Binding="{Binding TpDisplay}" Width="50" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="P&L $" Binding="{Binding PnlDisplay}" Width="104">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource Cell}">
|
||||
<Setter Property="Foreground" Value="{Binding PnlUsd, Converter={StaticResource PnlBrush}}"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
<DataGridTextColumn Header="Costo" Binding="{Binding CostDisplay}" Width="66" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="p ML" Binding="{Binding PMlDisplay}" Width="112" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTextColumn Header="Prossimo evento" Binding="{Binding NextEvent}" Width="230" ElementStyle="{StaticResource CellDim}"/>
|
||||
<DataGridTemplateColumn Header="" Width="90">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Button Content="CHIUDI" Click="OnCloseBasket" Tag="{Binding Name}" Style="{StaticResource Danger}"
|
||||
Padding="9,3" FontSize="11" FontWeight="SemiBold"
|
||||
Visibility="{Binding IsOpen, Converter={StaticResource BoolVis}}"
|
||||
ToolTip="Chiude entrambe le gambe a mercato, dopo conferma."/>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
|
||||
<!-- ==================== contesto ==================== -->
|
||||
<Grid Margin="0,0,0,14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Style="{StaticResource Card}" Margin="0,0,12,0" Padding="14,12"
|
||||
ToolTip="Dal calendario settimanale FairEconomy. Blackout: nessuna entrata nei 45 minuti prima e nei 30 dopo un evento ad alto impatto sulle valute del basket.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Prossimo evento" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding NextEvent}" Style="{StaticResource Small}" Margin="0,6,0,0" Foreground="{StaticResource Txt}"/>
|
||||
<TextBlock Text="{Binding CalendarState}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Style="{StaticResource Card}" Margin="0,0,12,0" Padding="14,12"
|
||||
ToolTip="Stato del collegamento a eToro Public API, latenza dell'ultima richiesta e uso delle quote (120 richieste al minuto per le quotazioni, 20 per gli ordini).">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Collegamento eToro" Style="{StaticResource Label}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<TextBlock Text="{Binding ApiState}" Style="{StaticResource Small}" Foreground="{StaticResource Txt}"/>
|
||||
<TextBlock Text="{Binding ApiLatency}" Style="{StaticResource Small}" Margin="8,0,0,0"/>
|
||||
<TextBlock Text="{Binding Uptime, StringFormat='· attivo da {0}'}" Style="{StaticResource Small}" Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Counters}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Style="{StaticResource Card}" Padding="14,12"
|
||||
ToolTip="Meta-modello (regressione logistica online, in ombra finché non supera i cancelli di attivazione), volatilità prevista (EWMA contro HAR-RV) e proposta del bandit sul preset.">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Apprendimento" Style="{StaticResource Label}"/>
|
||||
<TextBlock Text="{Binding MlState}" Style="{StaticResource Small}" Margin="0,6,0,0" Foreground="{StaticResource Txt}" TextTrimming="CharacterEllipsis" MaxHeight="34"/>
|
||||
<TextBlock Text="{Binding VolForecast}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}" TextTrimming="CharacterEllipsis" MaxHeight="34"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ==================== attività ==================== -->
|
||||
<Border Style="{StaticResource Card}" Padding="14,12">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<TextBlock Text="Attività" Style="{StaticResource Label}"/>
|
||||
<TextBlock Style="{StaticResource Hint}" FontSize="12"
|
||||
ToolTip="Le ultime righe del log. La scheda Log tiene tutta la cronologia, filtra per livello e apre il file su disco."/>
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Events}" MaxHeight="220">
|
||||
<ItemsControl.Template>
|
||||
<ControlTemplate TargetType="ItemsControl">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsPresenter/>
|
||||
</ScrollViewer>
|
||||
</ControlTemplate>
|
||||
</ItemsControl.Template>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,1">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="70"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding Time}" FontFamily="{StaticResource Mono}" FontSize="11" Foreground="{StaticResource Faint}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Message}" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Encelado.Bot.Ui.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard. It reads the view model and forwards every click to
|
||||
/// <see cref="IUiActions"/>: no decision, no engine access, no file system here.
|
||||
/// </summary>
|
||||
public partial class DashboardPage : UserControl
|
||||
{
|
||||
private bool _presetChangeFromUser = true;
|
||||
|
||||
public DashboardPage() => InitializeComponent();
|
||||
|
||||
public IUiActions? Actions { get; set; }
|
||||
|
||||
private async void OnCloseBasket(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: string basket } button || basket.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Disabled for the round trip so an impatient second click cannot submit a
|
||||
// second closing order against a basket that is already on its way out.
|
||||
button.IsEnabled = false;
|
||||
try
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.CloseBasketAsync(basket);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
button.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnKillSwitch(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.KillSwitchAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnResetEquityStop(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.ResetEquityStopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPresetChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
// The combo is refreshed from the snapshot every second; only a selection made by
|
||||
// a person becomes a command. The view model's Preset is one-way on purpose.
|
||||
if (!_presetChangeFromUser || !IsLoaded || sender is not ComboBox box || box.SelectedItem is not string chosen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is MainViewModel vm && string.Equals(vm.Preset, chosen, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_presetChangeFromUser = false;
|
||||
try
|
||||
{
|
||||
if (Actions is not null)
|
||||
{
|
||||
await Actions.SetPresetAsync(chosen);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_presetChangeFromUser = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="" 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'intero file con la configurazione di fabbrica, commenti compresi, dopo averne salvato una copia con la data accanto all'originale. Le chiavi eToro e strategy.json non vengono toccati."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Informazioni" Style="{StaticResource Head}"/>
|
||||
<TextBlock 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;
|
||||
}
|
||||
}
|
||||
@@ -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=""/>
|
||||
<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>
|
||||
@@ -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&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_{t−1}</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(t−1) 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&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 ask−bid 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_<SYMBOL>_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&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 (Marsaglia–Tsang).</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
Reference in New Issue
Block a user