5.0 Fasi 6-7: motore in Encelado.Engine, server Kestrel e interfaccia web al posto di WPF

Il bot deve girare solo nel container (D-28, ADR-0007): la finestra WPF e le chiavi
DPAPI se ne vanno. Il motore diventa la libreria Encelado.Engine; l'eseguibile
Encelado.Server (nessun NuGet) serve un'API JSON scritta a mano, lo stream SSE con uno
snapshot al secondo e l'interfaccia Material 3 incorporata: dashboard con margine e
contatori, storico ordini (ordini, posizioni classificate, profitti per periodo, CSV),
log, impostazioni con chiavi cifrate (AES-GCM + passphrase), ripristino in cinque passi,
ricerca, diagnostica, valuta di visualizzazione, token locale in cookie.

Lo strumento acquista il comando learn; VS Code avvia il server con F5 e la modalità
campione; ~45 membri mai usati e i test WPF sono rimossi. Test dell'HTML incorporato,
del token e dello stream, dello storico: 210 verdi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:20:59 +02:00
co-authored by Claude Fable 5.1
parent 21fa04690a
commit a69efe7df3
116 changed files with 4860 additions and 5527 deletions
+4 -4
View File
@@ -4,9 +4,9 @@
"ms-dotnettools.csharp", "ms-dotnettools.csharp",
"ms-dotnettools.csdevkit", "ms-dotnettools.csdevkit",
// Colora installer\Encelado.iss e ne conosce direttive e costanti. Serve solo a // Colora la Dockerfile e la docker-compose.yml e mostra container e immagini
// leggere e scrivere quel file: l'installer si costruisce con il task // locali. L'immagine si costruisce con il task "immagine docker", che non
// "installer", che non dipende da nessuna estensione. // dipende da nessuna estensione.
"idleberg.innosetup" "ms-azuretools.vscode-docker"
] ]
} }
+45 -7
View File
@@ -1,17 +1,55 @@
{ {
// One way to launch, on purpose. Encelado is a desktop application: F5 here starts // F5 avvia il server (motore + interfaccia web) come processo locale, fuori dal
// the same window you get by double-clicking Encelado.exe. Everything else — login, // container, con la cartella dati in Documenti\Encelado. Il browser si apre da solo
// start/stop, backtest, settings — lives inside that window. // appena Kestrel è in ascolto. "Encelado (campione)" serve l'interfaccia con dati
// finti, senza chiavi né mercato: è quello che si usa per lavorare sulle pagine.
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Encelado", "name": "Encelado (server)",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build", "preLaunchTask": "build",
"program": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows/Encelado.exe", "program": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll",
"cwd": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows", "args": ["--no-autostart"],
"console": "internalConsole", "cwd": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0",
"console": "integratedTerminal",
"stopAtEntry": false,
"env": {
"ENCELADO_WEB_PORT": "8080",
"DOTNET_ENVIRONMENT": "Development"
},
"serverReadyAction": {
"action": "openExternally",
"pattern": "in ascolto su (http://\\S+)",
"uriFormat": "%s"
}
},
{
"name": "Encelado (campione)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll",
"args": ["--sample", "--port", "8081"],
"cwd": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0",
"console": "integratedTerminal",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "in ascolto su (http://\\S+)",
"uriFormat": "%s"
}
},
{
"name": "Backtest (baskets)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/tools/Encelado.Backtest/bin/Debug/net10.0/backtest.dll",
"args": ["baskets", "--data", "${env:USERPROFILE}/Documents/Encelado/data/market", "--out", "results", "--quick"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"stopAtEntry": false "stopAtEntry": false
} }
] ]
+22 -13
View File
@@ -3,7 +3,8 @@
// MSBuild versionato col codice, e qui restano soltanto i nomi e le domande. // 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". // MSBuild non può chiedere niente a nessuno — i prompt stanno in "inputs".
// //
// È la stessa impostazione di Mimante/AutoBidder. // È la stessa impostazione di Mimante/AutoBidder, con l'immagine Docker al
// posto dell'installatore.
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
@@ -39,7 +40,7 @@
}, },
{ {
"label": "backtest", "label": "backtest",
"detail": "Ricerca sui basket: ticks (tick MT5 → barre), baskets (griglia, PSR/DSR, PBO, walk-forward), falsify (test di falsificazione).", "detail": "Ricerca sui basket: ticks (tick MT5 → barre), baskets (griglia, PSR/DSR, PBO, walk-forward), falsify (falsificazione), learn (ciclo di apprendimento in ombra su un ledger).",
"type": "process", "type": "process",
"command": "dotnet", "command": "dotnet",
"args": [ "args": [
@@ -55,14 +56,14 @@
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "crea installatore", "label": "immagine docker",
"detail": "Verifica, pubblica ed esegue Inno Setup: bin/installer/Encelado-<versione>-setup.exe. Crea il tag a pacchetto pronto. Non tocca Gitea.", "detail": "Verifica e costruisce l'immagine 192.168.30.23:3000/alby96/encelado:<versione> (e :latest) con la Dockerfile alla radice. Non pubblica niente.",
"type": "process", "type": "process",
"command": "dotnet", "command": "dotnet",
"args": [ "args": [
"msbuild", "msbuild",
"${workspaceFolder}/build/Release.proj", "${workspaceFolder}/build/Release.proj",
"-t:Pacchetto", "-t:Docker",
"-p:Versione=${input:versione}", "-p:Versione=${input:versione}",
"-nologo", "-nologo",
"-v:m" "-v:m"
@@ -71,8 +72,8 @@
"problemMatcher": "$msCompile" "problemMatcher": "$msCompile"
}, },
{ {
"label": "crea installatore (senza rieseguire i test)", "label": "pacchetto",
"detail": "Solo pubblicazione e Inno Setup. Da usare quando i test sono appena passati.", "detail": "Verifica, pubblica la cartella portabile (bin/installer/Encelado_<versione>_portabile.zip) e costruisce l'immagine. Crea il tag a pacchetto pronto. Non tocca Gitea.",
"type": "process", "type": "process",
"command": "dotnet", "command": "dotnet",
"args": [ "args": [
@@ -80,7 +81,6 @@
"${workspaceFolder}/build/Release.proj", "${workspaceFolder}/build/Release.proj",
"-t:Pacchetto", "-t:Pacchetto",
"-p:Versione=${input:versione}", "-p:Versione=${input:versione}",
"-p:SaltaVerifica=true",
"-nologo", "-nologo",
"-v:m" "-v:m"
], ],
@@ -89,7 +89,7 @@
}, },
{ {
"label": "rilascia su Gitea", "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.", "detail": "Verifica, pacchetto, immagine, tag, push dell'immagine sul registro di Gitea e release con lo zip e il template Unraid allegati. Richiede build/gitea.json.",
"type": "process", "type": "process",
"command": "dotnet", "command": "dotnet",
"args": [ "args": [
@@ -110,13 +110,22 @@
}, },
"presentation": { "reveal": "always", "panel": "dedicated" }, "presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": "$msCompile" "problemMatcher": "$msCompile"
},
{
"label": "docker compose up",
"detail": "Avvia il container dalla docker-compose.yml alla radice (immagine locale, cartelle ./deploy/local/config e ./deploy/local/data).",
"type": "shell",
"command": "docker compose up --build",
"options": { "cwd": "${workspaceFolder}" },
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": []
} }
], ],
"inputs": [ "inputs": [
{ {
"id": "versione", "id": "versione",
"type": "promptString", "type": "promptString",
"description": "Versione — lascia vuoto se hai già taggato (git tag v3.3.0), o per la minor successiva", "description": "Versione — lascia vuoto se hai già taggato (git tag v5.0.0), o per la minor successiva",
"default": "" "default": ""
}, },
{ {
@@ -128,14 +137,14 @@
{ {
"id": "dati", "id": "dati",
"type": "promptString", "type": "promptString",
"description": "Cartella dei dati: data/market (barre M15) per baskets e falsify, la cartella dei tick MT5 per ticks", "description": "Cartella dei dati: data/market (barre M15) per baskets e falsify, la cartella dei tick MT5 per ticks, la cartella data del bot per learn",
"default": "C:\\Users\\alber\\Documents\\Encelado\\data\\market" "default": "C:\\Users\\alber\\Documents\\Encelado\\data\\market"
}, },
{ {
"id": "comando", "id": "comando",
"type": "pickString", "type": "pickString",
"description": "Cosa misurare", "description": "Comando del backtest",
"options": ["baskets", "falsify", "ticks"], "options": ["baskets", "falsify", "ticks", "learn"],
"default": "baskets" "default": "baskets"
} }
] ]
+6 -6
View File
@@ -13,12 +13,12 @@
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<Product>Encelado</Product> <Product>Encelado</Product>
<Company>Encelado</Company> <Company>Encelado</Company>
<!-- Numero delle compilazioni di sviluppo: è quello che compare nella finestra <!-- Numero delle compilazioni di sviluppo: è quello che compare in
mentre si lavora. La versione RILASCIATA viene dal tag git — vedi Impostazioni ▸ Informazioni mentre si lavora. La versione RILASCIATA viene
build/Release.proj — e questo serve solo da seme quando non esiste ancora dal tag git — vedi build/Release.proj — e questo serve solo da seme quando
nessun tag. Tenerlo allineato all'ultimo rilascio evita di leggere in non esiste ancora nessun tag. Tenerlo allineato all'ultimo rilascio evita
finestra un numero che non corrisponde a niente. --> di leggere a schermo un numero che non corrisponde a niente. -->
<Version>4.0.0</Version> <Version>5.0.0</Version>
</PropertyGroup> </PropertyGroup>
<!-- <!--
+2 -1
View File
@@ -1,6 +1,7 @@
<Solution> <Solution>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/Encelado.Bot/Encelado.Bot.csproj" /> <Project Path="src/Encelado.Engine/Encelado.Engine.csproj" />
<Project Path="src/Encelado.Server/Encelado.Server.csproj" />
<Project Path="src/Encelado.Core/Encelado.Core.csproj" /> <Project Path="src/Encelado.Core/Encelado.Core.csproj" />
<Project Path="src/Encelado.Etoro/Encelado.Etoro.csproj" /> <Project Path="src/Encelado.Etoro/Encelado.Etoro.csproj" />
</Folder> </Folder>

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

-153
View File
@@ -1,153 +0,0 @@
; ─────────────────────────────────────────────────────────────────────────────
; 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;
+8 -2
View File
@@ -31,8 +31,14 @@
}, },
"ui": { "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": "Fuso orario con cui l'interfaccia mostra gli orari. 'computer' = quello del sistema (nel container la variabile TZ); 'UTC'; oppure un id IANA (es. 'Europe/Rome'). Il file di log porta l'offset, il ledger è in UTC: cambiare questo valore non tocca nessun file.",
"timeZone": "computer" "timeZone": "computer",
"_displayCurrency": "Valuta in cui l'interfaccia, Telegram e le esportazioni mostrano gli importi (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD). I tassi vengono dalle quotazioni eToro già in polling; ledger e decisioni restano in USD. Anche ENCELADO_DISPLAY_CURRENCY.",
"displayCurrency": "USD",
"_theme": "dark oppure light.",
"theme": "dark",
"_navExpanded": "Se la barra di navigazione a sinistra parte aperta (etichette estese) o chiusa (solo icone).",
"navExpanded": true
}, },
"logging": { "logging": {
+86
View File
@@ -0,0 +1,86 @@
# Linee guida dell'interfaccia web
Aggiornato: 2026-09-23 (5.0, ADR-0007). L'interfaccia è servita dal bot stesso (`src/Encelado.Server/Web/wwwroot/`: `index.html`, `login.html`, `app.css`, `app.js`, `icon.svg`, `manifest.webmanifest`, incorporati nell'assembly). Nessuna libreria, nessun font remoto, nessun `<script src=http…>`: un test lo verifica (`EmbeddedUiTests`).
## Principi
1. **La UI non decide niente.** Legge lo snapshot (`/api/snapshot`, `/api/stream`) e manda comandi (`POST /api/commands/<nome>`). Ogni regola di rischio sta nel motore.
2. **Le stesse informazioni della dashboard di sempre**: equity, P&L di oggi, P&L aperto (conto e basket), drawdown, basket aperti con in attesa / orfane / esterne, margine; la tabella dei basket; prossimo evento, collegamento eToro, Telegram; l'attività. I dettagli stanno nei tooltip (`title`) e nel log, non in più riquadri.
3. **Ogni numero ha un tooltip** con la formula o la fonte; ogni importo convertito porta nel tooltip il valore in USD e il tasso usato. Il ledger resta in USD.
4. **Gli orari a schermo sono nel fuso scelto** (`ui.timeZone`, `TZ`); la barra in alto mostra sempre anche l'UTC, che è l'ora del ledger.
5. **Le azioni irreversibili chiedono conferma** in un dialogo: chiusura di un basket, kill-switch (con la spunta «chiudi anche le esterne», deselezionata), reset (motivazione ≥ 10 caratteri), rimozione delle chiavi, ripristino della configurazione, avvio Live (frase `CONFERMO LIVE` scritta per intero).
## Material 3 senza libreria
### Colori (token CSS in `:root`)
| Ruolo | Scuro (default) | Chiaro | Uso |
|---|---|---|---|
| `--primary` / `--on-primary` | `#adc6ff` / `#002e69` | `#005ac1` / `#ffffff` | pulsante AVVIA, tab attiva, focus, curva dell'equity |
| `--primary-container` / `--on-primary-container` | `#1f4e9c` / `#d8e2ff` | `#d8e2ff` / `#001a41` | voce attiva del rail |
| `--surface`, `--surface-container-low/-/high/highest` | `#101418`, `#181c20`, `#1c2024`, `#262a2f`, `#31353a` | `#f8f9ff`, `#f2f3f9`, `#eceef4`, `#e6e8ee`, `#e0e2e8` | sfondo, rail, card, KPI, intestazioni delle tabelle |
| `--on-surface` / `--on-surface-variant` | `#e0e2e8` / `#c3c6d0` | `#191c20` / `#43474e` | testo, testo secondario |
| `--outline` / `--outline-variant` | `#8d9099` / `#43474e` | `#74777f` / `#c3c6d0` | bordi di input e tabelle |
| `--error` / `--error-container` | `#ffb4ab` / `#93000a` | `#ba1a1a` / `#ffdad6` | kill-switch, banner rosso, stato `Halted` |
| `--up` / `--down` / `--warn` | `#7fd39a` / `#ff8a80` / `#f5b74f` | `#1b7f3b` / `#c62828` / `#9a6400` | **solo** P&L, pip, stati (verde/rosso), avvisi (giallo) |
Una sola tinta d'accento; contrasto ≥ 4,5:1 per il testo. Il tema si sceglie in Impostazioni ▸ Interfaccia (`ui.theme`: `dark` | `light`) e viene ricordato nel browser (`localStorage`, solo comodità per chi guarda); `prefers-color-scheme` è rispettato quando il file dice `dark` ma il sistema è chiaro solo per `color-scheme`.
### Tipografia
`Roboto, "Segoe UI", system-ui, sans-serif`; monospazio `Cascadia Mono, JetBrains Mono, Consolas` per log, id e attività. Scala: titolo di pagina 20 px (title-large), titoli delle card 16 px (title-medium), KPI 24 px (headline-small), corpo 14 px, etichette 12 px. **Cifre tabulari** (`font-variant-numeric: tabular-nums`) su ogni numero.
### Forma, elevazione, stati
Angoli 12 px (card, banner), 16 px (dialoghi grandi 28 px come da M3), pulsanti a pillola (999 px). Elevazione tonale (superfici a livelli), niente ombre salvo dialoghi e snackbar. State layer su hover (8 %) e pressed (12 %) tramite `::after`. `:focus-visible` con anello di 2 px in `--primary`.
### Componenti
| Componente | Dove | Note |
|---|---|---|
| Navigation rail | sinistra, 80 px chiuso / 256 px aperto | quattro voci: Dashboard, Storico ordini, Log, Impostazioni; stato ricordato (`ui.navExpanded` e `localStorage`); sotto 600 px diventa un drawer con scrim; in fondo i chip ambiente e stato |
| Top app bar | sopra la pagina | titolo della pagina, ora UTC e ora nel fuso, selettore della valuta, AVVIA/FERMA, KILL-SWITCH (attivo solo a motore acceso); linear progress durante un comando |
| Cards | dashboard, contesto, impostazioni | KPI con etichetta, valore, riga secondaria |
| Data table compatta | basket, storico, log | intestazione fissa, righe da 32 px, allineamento a destra dei numeri; sotto 600 px la tabella dei basket diventa cards |
| Chips | ambiente (`PAPER` blu, `DEMO` giallo, `LIVE` rosso), stato del motore, stato del basket, origine della posizione | testo in minuscolo per gli stati |
| Buttons | filled (AVVIA, Salva, conferme), tonal (Applica, Avvia il ripristino), outlined danger (KILL-SWITCH), text (Chiudi, Esporta) | mai due filled affiancati |
| Select, input, switch | filtri, impostazioni, Segui nel log | validazione lato server con messaggio accanto al campo |
| Dialog | conferma, prompt (motivazione, `CONFERMO LIVE`), ripristino in cinque passi | `<dialog>` nativo, `showModal`, Esc chiude |
| Snackbar | esito dei comandi | 4 s, 8 s se errore |
| Banner | dashboard | rosso per errore, kill-switch con residuo, equity stop, sospensione; giallo per entrate bloccate, non riconciliate, orfane |
| Tooltip | `title` su ogni numero e riga | la formula, la fonte, il valore in USD |
### Classi di finestra
| Classe | Larghezza | Layout |
|---|---|---|
| compact | < 600 px | rail nascosto (menu ☰), KPI su due colonne, basket a cards, orologi nascosti |
| medium | 600-839 px | rail 80 px, KPI su tre colonne, contesto su una colonna, impostazioni su una colonna |
| expanded | ≥ 840 px | rail 80/256 px, KPI su sei colonne, contesto su tre, impostazioni su due |
### Accessibilità
Tastiera completa (Tab su rail, pulsanti, tabelle, dialoghi; Esc chiude drawer e dialoghi), `aria-label` su icone e selettori, `aria-current="page"` sulla voce attiva, `role="tablist"/"tab"/"tabpanel"` nello storico, `role="status"` su banner e snackbar, link «Vai al contenuto», `prefers-reduced-motion` (nessuna transizione), `prefers-color-scheme`.
## Pagine
- **Dashboard**: banner, sei KPI, tabella dei basket (nome e cross, stato, z, ρ, HL, pip, TP, P&L, costo, prossimo evento, Chiudi), preset, tre card di contesto, attività (ultime righe di log).
- **Storico ordini**: tre tab. *Ordini* (da `orders.jsonl`: unità richieste ed eseguite, prezzi, slippage, stato, esito, id), *Posizioni* (storico eToro classificato `basket` / `orfana-bot` / `esterna` / `movimento di cassa`, con P&L lordo, fee, netto, pip, durata, motivo), *Profitti per periodo* (oggi, ieri, 7 e 30 giorni, mese corrente e precedente, anno, tutto, intervallo personalizzato; curva dell'equity realizzata in SVG). Filtri e **Esporta CSV** (`;`, colonna `motivazione`).
- **Log**: livello, ricerca, «Segui», 2000 righe per pagina lette a incrementi (`/api/log?after=`).
- **Impostazioni**: configurazione a gruppi (eToro, esecuzione, notifiche, interfaccia/valuta, log) con validazione; chiavi eToro (verifica e salvataggio cifrato); ripristino in cinque passi e ripristino dei valori predefiniti; **Ricerca** (stato del modello in ombra, bandit, volatilità, criterio di riattivazione, «Esegui ciclo di apprendimento»); **Diagnostica** (strategia e run, percorsi, .NET, sistema, uptime, quote API, fuso, bonifica); **Informazioni** (nome, autore, versione, data di build, commit, licenza, documentazione).
- **Login**: solo con `ENCELADO_WEB_TOKEN`; cookie HttpOnly per 30 giorni.
## Flusso dei dati
`/api/stream` (SSE, `event: snapshot`, uno al secondo) → `apply(snapshot)``render()`; se il browser non ha `EventSource`, polling di `/api/snapshot` ogni 2 s; `?once=1` scatta una sola lettura (per gli screenshot). I comandi rispondono `{ok, message, …}` e finiscono nella snackbar; l'esito vero arriva con lo snapshot successivo. Il log legge `/api/log` ogni 1,5 s solo mentre la pagina Log è aperta.
## Screenshot
`docs/img/` (dashboard, storico, log, impostazioni, mobile), presi dal server in modalità campione:
```powershell
dotnet run --project src/Encelado.Server -- --sample --port 8085
& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --headless=new --disable-gpu --hide-scrollbars --window-size=1440,900 --virtual-time-budget=5000 --user-data-dir=$env:TEMP\edge-shot --screenshot=docs\img\dashboard.png "http://127.0.0.1:8085/?once=1#/dashboard"
```
Prima di dichiarare finita una modifica: `EmbeddedUiTests` e `WebHostTests` verdi, screenshot rifatti se cambia una pagina, revisione visiva con l'utente.
@@ -0,0 +1,29 @@
# ADR-0007 — Interfaccia web servita dal bot al posto della finestra WPF
Data: 2026-09-23. Stato: accettata (risposta dell'utente a D-28: «il bot deve essere eseguito SOLO tramite container. Elimina tutta la parte della grafica Windows»).
## Contesto
Fino alla 4.0.0 Encelado era un'applicazione WPF (`Encelado.exe`, `net10.0-windows`) con la modalità `--headless` per i test lunghi. Il post-mortem del 16-21/9 (`docs/POSTMORTEM_ordini_pendenti.md`) ha mostrato che il bot deve girare senza interruzioni su una macchina sempre accesa — il server Unraid dell'utente — e che la finestra era diventata il modo meno affidabile di tenerlo d'occhio: apribile solo sul PC di sviluppo, chiusa con la sessione di Windows, con un secondo motore possibile per sbaglio. Una UI Windows non gira in un container Linux.
## Decisione
1. Il progetto WPF viene **rimosso** (`src/Encelado.Bot/App.xaml`, `MainWindow`, `Ui/*`, tema, finestre di login e prompt, test di binding e di rendering). Il motore passa in `src/Encelado.Engine` (libreria `net10.0`), l'eseguibile è `src/Encelado.Server` (Kestrel).
2. L'interfaccia è **web**, servita dal bot stesso: HTML, CSS e JavaScript incorporati nell'assembly (`EmbeddedResource`), nessun framework, nessun font remoto, nessuna dipendenza NuGet (`Microsoft.AspNetCore.App` è un framework reference dell'SDK). Stile Material 3 (`docs/UI_GUIDELINES.md`).
3. Aggiornamenti via Server-Sent Events (`/api/stream`, uno snapshot al secondo); comandi via `POST /api/commands/<nome>`; storico, log, impostazioni, chiavi e diagnostica via API JSON scritta a mano (`Utf8JsonWriter`). Token locale `ENCELADO_WEB_TOKEN` in cookie HttpOnly; senza token il server ascolta solo su localhost.
4. La verifica visiva dei test WPF (`UiRenderTests`) è sostituita dal test dell'HTML incorporato, dal test dello snapshot JSON, dal test dello stream SSE e dagli screenshot (`docs/img/`) presi dal server in modalità `--sample`.
5. Le chiavi eToro non sono più in DPAPI (Windows): variabili d'ambiente oppure file cifrato `etoro.keys.enc` (AES-256-GCM, passphrase in `ENCELADO_KEY_PASSPHRASE`).
## Alternative scartate
- **Tenere WPF e aggiungere la web UI**: due interfacce da tenere allineate su ogni campo dello snapshot, due catene di test, e la finestra non avrebbe comunque girato dove il bot deve girare. L'utente ha chiesto di eliminarla.
- **Framework JavaScript (React, Vue, Blazor)**: una toolchain npm o una dipendenza NuGet contro la regola «nessun pacchetto»; per quattro pagine e un solo flusso di dati (lo snapshot) il vanilla è sufficiente e più leggibile.
- **Blazor Server**: dipendenza da SignalR e da uno stato per circuito; l'SSE fa lo stesso lavoro con una riga per snapshot.
- **Terminale (TUI)**: non raggiungibile da un telefono in rete locale.
## Conseguenze
- Niente più `net10.0-windows`: tutta la soluzione è portabile e la suite di test gira nello stadio di build dell'immagine Docker (ADR-0008).
- Le regole «una barra in alto, pagine sotto, dettagli nei tooltip» restano; cambia il mezzo. La navigazione diventa un navigation rail a sinistra (80/256 px, stato ricordato).
- La bonifica interattiva, il reset in cinque passi, il kill-switch con la spunta «chiudi anche le esterne» e la frase `CONFERMO LIVE` passano da dialoghi HTML.
- Chi vuole la finestra non ce l'ha più: si apre il browser su `http://<ip>:8080/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

-12
View File
@@ -1,12 +0,0 @@
<Application x:Class="Encelado.Bot.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnMainWindowClose">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Ui/Theme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
-174
View File
@@ -1,174 +0,0 @@
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);
}
}
@@ -1,253 +0,0 @@
using System.Buffers;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
namespace Encelado.Bot.Configuration;
/// <summary>The two long-lived keys eToro issues, for one environment.</summary>
public sealed record EtoroKeys(string ApiKey, string UserKey, DateTime SavedUtc)
{
public bool IsComplete => ApiKey.Length > 0 && UserKey.Length > 0;
}
/// <summary>
/// Persists the eToro keys outside the repository, per user and per environment (demo
/// and real are different keys), in <c>%LOCALAPPDATA%\Encelado\etoro.dat</c>.
/// <para>
/// On Windows the file is encrypted with DPAPI bound to the current user account, so it
/// needs no passphrase and survives an unattended restart; elsewhere it is plain JSON
/// with owner-only permissions and <see cref="IsEncrypted"/> says so.
/// </para>
/// </summary>
public static class EtoroKeyStore
{
public static bool IsEncrypted => OperatingSystem.IsWindows();
/// <summary>
/// Where the store lives. <c>ENCELADO_HOME</c> overrides it, which keeps portable
/// installs self-contained and lets the tests run without touching the real profile.
/// </summary>
public static string DirectoryPath =>
Environment.GetEnvironmentVariable("ENCELADO_HOME") is { Length: > 0 } custom
? custom
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Encelado");
public static string FilePath => Path.Combine(DirectoryPath, "etoro.dat");
public static bool Exists => File.Exists(FilePath);
public static EtoroKeys? Load(bool demo)
{
Dictionary<string, EtoroKeys> all = LoadAll();
return all.TryGetValue(Key(demo), out EtoroKeys? found) ? found : null;
}
public static void Save(bool demo, EtoroKeys keys)
{
ArgumentNullException.ThrowIfNull(keys);
Dictionary<string, EtoroKeys> all = LoadAll();
all[Key(demo)] = keys;
Write(all);
}
public static bool Clear(bool demo)
{
Dictionary<string, EtoroKeys> all = LoadAll();
if (!all.Remove(Key(demo)))
{
return false;
}
if (all.Count == 0)
{
try
{
File.Delete(FilePath);
}
catch (IOException)
{
// The caller reports the path; nothing more to do.
}
}
else
{
Write(all);
}
return true;
}
/// <summary>
/// Strips control characters, byte-order marks and stray spacing from a pasted key.
/// Keys copied out of a browser routinely carry a zero-width space, which would
/// surface much later as an opaque 401 deep inside the stack.
/// </summary>
public static string? Clean(string? raw)
{
if (string.IsNullOrEmpty(raw))
{
return null;
}
Span<char> buffer = raw.Length <= 256 ? stackalloc char[raw.Length] : new char[raw.Length];
int length = 0;
foreach (char c in raw)
{
if (!char.IsControl(c) && c != '' && c != '' && c != ' ')
{
buffer[length++] = c;
}
}
string cleaned = new string(buffer[..length]).Trim();
return cleaned.Length == 0 ? null : cleaned;
}
/// <summary>Masks a secret for display: the first six characters, then stars. Never the whole key.</summary>
public static string Mask(string? value)
{
if (string.IsNullOrEmpty(value))
{
return "(vuota)";
}
if (value.Length <= 6)
{
return new string('*', value.Length);
}
return value[..6] + new string('*', Math.Min(12, value.Length - 6));
}
/// <summary>Installs keys into the configuration: from the environment first, then from the store.</summary>
public static bool Resolve(BotConfig config, out string origin)
{
ArgumentNullException.ThrowIfNull(config);
if (config.Etoro.HasKeys)
{
origin = $"variabili d'ambiente ({Mask(config.Etoro.ApiKey)})";
return true;
}
if (Load(config.Etoro.IsDemo) is { IsComplete: true } saved)
{
config.Etoro.ApiKey = saved.ApiKey;
config.Etoro.UserKey = saved.UserKey;
origin = $"chiavi salvate ({Mask(saved.ApiKey)}, {saved.SavedUtc:yyyy-MM-dd})";
return true;
}
origin = "nessuna chiave eToro";
return false;
}
private static string Key(bool demo) => demo ? "demo" : "real";
private static Dictionary<string, EtoroKeys> LoadAll()
{
Dictionary<string, EtoroKeys> result = new(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(FilePath))
{
return result;
}
byte[] raw;
try
{
raw = File.ReadAllBytes(FilePath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return result;
}
byte[] plaintext;
try
{
plaintext = OperatingSystem.IsWindows()
? ProtectedData.Unprotect(raw, optionalEntropy: null, DataProtectionScope.CurrentUser)
: raw;
}
catch (CryptographicException)
{
// Written by a different Windows user, or corrupt: treated as absent so the
// caller prompts instead of crashing.
return result;
}
try
{
using JsonDocument doc = JsonDocument.Parse(plaintext);
foreach (JsonProperty entry in doc.RootElement.EnumerateObject())
{
JsonElement v = entry.Value;
string api = Get(v, "apiKey");
string user = Get(v, "userKey");
DateTime saved = DateTime.TryParse(Get(v, "savedUtc"), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t) ? t : DateTime.MinValue;
if (api.Length > 0 && user.Length > 0)
{
result[entry.Name] = new EtoroKeys(api, user, saved);
}
}
}
catch (JsonException)
{
return [];
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
return result;
static string Get(JsonElement e, string name) =>
e.TryGetProperty(name, out JsonElement p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? string.Empty : string.Empty;
}
private static void Write(Dictionary<string, EtoroKeys> all)
{
ArrayBufferWriter<byte> buffer = new(512);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
foreach ((string environment, EtoroKeys k) in all)
{
w.WriteStartObject(environment);
w.WriteString("apiKey", k.ApiKey);
w.WriteString("userKey", k.UserKey);
w.WriteString("savedUtc", k.SavedUtc.ToString("O", CultureInfo.InvariantCulture));
w.WriteEndObject();
}
w.WriteEndObject();
}
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
byte[] payload = OperatingSystem.IsWindows()
? ProtectedData.Protect(buffer.WrittenSpan.ToArray(), optionalEntropy: null, DataProtectionScope.CurrentUser)
: buffer.WrittenSpan.ToArray();
try
{
File.WriteAllBytes(FilePath, payload);
if (!OperatingSystem.IsWindows())
{
try
{
File.SetUnixFileMode(FilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
// Best effort; the login window already warns that the file is not encrypted here.
}
}
}
finally
{
CryptographicOperations.ZeroMemory(payload);
}
}
}
@@ -1,54 +0,0 @@
<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>
-10
View File
@@ -1,10 +0,0 @@
// 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;
-78
View File
@@ -1,78 +0,0 @@
<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>
@@ -1,723 +0,0 @@
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 Core.Notifications.INotifier _notifier;
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),
};
_notifier = Baskets.Notifications.Create(_config);
_supervisor = new BotSupervisor(_config, notifier: _notifier);
if (_notifier is Core.Notifications.TelegramNotifier telegram)
{
Baskets.TelegramCommands commands = new(_supervisor, () => _notifier.Status);
telegram.CommandHandler = commands.HandleAsync;
telegram.StartCommands();
}
_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;
}
bool foreign = false;
if (_vm.ForeignPositions > 0)
{
foreign = MessageBox.Show(this,
$"Sul conto ci sono {_vm.ForeignPositions} posizioni ESTERNE, non aperte dal bot.\n\nChiudere anche quelle? (No = restano aperte, come da regola.)",
"Kill-switch: posizioni esterne", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) == MessageBoxResult.Yes;
}
Log.Warn($"KILL-SWITCH richiesto dalla finestra{(foreign ? " (anche le posizioni esterne)" : string.Empty)}");
Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, foreign ? "esterne" : 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()
{
BotSnapshot current = _supervisor.Snapshot();
if (current.HaltedWithResidue)
{
if (MessageBox.Show(this,
$"Il kill-switch ha lasciato {current.HaltResidue.Count} posizioni del bot sul conto:\n\n" +
string.Join("\n", current.HaltResidue.Select(static r => $" {r.PositionId} {r.Symbol} {(r.IsBuy ? "long" : "short")} {r.Units:0.##} ({r.Origin})")) +
"\n\nChiuderle ora? Il reset non può partire finché restano sul conto.",
"Residui del kill-switch", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) == MessageBoxResult.Yes)
{
Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.CloseResidue, "all", "chiusura dei residui dalla finestra"), CancellationToken.None).ConfigureAwait(true));
}
if (_supervisor.Snapshot().HaltedWithResidue)
{
return;
}
}
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);
if (_notifier is IAsyncDisposable disposable)
{
await disposable.DisposeAsync().ConfigureAwait(true);
}
}
catch (Exception ex)
{
Log.Error("errore durante lo spegnimento", ex);
}
// Su un frame nuovo del dispatcher, non nella continuazione di OnClosing: se il
// Task si completa in modo sincrono la ripresa avviene ancora dentro il callback
// di chiusura, ed è lì che Close() solleva l'eccezione.
_ = Dispatcher.BeginInvoke(DispatcherPriority.Normal, ChiudiDavvero);
}
private void ChiudiDavvero()
{
if (_closed)
{
return;
}
_closed = true;
Close();
}
}
-108
View File
@@ -1,108 +0,0 @@
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();
}
@@ -1,51 +0,0 @@
<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>
@@ -1,143 +0,0 @@
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;
}
}
@@ -1,44 +0,0 @@
namespace Encelado.Bot.Ui;
/// <summary>
/// What a page can ask the shell to do. Pages are given this rather than a reference to
/// the window so they stay unaware of how the shell is put together — and so the only
/// place that touches the supervisor, the key store or the file system stays the window
/// itself. No decision is taken in the UI: it reads state and sends commands.
/// </summary>
public interface IUiActions
{
/// <summary>Closes both legs of one basket at market, after confirmation.</summary>
Task CloseBasketAsync(string basket);
/// <summary>Closes everything and blocks new entries, after confirmation.</summary>
Task KillSwitchAsync();
/// <summary>Switches the style preset at runtime; open baskets are not touched.</summary>
Task SetPresetAsync(string preset);
/// <summary>Lifts the equity stop or the kill-switch; asks for the written reason that goes in the ledger.</summary>
Task ResetEquityStopAsync();
void ShowLogin();
void ForgetCredentials();
void OpenConfigFile();
/// <summary>Opens <c>strategy.json</c> in the shell's default editor.</summary>
void OpenStrategyFile();
/// <summary>Opens the data folder (ledger, market, models) in Explorer.</summary>
void OpenDataFolder();
void OpenLogFolder();
void OpenLogFile();
/// <summary>Asks for a new log directory and persists it to the configuration file.</summary>
void ChangeLogDirectory();
/// <summary>Rewrites the configuration with the factory values, after taking a backup.</summary>
void RestoreDefaults();
}
@@ -1,199 +0,0 @@
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));
}
@@ -1,430 +0,0 @@
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 int _pendingBaskets;
private int _orphanLegs;
private int _foreignPositions;
private double _accountOpenPnl;
private double _usedMargin;
private bool _unreconciled;
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 int PendingBaskets { get => _pendingBaskets; private set => Set(ref _pendingBaskets, value); }
/// <summary>Positions of ours on the account that belong to no basket. Red when above zero.</summary>
public int OrphanLegs { get => _orphanLegs; private set => Set(ref _orphanLegs, value); }
public int ForeignPositions { get => _foreignPositions; private set => Set(ref _foreignPositions, value); }
public bool HasOrphans => _orphanLegs > 0;
/// <summary>The second line of the baskets tile: pending entries, orphans, foreign positions.</summary>
public string BasketsSub => string.Create(CultureInfo.CurrentCulture, $"in attesa {_pendingBaskets} · orfane {_orphanLegs} · esterne {_foreignPositions}");
/// <summary>The account's own unrealised result, all positions included.</summary>
public double AccountOpenPnl { get => _accountOpenPnl; private set => Set(ref _accountOpenPnl, value); }
public double UsedMargin { get => _usedMargin; private set => Set(ref _usedMargin, value); }
public string OpenPnlSub => string.Create(CultureInfo.CurrentCulture, $"di cui basket {_openPnl:+#,##0.00;-#,##0.00;0.00} · margine {_usedMargin:N0} / disp. {_availableBalance:N0}");
public bool Unreconciled { get => _unreconciled; private set => Set(ref _unreconciled, value); }
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;
PendingBaskets = s.PendingBaskets;
OrphanLegs = s.OrphanLegs;
ForeignPositions = s.ForeignPositions;
AccountOpenPnl = s.AccountOpenPnl;
UsedMargin = s.UsedMargin;
Unreconciled = s.Unreconciled;
Raise(nameof(BasketsDisplay));
Raise(nameof(BasketsSub));
Raise(nameof(HasOrphans));
Raise(nameof(OpenPnlSub));
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.HaltedWithResidue)
{
Banner = $"KILL-SWITCH CON RESIDUO — {s.HaltReason}. Posizioni ancora sul conto: {string.Join(", ", s.HaltResidue.Select(static r => $"{r.PositionId} {r.Symbol} {(r.IsBuy ? "long" : "short")} ({r.Origin})"))}. Chiudile con «Sblocca…» (chiusura dei residui) o a mano su eToro.";
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.Unreconciled)
{
Banner = $"Posizioni non riconciliate: {s.UnreconciledReason}.";
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));
}
@@ -1,309 +0,0 @@
<UserControl x:Class="Encelado.Bot.Ui.Pages.DashboardPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
La dashboard: solo quello che serve a capire in tre secondi come sta andando.
Cinque numeri, la tabella dei basket, una striscia di contesto e le ultime righe
del log. Nessuna decisione avviene qui: la pagina legge lo snapshot del motore e
gli manda comandi (chiudi, kill-switch, preset, reset). I dettagli stanno nei
tooltip e nella pagina Log.
-->
<UserControl.Resources>
<Style x:Key="Cell" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
</Style>
<Style x:Key="CellDim" TargetType="TextBlock" BasedOn="{StaticResource Cell}">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
</Style>
<Style x:Key="Small" TargetType="TextBlock">
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="TextWrapping" Value="Wrap"/>
</Style>
</UserControl.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
<StackPanel MaxWidth="1480" HorizontalAlignment="Stretch">
<!-- ==================== intestazione ==================== -->
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Correlation Baskets" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Hint}"
ToolTip="Cinque basket di due coppie forex correlate: ingresso quando il cross sintetico diverge (z-score oltre la soglia del preset), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale. Il bot apre e chiude da solo."/>
</StackPanel>
<TextBlock Text="{Binding StrategyVersion}" Style="{StaticResource Sub}"
ToolTip="Versione del codice, hash di strategy.json e id della sessione, scritti in ogni riga del ledger."/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,12,0">
<TextBlock Text="Preset" Style="{StaticResource Label}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox x:Name="PresetBox" Width="160" ItemsSource="{Binding Presets}" SelectedItem="{Binding Preset, Mode=OneWay}"
SelectionChanged="OnPresetChanged" IsEnabled="{Binding IsRunning}"
ToolTip="Conservative: z 2,5, rischio 0,25 %, 2 basket, TP 8 pip. Moderate: z 2,0, 0,5 %, 3 basket, TP 10. Aggressive: z 1,5, 1 %, 5 basket, TP 12. Il cambio a caldo non tocca i basket aperti."/>
</StackPanel>
<Button Grid.Column="2" Content="KILL-SWITCH" Click="OnKillSwitch" Style="{StaticResource Danger}" MinWidth="120"
FontWeight="SemiBold" IsEnabled="{Binding IsRunning}"
ToolTip="Chiude tutte le gambe di tutti i basket a mercato e blocca le nuove entrate. Chiede conferma. Lo stesso effetto si ottiene creando un file STOP nella cartella di lavoro."/>
</Grid>
<!-- ==================== avviso ==================== -->
<Border Margin="0,0,0,14" CornerRadius="10" Padding="14,10"
Visibility="{Binding HasBanner, Converter={StaticResource BoolVis}}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#1AF87171"/>
<Setter Property="BorderBrush" Value="#66F87171"/>
<Setter Property="BorderThickness" Value="1"/>
<Style.Triggers>
<DataTrigger Binding="{Binding BannerIsWarning}" Value="True">
<Setter Property="Background" Value="#1AF5B74F"/>
<Setter Property="BorderBrush" Value="#66F5B74F"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Banner}" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Down}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding BannerIsWarning}" Value="True">
<Setter Property="Foreground" Value="{StaticResource Warn}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<Button Grid.Column="1" Content="Sblocca…" Click="OnResetEquityStop" Margin="12,0,0,0"
Visibility="{Binding EquityStopped, Converter={StaticResource BoolVis}}"
ToolTip="Sblocca il bot dopo un equity stop o un kill-switch. La motivazione scritta finisce nel ledger."/>
</Grid>
</Border>
<!-- ==================== i cinque numeri ==================== -->
<UniformGrid Rows="1" Columns="5" Margin="0,0,-12,14">
<Border Style="{StaticResource Kpi}" ToolTip="Equity = saldo + P&amp;L non realizzato. È il numero su cui si calcolano rischio per basket, equity stop e perdita giornaliera.">
<StackPanel>
<TextBlock Text="Equity" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}" Text="{Binding Equity, StringFormat=N2}"/>
<TextBlock Style="{StaticResource Sub}" Text="{Binding Balance, StringFormat='saldo {0:N2}'}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource Kpi}" ToolTip="P&amp;L chiuso di oggi (giornata UTC), dal ledger dei basket, e in percentuale dell'equity di inizio giornata. Al 3 % di perdita il bot non apre più fino a domani.">
<StackPanel>
<TextBlock Text="P&amp;L oggi" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}"
Text="{Binding TodayPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
Foreground="{Binding TodayPnl, Converter={StaticResource PnlBrush}}"/>
<TextBlock Style="{StaticResource Sub}" Text="{Binding TodayPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource Kpi}" ToolTip="P&amp;L aperto del conto (tutte le posizioni, come lo riporta eToro); sotto, la parte dovuta ai basket del bot, il margine impegnato e il disponibile. Se equity saldo non torna con le posizioni per più di un minuto compare l'avviso «posizioni non riconciliate».">
<StackPanel>
<TextBlock Text="P&amp;L aperto (conto)" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}"
Text="{Binding AccountOpenPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
Foreground="{Binding AccountOpenPnl, Converter={StaticResource PnlBrush}}"/>
<TextBlock Style="{StaticResource Sub}" Text="{Binding OpenPnlSub}"/>
</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. «In attesa»: ingressi con una gamba senza esito, seguiti dal registro degli ordini. «Orfane»: posizioni del bot senza basket, adottate e chiuse (rosso se ce ne sono). «Esterne»: posizioni non aperte dal bot, mai toccate.">
<StackPanel>
<TextBlock Text="Basket aperti" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}" Text="{Binding BasketsDisplay}"/>
<TextBlock Text="{Binding BasketsSub}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource Sub}">
<Style.Triggers>
<DataTrigger Binding="{Binding HasOrphans}" Value="True">
<Setter Property="Foreground" Value="{StaticResource Down}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Border>
</UniformGrid>
<!-- ==================== basket ==================== -->
<Border Style="{StaticResource Card}" Padding="0" Margin="0,0,0,14">
<DataGrid ItemsSource="{Binding Baskets}" MinHeight="120" ColumnHeaderHeight="36" HorizontalScrollBarVisibility="Disabled">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow" BasedOn="{StaticResource {x:Type DataGridRow}}">
<Setter Property="ToolTip" Value="{Binding Tooltip}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Enabled}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTemplateColumn Header="Basket" Width="190">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Cross, StringFormat='cross {0}'}" FontSize="10.5" Foreground="{StaticResource Faint}"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Stato" Width="110">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Style="{StaticResource Chip}" HorizontalAlignment="Left" Padding="9,2">
<TextBlock Text="{Binding StateLabel}" FontSize="11">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsOpen}" Value="True">
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Error">
<Setter Property="Foreground" Value="{StaticResource Down}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="z" Binding="{Binding ZDisplay}" Width="72" ElementStyle="{StaticResource Cell}"/>
<DataGridTextColumn Header="ρ" Binding="{Binding RhoDisplay}" Width="66" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="HL" Binding="{Binding HalfLifeDisplay}" Width="54" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="Pips" Binding="{Binding PipsDisplay}" Width="70" ElementStyle="{StaticResource Cell}"/>
<DataGridTextColumn Header="TP" Binding="{Binding TpDisplay}" Width="50" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="P&amp;L $" Binding="{Binding PnlDisplay}" Width="104">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock" BasedOn="{StaticResource Cell}">
<Setter Property="Foreground" Value="{Binding PnlUsd, Converter={StaticResource PnlBrush}}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="Costo" Binding="{Binding CostDisplay}" Width="66" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="p ML" Binding="{Binding PMlDisplay}" Width="112" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="Prossimo evento" Binding="{Binding NextEvent}" Width="230" ElementStyle="{StaticResource CellDim}"/>
<DataGridTemplateColumn Header="" Width="90">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Content="CHIUDI" Click="OnCloseBasket" Tag="{Binding Name}" Style="{StaticResource Danger}"
Padding="9,3" FontSize="11" FontWeight="SemiBold"
Visibility="{Binding IsOpen, Converter={StaticResource BoolVis}}"
ToolTip="Chiude entrambe le gambe a mercato, dopo conferma."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Border>
<!-- ==================== contesto ==================== -->
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource Card}" Margin="0,0,12,0" Padding="14,12"
ToolTip="Dal calendario settimanale FairEconomy. Blackout: nessuna entrata nei 45 minuti prima e nei 30 dopo un evento ad alto impatto sulle valute del basket.">
<StackPanel>
<TextBlock Text="Prossimo evento" Style="{StaticResource Label}"/>
<TextBlock Text="{Binding NextEvent}" Style="{StaticResource Small}" Margin="0,6,0,0" Foreground="{StaticResource Txt}"/>
<TextBlock Text="{Binding CalendarState}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}"/>
</StackPanel>
</Border>
<Border Grid.Column="1" Style="{StaticResource Card}" Margin="0,0,12,0" Padding="14,12"
ToolTip="Stato del collegamento a eToro Public API, latenza dell'ultima richiesta e uso delle quote (120 richieste al minuto per le quotazioni, 20 per gli ordini).">
<StackPanel>
<TextBlock Text="Collegamento eToro" Style="{StaticResource Label}"/>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="{Binding ApiState}" Style="{StaticResource Small}" Foreground="{StaticResource Txt}"/>
<TextBlock Text="{Binding ApiLatency}" Style="{StaticResource Small}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Uptime, StringFormat='· attivo da {0}'}" Style="{StaticResource Small}" Margin="8,0,0,0"/>
</StackPanel>
<TextBlock Text="{Binding Counters}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}"/>
</StackPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource Card}" Padding="14,12"
ToolTip="Meta-modello (regressione logistica online, in ombra finché non supera i cancelli di attivazione), volatilità prevista (EWMA contro HAR-RV) e proposta del bandit sul preset.">
<StackPanel>
<TextBlock Text="Apprendimento" Style="{StaticResource Label}"/>
<TextBlock Text="{Binding MlState}" Style="{StaticResource Small}" Margin="0,6,0,0" Foreground="{StaticResource Txt}" TextTrimming="CharacterEllipsis" MaxHeight="34"/>
<TextBlock Text="{Binding VolForecast}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}" TextTrimming="CharacterEllipsis" MaxHeight="34"/>
</StackPanel>
</Border>
</Grid>
<!-- ==================== attività ==================== -->
<Border Style="{StaticResource Card}" Padding="14,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Attività" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Hint}" FontSize="12"
ToolTip="Le ultime righe del log. La scheda Log tiene tutta la cronologia, filtra per livello e apre il file su disco."/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Events}" MaxHeight="220">
<ItemsControl.Template>
<ControlTemplate TargetType="ItemsControl">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsPresenter/>
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="70"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Time}" FontFamily="{StaticResource Mono}" FontSize="11" Foreground="{StaticResource Faint}"/>
<TextBlock Grid.Column="1" Text="{Binding Message}" FontSize="11.5" TextWrapping="Wrap"
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -1,84 +0,0 @@
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;
}
}
}
@@ -1,111 +0,0 @@
<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>
@@ -1,62 +0,0 @@
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();
}
@@ -1,209 +0,0 @@
<UserControl x:Class="Encelado.Bot.Ui.Pages.SettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<UserControl.Resources>
<!--
Una riga del form. I campi non modificabili restano campi: stessa etichetta, stessa
cornice, stessa posizione. Cambia solo che non accettano il fuoco e lo dicono.
Nasconderli in un paragrafo è ciò che faceva sembrare questa pagina un documento.
-->
<DataTemplate x:Key="FieldTemplate">
<Grid Margin="0,0,0,11">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="230"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="260"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Label}" VerticalAlignment="Center"
Foreground="{StaticResource Dim}" FontSize="12.5" TextWrapping="Wrap"
Margin="0,0,10,0" ToolTip="{Binding FullTooltip}"/>
<TextBlock Grid.Column="1" Style="{StaticResource Hint}" Margin="0,0,10,0"
ToolTip="{Binding FullTooltip}"/>
<Grid Grid.Column="2">
<!--
Due controlli nella stessa cella, uno solo visibile. I campi con un insieme
di valori ammessi si scelgono da un elenco e non si scrivono: da lì non può
uscire un nome inventato, e quando un aggiornamento toglie una voce l'elenco
smette semplicemente di proporla.
-->
<TextBox Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="{Binding IsReadOnly}"
ToolTip="{Binding FullTooltip}"
Visibility="{Binding IsFreeText, Converter={StaticResource BoolVis}}">
<TextBox.Style>
<Style TargetType="TextBox" BasedOn="{StaticResource {x:Type TextBox}}">
<Style.Triggers>
<!-- Read-only: dimmed and not focusable, but still a field. -->
<DataTrigger Binding="{Binding IsReadOnly}" Value="True">
<Setter Property="Background" Value="{StaticResource Bg}"/>
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Cursor" Value="Arrow"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasError}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsDirty}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<ComboBox ItemsSource="{Binding Options}"
SelectedItem="{Binding Value, Mode=TwoWay}"
IsEnabled="{Binding IsEditable}"
ToolTip="{Binding FullTooltip}"
Visibility="{Binding IsList, Converter={StaticResource BoolVis}}">
<ComboBox.Style>
<Style TargetType="ComboBox" BasedOn="{StaticResource {x:Type ComboBox}}">
<Style.Triggers>
<DataTrigger Binding="{Binding HasError}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsDirty}" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ComboBox.Style>
</ComboBox>
<!-- Lucchetto sui campi fissati dalla strategia. -->
<TextBlock Text="&#xE72E;" FontFamily="Segoe MDL2 Assets" FontSize="11"
HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,9,0"
Foreground="{StaticResource Faint}" IsHitTestVisible="False"
Visibility="{Binding IsReadOnly, Converter={StaticResource BoolVis}}"/>
</Grid>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center"
Margin="10,0,0,0">
<TextBlock Text="{Binding Suffix}" Foreground="{StaticResource Faint}" FontSize="11.5"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Error}" Foreground="{StaticResource Down}" FontSize="11.5"
VerticalAlignment="Center" Margin="10,0,0,0"
Visibility="{Binding HasError, Converter={StaticResource BoolVis}}"/>
</StackPanel>
</Grid>
</DataTemplate>
</UserControl.Resources>
<DockPanel>
<StackPanel DockPanel.Dock="Top" Style="{StaticResource PageHeader}">
<TextBlock Text="Impostazioni" Style="{StaticResource PageTitle}"/>
<TextBlock Style="{StaticResource Hint}"
ToolTip="Ogni campo ha una spiegazione: passa il puntatore sopra l'etichetta o sul pallino. Le chiavi si applicano subito; tutto il resto ha effetto al prossimo avvio. I parametri della strategia si modificano in strategy.json."/>
</StackPanel>
<!-- ==================== barra di salvataggio ==================== -->
<Border DockPanel.Dock="Bottom" Style="{StaticResource Card}" Padding="14,11" Margin="0,10,0,0">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="SaveStatus" Style="{StaticResource Sub}" Margin="0"
VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Annulla modifiche" Click="OnRevert" Margin="0,0,10,0"/>
<Button x:Name="SaveButton" Content="Salva" Click="OnSave" Style="{StaticResource Primary}"
MinWidth="120"/>
</StackPanel>
</Grid>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,6,0">
<StackPanel MaxWidth="1000" HorizontalAlignment="Left">
<!-- ==================== credenziali ==================== -->
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock Text="Chiavi eToro" Style="{StaticResource Head}"/>
<TextBlock x:Name="CredStatus" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
<TextBlock x:Name="CredPath" Style="{StaticResource Sub}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
<Button Content="Inserisci / sostituisci le chiavi" Click="OnLogin" Style="{StaticResource Primary}"/>
<Button Content="Rimuovi le chiavi salvate" Click="OnLogout" Margin="10,0,0,0" Style="{StaticResource Danger}"/>
</StackPanel>
</StackPanel>
</Border>
<!-- ==================== cartella dei log ==================== -->
<Border Style="{StaticResource Card}" Margin="0,10,0,0">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="Cartella dei log" Style="{StaticResource Head}" Margin="0"/>
<TextBlock x:Name="LogHint" Style="{StaticResource Hint}" FontSize="12"/>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="LogPathBox" Grid.Column="0" IsReadOnly="True"/>
<Button Grid.Column="1" Content="Cambia…" Click="OnChangeLogDirectory" Margin="8,0,0,0"/>
<Button Grid.Column="2" Content="Apri" Click="OnOpenLogFolder" Margin="8,0,0,0"/>
</Grid>
</StackPanel>
</Border>
<!-- ==================== i gruppi di campi ==================== -->
<ItemsControl x:Name="Groups" Margin="0,10,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="{Binding Title}" Style="{StaticResource Head}" Margin="0,0,0,4"/>
<TextBlock Text="{Binding Description}" Style="{StaticResource Sub}"
TextWrapping="Wrap" Margin="0,0,0,14"/>
<ItemsControl ItemsSource="{Binding Fields}"
ItemTemplate="{StaticResource FieldTemplate}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="File" Style="{StaticResource Head}" Margin="0"/>
<TextBlock Style="{StaticResource Hint}" FontSize="12"
ToolTip="Il salvataggio riscrive solo i valori cambiati e conserva tutto il resto del file, commenti compresi. Per modifiche che questa pagina non copre, apri il file a mano."/>
</StackPanel>
<TextBlock x:Name="ConfigSummary" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Margin="0,14,0,0">
<Button Content="Apri il file di configurazione" Click="OnOpenConfig"/>
<Button Content="Apri strategy.json" Click="OnOpenStrategy" Margin="10,0,0,0"
ToolTip="I parametri della strategia dei basket: preset, soglie, rischio, calendario, sizing. Ogni chiave è documentata nel file."/>
<Button Content="Apri la cartella dei dati" Click="OnOpenData" Margin="10,0,0,0"
ToolTip="Ledger (data/ledger), barre di mercato, calendario, notizie e modelli."/>
<Button Content="Ripristina i valori predefiniti" Click="OnRestoreDefaults"
Style="{StaticResource Danger}" Margin="10,0,0,0"/>
</StackPanel>
<TextBlock Style="{StaticResource Sub}" Margin="0,10,0,0" TextWrapping="Wrap"
Text="Il ripristino riscrive l&apos;intero file con la configurazione di fabbrica, commenti compresi, dopo averne salvato una copia con la data accanto all&apos;originale. Le chiavi eToro e strategy.json non vengono toccati."/>
</StackPanel>
</Border>
<Border Style="{StaticResource Card}" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="Informazioni" Style="{StaticResource Head}"/>
<TextBlock x:Name="AboutText" Style="{StaticResource Sub}" Margin="0" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>
@@ -1,224 +0,0 @@
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();
}
@@ -1,24 +0,0 @@
<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>
@@ -1,44 +0,0 @@
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";
}
-563
View File
@@ -1,563 +0,0 @@
<!--
Il tema dell'applicazione: tavolozza, tipografia, superfici e i template dei
controlli che WPF disegnerebbe con la sua veste chiara.
Sta qui e non dentro App.xaml perché App.xaml dichiara x:Class: caricarlo come
dizionario costruisce l'oggetto Application, e in un AppDomain ne può esistere uno
solo. I test che istanziano le pagine per verificarne i binding hanno bisogno degli
stili senza far partire l'applicazione.
Scelte: una sola famiglia di caratteri per il testo (Segoe UI Variable, quella di
Windows 11), una monospaziata solo per i numeri; superfici con angoli morbidi e
senza ombre; contrasto minimo 4,5:1 per ogni testo (WCAG AA).
-->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:Encelado.Bot.Ui">
<!-- ================= palette ================= -->
<Color x:Key="BgColor">#FF0F1319</Color>
<SolidColorBrush x:Key="Bg" Color="#FF0F1319"/>
<SolidColorBrush x:Key="Panel" Color="#FF171C25"/>
<SolidColorBrush x:Key="Panel2" Color="#FF1F2531"/>
<SolidColorBrush x:Key="Line" Color="#FF2A3140"/>
<SolidColorBrush x:Key="Txt" Color="#FFECEFF6"/>
<SolidColorBrush x:Key="Dim" Color="#FFA8B3C7"/>
<SolidColorBrush x:Key="Faint" Color="#FF7B879E"/>
<SolidColorBrush x:Key="Up" Color="#FF34D399"/>
<SolidColorBrush x:Key="Down" Color="#FFF87171"/>
<SolidColorBrush x:Key="Accent" Color="#FF6C9CFF"/>
<SolidColorBrush x:Key="Warn" Color="#FFF5B74F"/>
<FontFamily x:Key="Sans">Segoe UI Variable Text, Segoe UI, Arial</FontFamily>
<FontFamily x:Key="Mono">Cascadia Mono, Consolas, Courier New</FontFamily>
<ui:PnlBrushConverter x:Key="PnlBrush"/>
<ui:BoolToVisibilityConverter x:Key="BoolVis"/>
<ui:InverseBoolConverter x:Key="NotBool"/>
<ui:LevelBrushConverter x:Key="LevelBrush"/>
<ui:LocalTimeConverter x:Key="LocalTime"/>
<ui:YesNoConverter x:Key="YesNo"/>
<!-- ================= text ================= -->
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal"/>
</Style>
<!-- Etichetta piccola in maiuscoletto sopra un numero. -->
<Style x:Key="Label" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
</Style>
<!-- Il numero grande di un indicatore. -->
<Style x:Key="Value" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="24"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="0,6,0,0"/>
</Style>
<Style x:Key="Sub" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Margin" Value="0,4,0,0"/>
</Style>
<!-- Titolo di una sezione. -->
<Style x:Key="Head" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<Style x:Key="PageTitle" TargetType="TextBlock">
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="PageHeader" TargetType="StackPanel">
<Setter Property="Orientation" Value="Horizontal"/>
<Setter Property="Margin" Value="0,0,0,14"/>
</Style>
<!-- ================= surfaces ================= -->
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Panel}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="16"/>
</Style>
<Style x:Key="Kpi" TargetType="Border" BasedOn="{StaticResource Card}">
<Setter Property="Padding" Value="16,14"/>
<Setter Property="Margin" Value="0,0,12,0"/>
</Style>
<Style x:Key="Chip" TargetType="Border">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="999"/>
<Setter Property="Padding" Value="9,3"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- Il badge dell'ambiente: PAPER azzurro, DEMO ambra, LIVE rosso. -->
<Style x:Key="ModeBadge" TargetType="Border" BasedOn="{StaticResource Chip}">
<Setter Property="Padding" Value="10,3"/>
<Style.Triggers>
<DataTrigger Binding="{Binding ModeKind}" Value="live">
<Setter Property="Background" Value="#26F87171"/>
<Setter Property="BorderBrush" Value="#80F87171"/>
</DataTrigger>
<DataTrigger Binding="{Binding ModeKind}" Value="demo">
<Setter Property="Background" Value="#26F5B74F"/>
<Setter Property="BorderBrush" Value="#80F5B74F"/>
</DataTrigger>
<DataTrigger Binding="{Binding ModeKind}" Value="paper">
<Setter Property="Background" Value="#266C9CFF"/>
<Setter Property="BorderBrush" Value="#806C9CFF"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- Il pallino dello stato del motore. -->
<Style x:Key="Dot" TargetType="Ellipse">
<Setter Property="Width" Value="9"/>
<Setter Property="Height" Value="9"/>
<Setter Property="Fill" Value="{StaticResource Faint}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding StateKind}" Value="running">
<Setter Property="Fill" Value="{StaticResource Up}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateKind}" Value="faulted">
<Setter Property="Fill" Value="{StaticResource Down}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateKind}" Value="starting">
<Setter Property="Fill" Value="{StaticResource Warn}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateKind}" Value="stopping">
<Setter Property="Fill" Value="{StaticResource Warn}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- ================= buttons ================= -->
<Style TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="14,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="b" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="b" Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
<Setter Property="Cursor" Value="Arrow"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Primary" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{StaticResource Accent}"/>
<Setter Property="BorderBrush" Value="{StaticResource Accent}"/>
<Setter Property="Foreground" Value="#FF0B1020"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="Danger" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="{StaticResource Down}"/>
<Setter Property="BorderBrush" Value="#66F87171"/>
</Style>
<!-- AVVIA verde, FERMA rossa. -->
<Style x:Key="PowerButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Padding" Value="22,8"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="#FF06170F"/>
<Setter Property="Background" Value="{StaticResource Up}"/>
<Setter Property="BorderBrush" Value="{StaticResource Up}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsRunning}" Value="True">
<Setter Property="Background" Value="{StaticResource Down}"/>
<Setter Property="BorderBrush" Value="{StaticResource Down}"/>
<Setter Property="Foreground" Value="White"/>
</DataTrigger>
</Style.Triggers>
</Style>
<!-- ================= inputs ================= -->
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="CaretBrush" Value="{StaticResource Accent}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="9,7"/>
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="PasswordBox">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="CaretBrush" Value="{StaticResource Accent}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="9,7"/>
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="PasswordBox">
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12.5"/>
</Style>
<!--
Fully templated. Setting Background on the stock ComboBox does almost nothing:
its default template wraps a system-themed ToggleButton that paints its own chrome.
-->
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="b" Background="Transparent" Padding="{TemplateBinding Padding}" CornerRadius="6">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="b" Property="Background" Value="#266C9CFF"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="b" Property="Background" Value="#336C9CFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="MaxDropDownHeight" Value="360"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="Toggle" Focusable="False" ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="bg" Background="{StaticResource Panel2}"
BorderBrush="{Binding BorderBrush, RelativeSource={RelativeSource AncestorType=ComboBox}}"
BorderThickness="1" CornerRadius="8">
<Path x:Name="arrow" HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,11,0" Data="M 0 0 L 4 4 L 8 0"
Stroke="{StaticResource Dim}" StrokeThickness="1.4"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bg" Property="BorderBrush" Value="{StaticResource Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Left" VerticalAlignment="Center"
IsHitTestVisible="False"/>
<Popup IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom"
AllowsTransparency="True" Focusable="False" PopupAnimation="Fade">
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Line}"
BorderThickness="1" CornerRadius="8" Margin="0,3,0,0" Padding="4"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<ScrollViewer>
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="Height" Value="4"/>
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<!-- ================= top navigation ================= -->
<!--
Le pagine sono schede orizzontali nella barra in alto: una ListBox, così la
selezione, le frecce e la voce selezionata arrivano gratis.
-->
<Style x:Key="TabList" TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Margin" Value="0,0,4,0"/>
<Setter Property="AutomationProperties.Name" Value="{Binding}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="b" CornerRadius="8" Padding="14,7" Background="Transparent">
<TextBlock Text="{Binding}" FontSize="13" FontWeight="SemiBold"
Foreground="{TemplateBinding Foreground}" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="b" Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="False"/>
<Condition Property="IsMouseOver" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="b" Property="Background" Value="#14FFFFFF"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
</Style>
<!-- ================= tooltip ================= -->
<Style TargetType="ToolTip">
<Setter Property="Background" Value="{StaticResource Panel2}"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="12,10"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="MaxWidth" Value="440"/>
<Setter Property="HasDropShadow" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToolTip">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter>
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="LineHeight" Value="17"/>
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Il pallino informativo accanto a un titolo. -->
<Style x:Key="Hint" TargetType="TextBlock">
<Setter Property="Text" Value="&#xE9CE;"/>
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="7,0,0,0"/>
<Setter Property="Cursor" Value="Help"/>
<Setter Property="ToolTipService.ShowDuration" Value="60000"/>
<Setter Property="ToolTipService.InitialShowDelay" Value="250"/>
<Setter Property="ToolTipService.Placement" Value="Bottom"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- ================= data grid ================= -->
<Style TargetType="DataGrid">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="RowBackground" Value="Transparent"/>
<Setter Property="AlternatingRowBackground" Value="Transparent"/>
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
<Setter Property="HorizontalGridLinesBrush" Value="{StaticResource Line}"/>
<Setter Property="HeadersVisibility" Value="Column"/>
<Setter Property="AutoGenerateColumns" Value="False"/>
<Setter Property="IsReadOnly" Value="True"/>
<Setter Property="CanUserResizeRows" Value="False"/>
<Setter Property="CanUserSortColumns" Value="False"/>
<Setter Property="CanUserReorderColumns" Value="False"/>
<Setter Property="SelectionMode" Value="Single"/>
<Setter Property="RowHeight" Value="38"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource Faint}"/>
<Setter Property="FontFamily" Value="{StaticResource Sans}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Typography.Capitals" Value="AllSmallCaps"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="BorderBrush" Value="{StaticResource Line}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource Txt}"/>
<Setter Property="FontFamily" Value="{StaticResource Mono}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Padding" Value="10,0"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#146C9CFF"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#206C9CFF"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- ================= scrollbar ================= -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="8"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="{StaticResource Line}" CornerRadius="4" Margin="2,0"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageDownCommand" Opacity="0" Focusable="False"/>
</Track.IncreaseRepeatButton>
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageUpCommand" Opacity="0" Focusable="False"/>
</Track.DecreaseRepeatButton>
</Track>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
@@ -62,8 +62,6 @@ public sealed class BacktestBroker : IBroker
public IReadOnlyList<ClosedTrade> Closed => _closed; 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> /// <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) public void Advance(DateTime nowUtc, IReadOnlyList<QuoteSnapshot> quotes)
{ {
@@ -178,7 +178,6 @@ public sealed record BasketDecision(
BasketEvaluation Evaluation, BasketEvaluation Evaluation,
CostGateResult? Cost) CostGateResult? Cost)
{ {
public bool IsExit => Kind == DecisionKind.Exit;
} }
/// <summary> /// <summary>
@@ -79,8 +79,6 @@ public sealed class BasketExecutor
_mode = mode ?? string.Empty; _mode = mode ?? string.Empty;
} }
public OrderTracker? Tracker => _tracker;
public Task<EntryOutcome> OpenAsync(BasketContext ctx, BasketDecision decision, BasketPreset preset, CancellationToken ct) => public Task<EntryOutcome> OpenAsync(BasketContext ctx, BasketDecision decision, BasketPreset preset, CancellationToken ct) =>
OpenAsync(ctx, decision, preset, ctx?.BasketId ?? string.Empty, ct); OpenAsync(ctx, decision, preset, ctx?.BasketId ?? string.Empty, ct);
@@ -651,5 +649,4 @@ public sealed class BasketExecutor
private static string Describe(OrderOutcome o) => private static string Describe(OrderOutcome o) =>
o.Error.Length > 0 ? $"{o.Status} — {o.Error}" : o.Status; o.Error.Length > 0 ? $"{o.Status} — {o.Error}" : o.Status;
public static string Money(double v) => v.ToString("0.00", CultureInfo.InvariantCulture);
} }
@@ -124,26 +124,6 @@ public static class BasketMath
return halfLife >= n ? double.NaN : halfLife; 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> /// <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) public static double AtrSmoothed(ReadOnlySpan<BidAskBar> bars, int period)
{ {
@@ -0,0 +1,69 @@
using System.Globalization;
namespace Encelado.Core.Baskets.History;
/// <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)
{
ArgumentNullException.ThrowIfNull(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);
}
}
@@ -0,0 +1,367 @@
using System.Globalization;
using System.Text;
using Encelado.Core.Broker;
namespace Encelado.Core.Baskets.History;
/// <summary>One position (open or closed) or one cash movement, as the Storico page shows it.</summary>
public sealed record PositionRecord(
long PositionId,
string Symbol,
bool IsBuy,
double Units,
double OpenRate,
double CloseRate,
DateTime OpenedUtc,
DateTime? ClosedUtc,
double PnlGrossUsd,
double FeesUsd,
double PnlNetUsd,
double Pips,
string Origin,
string BasketId,
string Basket,
string ExitReason,
bool IsOpen,
string Motivazione)
{
public const string Header = "position_id;strumento;verso;unita;prezzo_apertura;prezzo_chiusura;aperta_utc;chiusa_utc;pnl_lordo_usd;fee_usd;pnl_netto_usd;pip;origine;basket_id;basket;motivo_uscita;aperta;durata_min;motivazione";
public double DurationMinutes => ((ClosedUtc ?? DateTime.UtcNow) - OpenedUtc).TotalMinutes;
public string ToCsv() => string.Join(';',
[
PositionId.ToString(CultureInfo.InvariantCulture), Symbol, IsBuy ? "long" : "short", N(Units), N(OpenRate), N(CloseRate),
OpenedUtc.ToString("O", CultureInfo.InvariantCulture), ClosedUtc?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty,
N(PnlGrossUsd), N(FeesUsd), N(PnlNetUsd), N(Pips), Origin, BasketId, Basket, ExitReason, IsOpen ? "1" : "0",
DurationMinutes.ToString("0", CultureInfo.InvariantCulture), Motivazione.Replace(';', ',').Replace('\n', ' '),
]);
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.######", CultureInfo.InvariantCulture) : string.Empty;
}
/// <summary>The realised result of a period, the way the Storico page tabulates it.</summary>
public sealed record PeriodStats(
string Periodo,
DateTime FromUtc,
DateTime ToUtc,
int NBasket,
int NPosizioni,
int Vinti,
int Persi,
double WinRate,
double PnlLordo,
double Fee,
double PnlNetto,
double MediaPerBasket,
double MaxDrawdownUsd,
double MovimentiDiCassa,
string Motivazione)
{
public const string Header = "periodo;da_utc;a_utc;n_basket;n_posizioni;vinti;persi;win_rate;pnl_lordo;fee;pnl_netto;media_per_basket;max_dd;movimenti_di_cassa;motivazione";
public string ToCsv() => string.Join(';',
[
Periodo, FromUtc.ToString("O", CultureInfo.InvariantCulture), ToUtc.ToString("O", CultureInfo.InvariantCulture),
NBasket.ToString(CultureInfo.InvariantCulture), NPosizioni.ToString(CultureInfo.InvariantCulture), Vinti.ToString(CultureInfo.InvariantCulture), Persi.ToString(CultureInfo.InvariantCulture),
N(WinRate), N(PnlLordo), N(Fee), N(PnlNetto), N(MediaPerBasket), N(MaxDrawdownUsd), N(MovimentiDiCassa), Motivazione.Replace(';', ','),
]);
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.####", CultureInfo.InvariantCulture) : string.Empty;
}
/// <summary>A cash movement as the ledger recorded it (deposit, withdrawal, virtual credit).</summary>
public sealed record CashMovementRecord(DateTime TimeUtc, double Amount, string Motivazione);
/// <summary>One point of the realised equity curve (cumulative net P&amp;L of the closed positions, cash movements excluded).</summary>
public readonly record struct EquityPoint(DateTime TimeUtc, double CumulativeNetUsd);
/// <summary>
/// Turns the raw sources — the venue's closed trades, the open positions, the bot's own
/// <c>baskets.csv</c> and <c>orders.jsonl</c>, the cash movements of the ledger — into
/// the three views of the Storico page (§1 of the 5.0 plan). Pure and testable: no I/O,
/// no clock other than the one passed in. The venue's history is the truth for the
/// realised result; the bot's files say whose each position was.
/// </summary>
public static class HistoryBuilder
{
/// <summary>Every position, closed and open, plus one row per cash movement; newest first.</summary>
public static List<PositionRecord> Positions(
IReadOnlyList<ClosedTrade> closed,
IReadOnlyList<BrokerPosition> open,
IReadOnlyList<BasketOutcomeRow> baskets,
IReadOnlyList<OrderRecord> orders,
IReadOnlyList<CashMovementRecord> cash,
Func<long, string> symbolOf,
Func<string, double> pipOf,
DateTime nowUtc)
{
ArgumentNullException.ThrowIfNull(closed);
ArgumentNullException.ThrowIfNull(open);
ArgumentNullException.ThrowIfNull(baskets);
ArgumentNullException.ThrowIfNull(orders);
ArgumentNullException.ThrowIfNull(cash);
ArgumentNullException.ThrowIfNull(symbolOf);
ArgumentNullException.ThrowIfNull(pipOf);
// Whose is each position id: the last order line that produced it.
Dictionary<long, OrderRecord> byPosition = [];
foreach (OrderRecord o in orders)
{
if (o.PositionId > 0 && o.Leg is not (OrderLeg.Close or OrderLeg.Unwind))
{
byPosition[o.PositionId] = o;
}
}
Dictionary<string, BasketOutcomeRow> byBasketId = new(StringComparer.Ordinal);
foreach (BasketOutcomeRow b in baskets)
{
if (b.BasketId.Length > 0)
{
byBasketId[b.BasketId] = b;
}
}
List<PositionRecord> rows = [];
foreach (ClosedTrade t in closed)
{
string symbol = symbolOf(t.InstrumentId);
(string origin, string basketId, string basket, string exit) = Classify(t.PositionId, t.OpenedUtc, byPosition, byBasketId, baskets);
double pip = pipOf(symbol);
double pips = pip > 0 ? (t.IsBuy ? t.CloseRate - t.OpenRate : t.OpenRate - t.CloseRate) / pip : double.NaN;
rows.Add(new PositionRecord(t.PositionId, symbol, t.IsBuy, t.Units, t.OpenRate, t.CloseRate, t.OpenedUtc, t.ClosedUtc,
t.NetProfit, t.Fees, t.NetProfit - t.Fees, pips, origin, basketId, basket, exit, false, origin == "esterna" ? "posizione non aperta dal bot" : string.Empty));
}
foreach (BrokerPosition p in open)
{
string symbol = symbolOf(p.InstrumentId);
(string origin, string basketId, string basket, _) = Classify(p.PositionId, p.OpenedUtc, byPosition, byBasketId, baskets);
double pip = pipOf(symbol);
double pips = pip > 0 && p.CurrentRate > 0 ? (p.IsBuy ? p.CurrentRate - p.OpenRate : p.OpenRate - p.CurrentRate) / pip : double.NaN;
rows.Add(new PositionRecord(p.PositionId, symbol, p.IsBuy, p.Units, p.OpenRate, p.CurrentRate, p.OpenedUtc, null,
p.UnrealizedPnl + p.Fees, p.Fees, p.UnrealizedPnl, pips, origin, basketId, basket, string.Empty, true, "aperta: P&L corrente"));
}
foreach (CashMovementRecord c in cash)
{
rows.Add(new PositionRecord(0, "USD", c.Amount >= 0, 0, 0, 0, c.TimeUtc, c.TimeUtc, c.Amount, 0, c.Amount, double.NaN, "movimento di cassa", string.Empty, string.Empty,
c.Amount >= 0 ? "deposito" : "prelievo", false, c.Motivazione));
}
rows.Sort(static (a, b) => (b.ClosedUtc ?? DateTime.MaxValue).CompareTo(a.ClosedUtc ?? DateTime.MaxValue));
return rows;
}
private static (string Origin, string BasketId, string Basket, string Exit) Classify(long positionId, DateTime openedUtc, Dictionary<long, OrderRecord> byPosition, Dictionary<string, BasketOutcomeRow> byBasketId, IReadOnlyList<BasketOutcomeRow> baskets)
{
if (byPosition.TryGetValue(positionId, out OrderRecord? o))
{
if (o.BasketId.Length > 0 && byBasketId.TryGetValue(o.BasketId, out BasketOutcomeRow? b))
{
bool lone = b.ExitReason is "leg_risk_unwind" or "orphan_closed" or "bonifica_orfana" || !double.IsFinite(b.EntryZ);
return (lone ? "orfana-bot" : "basket", o.BasketId, o.Basket, b.ExitReason);
}
return ("orfana-bot", o.BasketId, o.Basket, string.Empty);
}
// Before orders.jsonl existed (4.0.0) the only trace is a lone-leg row in baskets.csv whose motivation carries the position id.
foreach (BasketOutcomeRow b in baskets)
{
if (b.Motivazione.Contains(positionId.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
{
return ("orfana-bot", b.BasketId, b.Basket, b.ExitReason);
}
}
return ("esterna", string.Empty, string.Empty, string.Empty);
}
/// <summary>The standard periods of the Storico page, computed on the closed positions of the bot (cash movements apart).</summary>
public static List<PeriodStats> Periods(IReadOnlyList<PositionRecord> positions, DateTime nowUtc, (DateTime From, DateTime To)? custom = null)
{
ArgumentNullException.ThrowIfNull(positions);
DateTime today = nowUtc.Date;
DateTime monthStart = new(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime previousMonthStart = monthStart.AddMonths(-1);
DateTime yearStart = new(nowUtc.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
DateTime first = positions.Count > 0 ? positions.Min(static p => p.OpenedUtc) : today;
List<(string, DateTime, DateTime)> periods =
[
("oggi", today, today.AddDays(1)),
("ieri", today.AddDays(-1), today),
("7 giorni", nowUtc.AddDays(-7), nowUtc.AddSeconds(1)),
("30 giorni", nowUtc.AddDays(-30), nowUtc.AddSeconds(1)),
("mese corrente", monthStart, monthStart.AddMonths(1)),
("mese precedente", previousMonthStart, monthStart),
("anno", yearStart, yearStart.AddYears(1)),
("tutto", first < today ? first.Date : today.AddDays(-1), nowUtc.AddSeconds(1)),
];
if (custom is { } c)
{
periods.Add(("personalizzato", c.From, c.To));
}
return [.. periods.Select(p => Period(p.Item1, p.Item2, p.Item3, positions))];
}
public static PeriodStats Period(string label, DateTime fromUtc, DateTime toUtc, IReadOnlyList<PositionRecord> positions)
{
ArgumentNullException.ThrowIfNull(positions);
List<PositionRecord> closed = [.. positions.Where(p => !p.IsOpen && p.Origin != "movimento di cassa" && p.Origin != "esterna" && p.ClosedUtc is { } t && t >= fromUtc && t < toUtc).OrderBy(static p => p.ClosedUtc)];
double cash = positions.Where(p => p.Origin == "movimento di cassa" && p.OpenedUtc >= fromUtc && p.OpenedUtc < toUtc).Sum(static p => p.PnlNetUsd);
HashSet<string> basketIds = [.. closed.Where(static p => p.Origin == "basket" && p.BasketId.Length > 0).Select(static p => p.BasketId)];
int nBasket = basketIds.Count + closed.Count(static p => p.Origin != "basket" || p.BasketId.Length == 0);
// Wins and losses per basket (both legs together), per lone leg otherwise.
Dictionary<string, double> perUnit = new(StringComparer.Ordinal);
foreach (PositionRecord p in closed)
{
string key = p.Origin == "basket" && p.BasketId.Length > 0 ? p.BasketId : "#" + p.PositionId.ToString(CultureInfo.InvariantCulture);
perUnit[key] = perUnit.GetValueOrDefault(key) + p.PnlNetUsd;
}
int won = perUnit.Values.Count(static v => v > 0);
int lost = perUnit.Values.Count(static v => v <= 0);
double gross = closed.Sum(static p => p.PnlGrossUsd);
double fees = closed.Sum(static p => p.FeesUsd);
double net = closed.Sum(static p => p.PnlNetUsd);
double maxDd = MaxDrawdown(EquityCurve(closed));
string why = closed.Count == 0
? "nessuna posizione chiusa nel periodo"
: string.Create(CultureInfo.InvariantCulture, $"{closed.Count} posizioni in {perUnit.Count} unità, realizzato dallo storico eToro, fee incluse nel netto{(cash != 0 ? $"; movimenti di cassa {cash:+0.00;-0.00} esclusi dal P&L" : string.Empty)}");
return new PeriodStats(label, fromUtc, toUtc, nBasket, closed.Count, won, lost, perUnit.Count > 0 ? (double)won / perUnit.Count : double.NaN,
gross, fees, net, perUnit.Count > 0 ? net / perUnit.Count : double.NaN, maxDd, cash, why);
}
/// <summary>The cumulative realised net P&amp;L of the closed positions, in closing order.</summary>
public static List<EquityPoint> EquityCurve(IEnumerable<PositionRecord> closed)
{
ArgumentNullException.ThrowIfNull(closed);
double sum = 0;
List<EquityPoint> points = [];
foreach (PositionRecord p in closed.Where(static p => !p.IsOpen && p.Origin != "movimento di cassa" && p.ClosedUtc is not null).OrderBy(static p => p.ClosedUtc))
{
sum += p.PnlNetUsd;
points.Add(new EquityPoint(p.ClosedUtc!.Value, sum));
}
return points;
}
public static double MaxDrawdown(IReadOnlyList<EquityPoint> curve)
{
ArgumentNullException.ThrowIfNull(curve);
double peak = 0, dd = 0;
foreach (EquityPoint p in curve)
{
peak = Math.Max(peak, p.CumulativeNetUsd);
dd = Math.Max(dd, peak - p.CumulativeNetUsd);
}
return dd;
}
/// <summary>A small SVG of the equity curve, no library: a polyline in a viewBox, with the zero line.</summary>
public static string EquitySvg(IReadOnlyList<EquityPoint> curve, int width = 640, int height = 160)
{
ArgumentNullException.ThrowIfNull(curve);
StringBuilder sb = new();
sb.Append(CultureInfo.InvariantCulture, $"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" role=\"img\" aria-label=\"curva dell'equity realizzata\">");
if (curve.Count >= 2)
{
double min = Math.Min(0, curve.Min(static p => p.CumulativeNetUsd));
double max = Math.Max(0, curve.Max(static p => p.CumulativeNetUsd));
double span = max - min < 1e-9 ? 1 : max - min;
long t0 = curve[0].TimeUtc.Ticks, t1 = curve[^1].TimeUtc.Ticks;
double tspan = t1 - t0 <= 0 ? 1 : t1 - t0;
double zeroY = height - 8 - ((0 - min) / span * (height - 16));
sb.Append(CultureInfo.InvariantCulture, $"<line x1=\"0\" y1=\"{zeroY:0.#}\" x2=\"{width}\" y2=\"{zeroY:0.#}\" stroke=\"currentColor\" stroke-opacity=\"0.25\" stroke-dasharray=\"4 4\"/>");
sb.Append("<polyline fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" points=\"");
foreach (EquityPoint p in curve)
{
double x = (p.TimeUtc.Ticks - t0) / tspan * (width - 4) + 2;
double y = height - 8 - ((p.CumulativeNetUsd - min) / span * (height - 16));
sb.Append(CultureInfo.InvariantCulture, $"{x:0.#},{y:0.#} ");
}
sb.Append("\"/>");
}
else
{
sb.Append(CultureInfo.InvariantCulture, $"<text x=\"{width / 2}\" y=\"{height / 2}\" text-anchor=\"middle\" fill=\"currentColor\" fill-opacity=\"0.6\" font-size=\"13\">nessuna posizione chiusa</text>");
}
sb.Append("</svg>");
return sb.ToString();
}
public static string PositionsCsv(IEnumerable<PositionRecord> rows)
{
ArgumentNullException.ThrowIfNull(rows);
StringBuilder sb = new();
sb.AppendLine(PositionRecord.Header);
foreach (PositionRecord r in rows)
{
sb.AppendLine(r.ToCsv());
}
return sb.ToString();
}
public static string PeriodsCsv(IEnumerable<PeriodStats> rows)
{
ArgumentNullException.ThrowIfNull(rows);
StringBuilder sb = new();
sb.AppendLine(PeriodStats.Header);
foreach (PeriodStats r in rows)
{
sb.AppendLine(r.ToCsv());
}
return sb.ToString();
}
/// <summary>The order lines as the Storico page lists them: the last state of every order, newest first.</summary>
public static List<OrderRecord> LatestOrders(IReadOnlyList<OrderRecord> lines)
{
ArgumentNullException.ThrowIfNull(lines);
Dictionary<string, OrderRecord> last = new(StringComparer.Ordinal);
Dictionary<string, DateTime> sent = new(StringComparer.Ordinal);
foreach (OrderRecord o in lines)
{
if (!sent.ContainsKey(o.ClientRef))
{
sent[o.ClientRef] = o.Ts;
}
last[o.ClientRef] = o;
}
return [.. last.Values.OrderByDescending(o => sent[o.ClientRef])];
}
public const string OrdersHeader = "ts;basket_id;basket;strumento;verso;leg;unita_richieste;unita_eseguite;prezzo_richiesto;prezzo_eseguito;slippage_pip;stato;esito;order_id;position_id;fee;motivazione";
public static string OrdersCsv(IEnumerable<OrderRecord> rows)
{
ArgumentNullException.ThrowIfNull(rows);
StringBuilder sb = new();
sb.AppendLine(OrdersHeader);
foreach (OrderRecord o in rows)
{
sb.AppendLine(string.Join(';',
[
o.Ts.ToString("O", CultureInfo.InvariantCulture), o.BasketId, o.Basket, o.Symbol, o.IsBuy ? "long" : "short", o.Leg.ToString(),
N(o.RequestedUnits), N(o.ExecutedUnits), N(o.RequestedPrice), N(o.FillRate), N(o.SlippagePips), o.Status, o.Resolution.ToString(),
o.OrderId.ToString(CultureInfo.InvariantCulture), o.PositionId.ToString(CultureInfo.InvariantCulture), N(o.Fees), o.Motivazione.Replace(';', ',').Replace('\n', ' '),
]));
}
return sb.ToString();
static string N(double v) => double.IsFinite(v) ? v.ToString("0.######", CultureInfo.InvariantCulture) : string.Empty;
}
}
@@ -39,8 +39,6 @@ public sealed class RollingStandardizer
_alpha = 1 - Math.Pow(0.5, 1.0 / Math.Max(1, halfLifeRows)); _alpha = 1 - Math.Pow(0.5, 1.0 / Math.Max(1, halfLifeRows));
} }
public int Size => _mean.Length;
public long Count => _n; public long Count => _n;
/// <summary>Standardises a row with the statistics seen so far, then updates them. NaN inputs become 0 (the mean).</summary> /// <summary>Standardises a row with the statistics seen so far, then updates them. NaN inputs become 0 (the mean).</summary>
@@ -185,10 +183,6 @@ public sealed class OnlineLogistic : IModel
public int Seen => _seen; 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> /// <summary>Learning rate with a slow decay: <c>lr₀ / (1 + n/1000)</c>.</summary>
private double LearningRate => _lr0 / (1 + (_seen / 1000.0)); private double LearningRate => _lr0 / (1 + (_seen / 1000.0));
@@ -209,38 +209,3 @@ public sealed class VolForecaster
} }
} }
/// <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;
}
}
@@ -242,31 +242,6 @@ public sealed class OrderTracker
return resolved; return resolved;
} }
public TrackedOrder? Find(string clientRef)
{
lock (_gate)
{
return _orders.GetValueOrDefault(clientRef);
}
}
public TrackedOrder? FindByOrderId(long orderId)
{
lock (_gate)
{
return orderId > 0 ? _orders.Values.FirstOrDefault(o => o.OrderId == orderId) : null;
}
}
/// <summary>The order that opened this position, when the bot sent it.</summary>
public TrackedOrder? FindByPosition(long positionId)
{
lock (_gate)
{
return positionId > 0 ? _orders.Values.FirstOrDefault(o => o.PositionId == positionId && o.Leg is not (OrderLeg.Close or OrderLeg.Unwind)) : null;
}
}
/// <summary>Positions the register knows the bot opened, with the basket they belong to.</summary> /// <summary>Positions the register knows the bot opened, with the basket they belong to.</summary>
public IReadOnlyDictionary<long, string> OpenedPositions() public IReadOnlyDictionary<long, string> OpenedPositions()
{ {
@@ -21,13 +21,8 @@ public static class PipMath
public static int Digits(string symbol) => public static int Digits(string symbol) =>
symbol.EndsWith("JPY", StringComparison.OrdinalIgnoreCase) ? 3 : 5; 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(); 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> /// <summary>
/// USD value of one pip for <paramref name="units"/> of <paramref name="symbol"/>. /// 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). /// <paramref name="mid"/> resolves a pair to its mid price (null when unknown).
@@ -85,5 +80,4 @@ public static class PipMath
return f is { } factor ? units * price * factor : double.NaN; 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);
} }
@@ -214,9 +214,6 @@ public sealed class SymbolSeries
return closed; 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 private sealed class BarBuilder
{ {
public readonly long Bucket; public readonly long Bucket;
@@ -34,8 +34,6 @@ public sealed record Instrument(
double MaxStopLossPct, double MaxStopLossPct,
string Notes) string Notes)
{ {
public string Base => Symbol.Length >= 6 ? Symbol[..3] : string.Empty;
public string Quote => Symbol.Length >= 6 ? Symbol[3..6] : string.Empty; public string Quote => Symbol.Length >= 6 ? Symbol[3..6] : string.Empty;
public double RoundPrice(double price) => Math.Round(price, Digits); public double RoundPrice(double price) => Math.Round(price, Digits);
@@ -152,7 +150,6 @@ public sealed record CostEstimate(
double OverWeekend, double OverWeekend,
DateTime TimeUtc) DateTime TimeUtc)
{ {
public double EntryCost => Markup + MarketSpread + TransactionFee;
} }
/// <summary>A closed trade from the broker's history, for realised P&amp;L and slippage.</summary> /// <summary>A closed trade from the broker's history, for realised P&amp;L and slippage.</summary>
@@ -246,13 +246,6 @@ public sealed class SentimentEngine
return weights > 0 ? weighted / weights : 0; return weights > 0 ? weighted / weights : 0;
} }
public IReadOnlyList<ScoredItem> Recent(int count)
{
lock (_gate)
{
return [.. _items.OrderByDescending(static i => i.Item.PublishedUtc).Take(count)];
}
}
} }
/// <summary>Calendar features for one set of currencies at one instant.</summary> /// <summary>Calendar features for one set of currencies at one instant.</summary>
@@ -31,34 +31,9 @@ public sealed class OlsFit
public int Parameters => Coefficients.Length; public int Parameters => Coefficients.Length;
public int DegreesOfFreedom => Observations - Parameters;
public double RSquared => public double RSquared =>
TotalSumOfSquares > 0 ? 1 - (ResidualSumOfSquares / TotalSumOfSquares) : 0; TotalSumOfSquares > 0 ? 1 - (ResidualSumOfSquares / TotalSumOfSquares) : 0;
/// <summary>
/// The t-statistic of one coefficient: how many standard errors it sits away from
/// zero. This is the number the Dickey-Fuller test is built on.
/// </summary>
public double TStatistic(int index) =>
(uint)index < (uint)Coefficients.Length && StandardErrors[index] > 0
? Coefficients[index] / StandardErrors[index]
: double.NaN;
/// <summary>Akaike information criterion, used to pick the lag order of an ADF regression.</summary>
public double Aic
{
get
{
if (Observations <= 0 || ResidualSumOfSquares <= 0)
{
return double.PositiveInfinity;
}
double sigmaSquared = ResidualSumOfSquares / Observations;
return (Observations * Math.Log(sigmaSquared)) + (2 * Parameters);
}
}
} }
/// <summary> /// <summary>
@@ -106,23 +106,6 @@ public static class Performance
// Returns and ratios // Returns and ratios
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary>Log return between two prices; zero when either is unusable.</summary>
public static double LogReturn(double previous, double current) =>
previous > 0 && current > 0 ? Math.Log(current / previous) : 0;
/// <summary>Realised volatility of a window of returns: <c>sqrt(Σ r²)</c>, un-annualised.</summary>
public static double RealizedVolatility(IReadOnlyList<double> returns)
{
ArgumentNullException.ThrowIfNull(returns);
double sum = 0;
foreach (double r in returns)
{
sum += r * r;
}
return Math.Sqrt(sum);
}
/// <summary>Per-period Sharpe ratio: mean excess return over its standard deviation.</summary> /// <summary>Per-period Sharpe ratio: mean excess return over its standard deviation.</summary>
public static double Sharpe(IReadOnlyList<double> returns, double riskFreePerPeriod = 0) public static double Sharpe(IReadOnlyList<double> returns, double riskFreePerPeriod = 0)
{ {
@@ -135,26 +118,6 @@ public static class Performance
public static double Annualise(double sharpePerPeriod, double periodsPerYear) => public static double Annualise(double sharpePerPeriod, double periodsPerYear) =>
sharpePerPeriod * Math.Sqrt(Math.Max(0, periodsPerYear)); sharpePerPeriod * Math.Sqrt(Math.Max(0, periodsPerYear));
/// <summary>Sortino: mean return over the deviation of the negative returns only.</summary>
public static double Sortino(IReadOnlyList<double> returns, double target = 0)
{
ArgumentNullException.ThrowIfNull(returns);
if (returns.Count < 2)
{
return 0;
}
double sum = 0;
foreach (double r in returns)
{
double shortfall = Math.Min(0, r - target);
sum += shortfall * shortfall;
}
double downside = Math.Sqrt(sum / returns.Count);
return downside > 0 ? (Mean(returns) - target) / downside : 0;
}
/// <summary>Worst peak-to-trough fall of an equity curve, as a fraction of the peak.</summary> /// <summary>Worst peak-to-trough fall of an equity curve, as a fraction of the peak.</summary>
public static double MaxDrawdown(IReadOnlyList<double> equity) public static double MaxDrawdown(IReadOnlyList<double> equity)
{ {
@@ -182,14 +145,6 @@ public static class Performance
return worst; return worst;
} }
public static double Cagr(double startEquity, double endEquity, double years) =>
startEquity > 0 && endEquity > 0 && years > 0
? Math.Pow(endEquity / startEquity, 1 / years) - 1
: 0;
public static double Calmar(double cagr, double maxDrawdown) =>
maxDrawdown > 1e-12 ? cagr / maxDrawdown : 0;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Probabilistic and deflated Sharpe // Probabilistic and deflated Sharpe
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -273,47 +228,4 @@ public static class Performance
return Math.Max(0, winRate - ((1 - winRate) / winLossRatio)); return Math.Max(0, winRate - ((1 - winRate) / winLossRatio));
} }
/// <summary>The same in betting terms: <c>f* = (b·p q)/b</c>.</summary>
public static double KellyFromOdds(double probability, double odds) =>
Kelly(probability, odds);
/// <summary>Continuous Kelly: <c>f* = (μ r)/σ²</c>.</summary>
public static double KellyContinuous(double meanReturn, double variance, double riskFree = 0) =>
variance > 0 ? (meanReturn - riskFree) / variance : 0;
/// <summary>
/// Volatility targeting: the exposure that makes the realised volatility land on the
/// target, capped by the maximum leverage. Zero realised volatility means zero size,
/// not infinite.
/// </summary>
public static double VolatilityTargetSize(
double targetVolatility, double realizedVolatility, double capital, double maxLeverage)
{
if (realizedVolatility <= 0 || targetVolatility <= 0 || capital <= 0)
{
return 0;
}
double size = targetVolatility / realizedVolatility * capital;
return Math.Min(size, capital * Math.Max(0, maxLeverage));
}
/// <summary>Expected value after costs: <c>p·win (1p)·loss costs</c>. Below zero, do not enter.</summary>
public static double NetExpectedValue(double probability, double averageWin, double averageLoss, double costs) =>
(probability * averageWin) - ((1 - probability) * averageLoss) - costs;
/// <summary>
/// Bet size from a predicted probability (López de Prado): <c>2·Φ(z) 1</c> with
/// <c>z = (p ½)/√(p(1p))</c>. A coin flip sizes to nothing; certainty to one.
/// </summary>
public static double BetSizeFromProbability(double probability)
{
if (probability is <= 0 or >= 1 || !double.IsFinite(probability))
{
return probability >= 1 ? 1 : 0;
}
double z = (probability - 0.5) / Math.Sqrt(probability * (1 - probability));
return Math.Clamp((2 * Normal.Cdf(z)) - 1, -1, 1);
}
} }
@@ -1,12 +1,13 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using Encelado.Bot.Engine; using Encelado.Engine;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Baskets.History;
using Encelado.Core.Broker; using Encelado.Core.Broker;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary>Commands from the window, the console or Telegram, executed on the engine's own thread and written to the ledger.</summary> /// <summary>Commands from the window, the console or Telegram, executed on the engine's own thread and written to the ledger.</summary>
public sealed partial class BasketEngine public sealed partial class BasketEngine
@@ -1,12 +1,12 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using Encelado.Bot.Engine; using Encelado.Engine;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Broker; using Encelado.Core.Broker;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// The kill-switch that closes for real (§9 of the 5.0 plan) and the guided reset. Both /// The kill-switch that closes for real (§9 of the 5.0 plan) and the guided reset. Both
@@ -1,10 +1,11 @@
using System.Globalization; using System.Globalization;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Baskets.History;
using Encelado.Core.Broker; using Encelado.Core.Broker;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// The order register at work (§5.1-5.3 of the 5.0 plan): every second the pending /// The order register at work (§5.1-5.3 of the 5.0 plan): every second the pending
@@ -1,11 +1,12 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Baskets.History;
using Encelado.Core.Broker; using Encelado.Core.Broker;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// Account, reconciliation and safety. Every twenty seconds the account and the /// Account, reconciliation and safety. Every twenty seconds the account and the
@@ -1,10 +1,10 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// Heartbeat, instance lock and the recovery after an inactivity (§6 of the 5.0 plan): /// Heartbeat, instance lock and the recovery after an inactivity (§6 of the 5.0 plan):
@@ -1,13 +1,13 @@
using System.Globalization; using System.Globalization;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using Encelado.Bot.Configuration; using Encelado.Engine.Configuration;
using Encelado.Bot.Engine; using Encelado.Engine;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Etoro; using Encelado.Etoro;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary>The picture the window and the console render.</summary> /// <summary>The picture the window and the console render.</summary>
public sealed partial class BasketEngine public sealed partial class BasketEngine
@@ -38,6 +38,11 @@ public sealed partial class BasketEngine
List<QuoteRow> quotes = new(_series.Count); List<QuoteRow> quotes = new(_series.Count);
foreach (SymbolSeries s in _series.Values) foreach (SymbolSeries s in _series.Values)
{ {
if (_conversionOnly.Contains(s.Symbol))
{
continue;
}
quotes.Add(new QuoteRow(s.Symbol, s.HasQuote ? s.Quote.Bid : 0, s.HasQuote ? s.Quote.Ask : 0, s.SpreadPips, s.HasQuote ? s.Quote.TimeUtc : default, quotes.Add(new QuoteRow(s.Symbol, s.HasQuote ? s.Quote.Bid : 0, s.HasQuote ? s.Quote.Ask : 0, s.SpreadPips, s.HasQuote ? s.Quote.TimeUtc : default,
s.QuoteSeenUtc == default ? -1 : (now - s.QuoteSeenUtc).TotalSeconds)); s.QuoteSeenUtc == default ? -1 : (now - s.QuoteSeenUtc).TotalSeconds));
} }
@@ -85,7 +90,10 @@ public sealed partial class BasketEngine
EquityStopped = _equityStopped, EquityStopped = _equityStopped,
KillSwitched = _killSwitched, KillSwitched = _killSwitched,
EntriesBlockedReason = _entriesBlocked, EntriesBlockedReason = _entriesBlocked,
Counters = string.Create(CultureInfo.InvariantCulture, $"quote/min {_feed.QuotaUsed(EtoroQuota.MarketData)}/110 · ordini/min {_feed.QuotaUsed(EtoroQuota.Trading)}/18 · esiti/min {_feed.QuotaUsed(EtoroQuota.Lookup)}/55 · ultima quotazione {(_lastQuoteUtc == default ? "" : (now - _lastQuoteUtc).TotalSeconds.ToString("0") + " s fa")} · {_notifier.Status}"), Counters = string.Create(CultureInfo.InvariantCulture, $"quote/min {_feed.QuotaUsed(EtoroQuota.MarketData)}/110 · ordini/min {_feed.QuotaUsed(EtoroQuota.Trading)}/18 · esiti/min {_feed.QuotaUsed(EtoroQuota.Lookup)}/55 · ultima quotazione {(_lastQuoteUtc == default ? "" : (now - _lastQuoteUtc).TotalSeconds.ToString("0") + " s fa")}"),
NotifierStatus = _notifier.Status,
DisplayCurrency = _config.Ui.DisplayCurrency,
FxRates = FxRates(),
Events = events, Events = events,
Baskets = rows, Baskets = rows,
Quotes = quotes, Quotes = quotes,
@@ -93,6 +101,32 @@ public sealed partial class BasketEngine
}; };
} }
/// <summary>USD per unit of every display currency the polled quotes can price (mid of the moment).</summary>
private List<FxRate> FxRates()
{
List<FxRate> rates = [new FxRate("USD", 1, DateTime.UtcNow)];
Add("EUR", "EURUSD", false);
Add("GBP", "GBPUSD", false);
Add("AUD", "AUDUSD", false);
Add("NZD", "NZDUSD", false);
Add("CHF", "USDCHF", true);
Add("JPY", "USDJPY", true);
Add("CAD", "USDCAD", true);
return rates;
void Add(string currency, string pair, bool invert)
{
if (_series.TryGetValue(pair, out SymbolSeries? s) && s.HasQuote && s.Mid > 0)
{
rates.Add(new FxRate(currency, invert ? 1 / s.Mid : s.Mid, s.Quote.TimeUtc));
}
else if (s is { Count: > 0 })
{
rates.Add(new FxRate(currency, invert ? 1 / s.Last.MidClose : s.Last.MidClose, s.LastTimeUtc));
}
}
}
private ContextRow ContextWithLearning(DateTime now) private ContextRow ContextWithLearning(DateTime now)
{ {
ContextRow row = _context.Row(now); ContextRow row = _context.Row(now);
@@ -135,6 +169,7 @@ public sealed partial class BasketEngine
Endpoint = config.Etoro.BaseUrl, Endpoint = config.Etoro.BaseUrl,
Preset = "—", Preset = "—",
ApiState = "fermo", ApiState = "fermo",
DisplayCurrency = config.Ui.DisplayCurrency,
Events = events, Events = events,
Baskets = rows, Baskets = rows,
Context = new ContextRow([], [], "—", "—", "—", "—", "—"), Context = new ContextRow([], [], "—", "—", "—", "—", "—"),
@@ -1,10 +1,10 @@
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Encelado.Bot.Engine; using Encelado.Engine;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// The state on disk (<c>data/state/baskets_state.json</c>): open baskets, entries in /// The state on disk (<c>data/state/baskets_state.json</c>): open baskets, entries in
@@ -3,9 +3,9 @@ using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Encelado.Bot.Configuration; using Encelado.Engine.Configuration;
using Encelado.Bot.Engine; using Encelado.Engine;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Baskets.Data; using Encelado.Core.Baskets.Data;
using Encelado.Core.Baskets.History; using Encelado.Core.Baskets.History;
@@ -14,7 +14,7 @@ using Encelado.Core.Broker;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
using Encelado.Etoro; using Encelado.Etoro;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary>What the engine does with a position that carries the bot's signature but belongs to no basket.</summary> /// <summary>What the engine does with a position that carries the bot's signature but belongs to no basket.</summary>
public enum OrphanPolicy public enum OrphanPolicy
@@ -85,6 +85,7 @@ public sealed partial class BasketEngine : IEngine
private readonly Dictionary<long, SymbolSeries> _seriesById = []; private readonly Dictionary<long, SymbolSeries> _seriesById = [];
private readonly Dictionary<long, (DateTime At, CostEstimate Cost, double Units)> _costs = []; private readonly Dictionary<long, (DateTime At, CostEstimate Cost, double Units)> _costs = [];
private readonly List<BasketSlot> _slots = []; private readonly List<BasketSlot> _slots = [];
private readonly HashSet<string> _conversionOnly = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<long> _foreignPositions = []; private readonly HashSet<long> _foreignPositions = [];
private readonly HashSet<long> _knownPositions = []; private readonly HashSet<long> _knownPositions = [];
@@ -195,7 +196,7 @@ public sealed partial class BasketEngine : IEngine
string strategyPath = config.Run.StrategyPath; string strategyPath = config.Run.StrategyPath;
if (!File.Exists(strategyPath)) if (!File.Exists(strategyPath))
{ {
App.SeedStrategyFile(strategyPath); AppPaths.SeedStrategyFile(strategyPath);
} }
_strategy = BasketStrategyConfig.Load(strategyPath, out List<string> warnings); _strategy = BasketStrategyConfig.Load(strategyPath, out List<string> warnings);
@@ -209,7 +210,7 @@ public sealed partial class BasketEngine : IEngine
_configHash = _strategy.Hash(); _configHash = _strategy.Hash();
_runId = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N")[..6]}"); _runId = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N")[..6]}");
EtoroKeyStore.Resolve(config, out string origin); KeyStores.Resolve(config, out string origin);
Log.Info($"chiavi eToro: {origin}"); Log.Info($"chiavi eToro: {origin}");
_feed = new EtoroBroker(config.Etoro) _feed = new EtoroBroker(config.Etoro)
@@ -358,9 +359,21 @@ public sealed partial class BasketEngine : IEngine
} }
} }
/// <summary>The pairs that price the display currencies against USD (D-33): polled with the rest, never traded.</summary>
private static readonly string[] ConversionPairs = ["EURUSD", "GBPUSD", "USDCHF", "USDJPY", "AUDUSD", "USDCAD", "NZDUSD"];
private async Task LoadInstrumentsAsync(CancellationToken ct) private async Task LoadInstrumentsAsync(CancellationToken ct)
{ {
List<string> symbols = _strategy.Symbols(_strategy.PreferDirectCross); List<string> symbols = _strategy.Symbols(_strategy.PreferDirectCross);
foreach (string extra in ConversionPairs)
{
if (!symbols.Contains(extra, StringComparer.OrdinalIgnoreCase))
{
symbols.Add(extra);
_conversionOnly.Add(extra);
}
}
IReadOnlyList<Instrument> instruments = await _broker.GetInstrumentsAsync(symbols, ct).ConfigureAwait(false); IReadOnlyList<Instrument> instruments = await _broker.GetInstrumentsAsync(symbols, ct).ConfigureAwait(false);
Dictionary<string, Instrument> bySymbol = instruments.ToDictionary(static i => i.Symbol, StringComparer.OrdinalIgnoreCase); Dictionary<string, Instrument> bySymbol = instruments.ToDictionary(static i => i.Symbol, StringComparer.OrdinalIgnoreCase);
@@ -368,10 +381,15 @@ public sealed partial class BasketEngine : IEngine
{ {
if (bySymbol.TryGetValue(s, out Instrument? i)) if (bySymbol.TryGetValue(s, out Instrument? i))
{ {
SymbolSeries series = new(i, TimeSpan.FromMinutes(15), Math.Max(2000, WarmupBars * 2)); bool conversion = _conversionOnly.Contains(s);
SymbolSeries series = new(i, TimeSpan.FromMinutes(15), conversion ? 500 : Math.Max(2000, WarmupBars * 2));
_series[s] = series; _series[s] = series;
_seriesById[i.Id] = series; _seriesById[i.Id] = series;
Log.Info($"[{s}] id {i.Id}: {i.Notes}"); Log.Info($"[{s}] id {i.Id}: {(conversion ? "solo per la conversione della valuta" : i.Notes)}");
}
else if (_conversionOnly.Contains(s))
{
Log.Warn($"[{s}] non quotato da eToro: la valuta che prezza resta senza tasso di conversione");
} }
else else
{ {
@@ -462,6 +480,11 @@ public sealed partial class BasketEngine : IEngine
int want = WarmupBars; int want = WarmupBars;
foreach (SymbolSeries s in _series.Values) foreach (SymbolSeries s in _series.Values)
{ {
if (_conversionOnly.Contains(s.Symbol))
{
continue;
}
string path = Path.Combine(_marketDir, BidAskBarCsv.FileName(s.Symbol)); string path = Path.Combine(_marketDir, BidAskBarCsv.FileName(s.Symbol));
List<BidAskBar> local = BidAskBarCsv.Read(path); List<BidAskBar> local = BidAskBarCsv.Read(path);
if (local.Count > want * 2) if (local.Count > want * 2)
@@ -774,7 +797,7 @@ public sealed partial class BasketEngine : IEngine
private void OnBarClosed(SymbolSeries s, in BidAskBar bar) private void OnBarClosed(SymbolSeries s, in BidAskBar bar)
{ {
if (!s.Append(bar)) if (!s.Append(bar) || _conversionOnly.Contains(s.Symbol))
{ {
return; return;
} }
@@ -1,6 +1,6 @@
using Encelado.Bot.Engine; using Encelado.Engine;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary>Calendar and sentiment features for one basket at one instant (§7).</summary> /// <summary>Calendar and sentiment features for one basket at one instant (§7).</summary>
public sealed record BasketContextFeatures( public sealed record BasketContextFeatures(
@@ -37,16 +37,3 @@ public interface IContextProvider
Task RefreshAsync(DateTime nowUtc, CancellationToken ct); 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;
}
@@ -1,11 +1,11 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Encelado.Bot.Engine; using Encelado.Engine;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.News; using Encelado.Core.News;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary>One feed: where it lives, what it is, how often it may be asked.</summary> /// <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 sealed record FeedSource(string Name, Uri Url, string Kind, string Currency = "")
@@ -1,175 +1,25 @@
using System.Globalization; using System.Globalization;
using System.Runtime.InteropServices; using Encelado.Engine.Configuration;
using Encelado.Bot.Configuration; using Encelado.Engine;
using Encelado.Bot.Engine; using Encelado.Engine.Logging;
using Encelado.Bot.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// The bot without a window: the same supervisor and engine, the log on the console, /// The console side of the server: a status line on standard output, commands from
/// a status line every minute and commands from standard input. For a VPS, a service, /// standard input and the interactive bonifica. The lifecycle (configuration, web host,
/// or a long unattended test. /// engine start, shutdown) lives in <c>Encelado.Server.Program</c>.
/// <para> /// <para>
/// Commands: <c>status</c>, <c>close &lt;basket&gt;</c>, <c>kill</c>, <c>preset &lt;nome&gt;</c>, /// Commands: <c>status</c>, <c>close &lt;basket&gt;</c>, <c>kill</c>, <c>residuo [id]</c>,
/// <c>reset &lt;motivazione&gt;</c>, <c>bonifica</c>, <c>stop</c>. Arguments: <c>--headless</c>, /// <c>preset &lt;nome&gt;</c>, <c>reset &lt;motivazione&gt;</c>, <c>bonifica</c>, <c>stop</c>.
/// <c>--confirm-live "CONFERMO LIVE"</c>, <c>--minutes N</c> (stop by itself after N minutes),
/// <c>--bonifica</c> (start without closing orphans on its own; list them and ask, one by one).
/// </para> /// </para>
/// </summary> /// </summary>
public static class HeadlessRunner public static class HeadlessRunner
{ {
[DllImport("kernel32.dll", SetLastError = true)] /// <summary>Commands from standard input until <c>stop</c> or the token; a process without a console just waits.</summary>
private static extern bool AttachConsole(int processId); public static async Task ReadCommandsAsync(BotSupervisor supervisor, CancellationTokenSource stopping)
[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;
}
}
bool bonifica = args.Any(static a => a.Equals("--bonifica", StringComparison.OrdinalIgnoreCase));
INotifier notifier = Notifications.Create(config);
await using BotSupervisor supervisor = new(config, (c, confirmed) => new BasketEngine(c, confirmed, null, notifier) { OrphanPolicy = bonifica ? OrphanPolicy.Report : OrphanPolicy.Close }, notifier) { StartConfirmed = true };
if (notifier is TelegramNotifier telegram)
{
TelegramCommands commands = new(supervisor, () => notifier.Status);
telegram.CommandHandler = commands.HandleAsync;
telegram.StartCommands();
}
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, residuo [id], preset <nome>, reset <motivazione>, bonifica, stop");
if (minutes > 0)
{
Log.Info($"arresto automatico fra {minutes} minuti");
stopping.CancelAfter(TimeSpan.FromMinutes(minutes));
}
if (bonifica)
{
await BonificaAsync(supervisor, stopping.Token).ConfigureAwait(false);
}
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);
if (notifier is IAsyncDisposable disposable)
{
await disposable.DisposeAsync().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) while (!stopping.IsCancellationRequested)
{ {
@@ -263,7 +113,7 @@ public static class HeadlessRunner
/// records it; foreign positions are never touched here. Ends by handing orphan /// records it; foreign positions are never touched here. Ends by handing orphan
/// handling back to the engine. /// handling back to the engine.
/// </summary> /// </summary>
private static async Task BonificaAsync(BotSupervisor supervisor, CancellationToken ct) public static async Task BonificaAsync(BotSupervisor supervisor, CancellationToken ct)
{ {
CommandResult list = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Bonifica, "list"), ct).ConfigureAwait(false); CommandResult list = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Bonifica, "list"), ct).ConfigureAwait(false);
Console.WriteLine(); Console.WriteLine();
@@ -315,7 +165,7 @@ public static class HeadlessRunner
Console.WriteLine($"── {done.Message}: rapporto in reports/bonifica_{DateTime.UtcNow:yyyyMMdd}.csv"); Console.WriteLine($"── {done.Message}: rapporto in reports/bonifica_{DateTime.UtcNow:yyyyMMdd}.csv");
} }
private static void PrintStatus(BotSnapshot s) public static void PrintStatus(BotSnapshot s)
{ {
Console.WriteLine(); Console.WriteLine();
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
@@ -0,0 +1,188 @@
using System.Globalization;
using System.Text.Json;
using Encelado.Core.Baskets;
using Encelado.Core.Baskets.History;
using Encelado.Core.Broker;
using Encelado.Engine.Configuration;
using Encelado.Engine.Logging;
using Encelado.Etoro;
namespace Encelado.Engine.Baskets;
/// <summary>
/// The sources of the Storico page, gathered and cached: the bot's own ledger files
/// (<c>orders.jsonl</c>, <c>baskets.csv</c>, the cash movements of <c>decisions.jsonl</c>)
/// and the venue's history and open positions, read with the configured keys through
/// a read-only broker so the page works whether the engine runs or not.
/// </summary>
public sealed class HistoryService(BotConfig config) : IAsyncDisposable
{
private static readonly TimeSpan CacheFor = TimeSpan.FromSeconds(30);
private readonly Lock _gate = new();
private EtoroBroker? _broker;
private Dictionary<long, string> _symbols = [];
private (DateTime At, List<PositionRecord> Positions, List<OrderRecord> Orders)? _cache;
private string _lastProblem = string.Empty;
public string LedgerDirectory => Path.Combine(config.Run.DataPath, "ledger");
/// <summary>What went wrong at the last read (the venue unreachable, no keys), or empty.</summary>
public string LastProblem => _lastProblem;
public async Task<(List<PositionRecord> Positions, List<OrderRecord> Orders)> LoadAsync(DateTime sinceUtc, CancellationToken ct)
{
lock (_gate)
{
if (_cache is { } c && DateTime.UtcNow - c.At < CacheFor)
{
return (c.Positions, c.Orders);
}
}
List<OrderRecord> orders = ReadOrders();
List<BasketOutcomeRow> baskets = ReadBaskets();
List<CashMovementRecord> cash = ReadCashMovements();
IReadOnlyList<ClosedTrade> closed = [];
IReadOnlyList<BrokerPosition> open = [];
try
{
EtoroBroker broker = Broker();
if (broker.SupportsTrading)
{
if (_symbols.Count == 0)
{
List<string> symbols = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _).Symbols(true);
foreach (Instrument i in await broker.GetInstrumentsAsync(symbols, ct).ConfigureAwait(false))
{
_symbols[i.Id] = i.Symbol;
}
}
closed = await broker.GetClosedTradesAsync(sinceUtc, ct).ConfigureAwait(false);
open = await broker.GetPositionsAsync(ct).ConfigureAwait(false);
_lastProblem = string.Empty;
}
else
{
_lastProblem = "nessuna chiave eToro: solo i dati del ledger";
}
}
catch (Exception ex) when (ex is BrokerException or HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
{
_lastProblem = $"storico eToro non letto: {ex.Message}";
Log.Warn(_lastProblem);
}
List<PositionRecord> positions = HistoryBuilder.Positions(closed, open, baskets, orders, cash, SymbolOf, static s => PipMath.Pip(s), DateTime.UtcNow);
lock (_gate)
{
_cache = (DateTime.UtcNow, positions, orders);
}
return (positions, orders);
}
private string SymbolOf(long id) => _symbols.TryGetValue(id, out string? s) ? s : id.ToString(CultureInfo.InvariantCulture);
private EtoroBroker Broker()
{
if (_broker is null)
{
EtoroOptions options = new()
{
Environment = config.Etoro.Environment,
BaseUrl = config.Etoro.BaseUrl,
RequestTimeoutSeconds = config.Etoro.RequestTimeoutSeconds,
ApiKey = config.Etoro.ApiKey,
UserKey = config.Etoro.UserKey,
UserAgent = config.Etoro.UserAgent,
};
if (!options.HasKeys)
{
KeyStores.Resolve(config, out _);
options.ApiKey = config.Etoro.ApiKey;
options.UserKey = config.Etoro.UserKey;
}
_broker = new EtoroBroker(options) { OnLog = static (m, ex) => Log.Warn(ex is null ? m : $"{m}: {ex.Message}") };
}
return _broker;
}
public List<OrderRecord> ReadOrders()
{
List<OrderRecord> rows = [];
foreach (string line in Ledger.ReadLines(Path.Combine(LedgerDirectory, "orders.jsonl")))
{
if (OrderRecord.Parse(line) is { } r)
{
rows.Add(r);
}
}
return rows;
}
public List<BasketOutcomeRow> ReadBaskets()
{
List<BasketOutcomeRow> rows = [];
foreach (string line in Ledger.ReadLines(Path.Combine(LedgerDirectory, "baskets.csv")))
{
if (BasketOutcomeRow.Parse(line) is { } r)
{
rows.Add(r);
}
}
return rows;
}
/// <summary>The <c>movimento_di_cassa</c> lines of every decisions file.</summary>
public List<CashMovementRecord> ReadCashMovements()
{
List<CashMovementRecord> rows = [];
if (!Directory.Exists(LedgerDirectory))
{
return rows;
}
foreach (string file in Directory.GetFiles(LedgerDirectory, "decisions*.jsonl").OrderBy(static f => f, StringComparer.Ordinal))
{
foreach (string line in Ledger.ReadLines(file))
{
if (!line.Contains("movimento_di_cassa", StringComparison.Ordinal))
{
continue;
}
try
{
using JsonDocument doc = JsonDocument.Parse(line);
JsonElement r = doc.RootElement;
if (r.TryGetProperty("evento", out JsonElement ev) && ev.GetString() == "movimento_di_cassa" &&
r.TryGetProperty("ts", out JsonElement ts) && DateTime.TryParse(ts.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t) &&
r.TryGetProperty("importo", out JsonElement amount))
{
rows.Add(new CashMovementRecord(t, amount.GetDouble(), r.TryGetProperty("motivazione", out JsonElement m) ? m.GetString() ?? string.Empty : string.Empty));
}
}
catch (JsonException)
{
// A damaged line is skipped.
}
}
}
return rows;
}
public async ValueTask DisposeAsync()
{
if (_broker is not null)
{
await _broker.DisposeAsync().ConfigureAwait(false);
}
}
}
@@ -1,11 +1,12 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Baskets.History;
using Encelado.Core.Baskets.Learning; using Encelado.Core.Baskets.Learning;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// The learning stack at runtime (§8): the shadow logistic model that scores every /// The learning stack at runtime (§8): the shadow logistic model that scores every
@@ -57,8 +58,6 @@ public sealed class LearningState : IDisposable
public int Version => _version; public int Version => _version;
public DateTime LastCycleUtc => _lastCycleUtc;
/// <summary>Mean of the last ten labels, or NaN before there are any.</summary> /// <summary>Mean of the last ten labels, or NaN before there are any.</summary>
public double LastOutcomes public double LastOutcomes
{ {
@@ -1,76 +1,11 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Baskets.History; using Encelado.Core.Baskets.History;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.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> /// <summary>
/// The history of information and decisions (§8.1): <c>decisions.jsonl</c> gets one /// The history of information and decisions (§8.1): <c>decisions.jsonl</c> gets one
@@ -1,9 +1,9 @@
using System.Globalization; using System.Globalization;
using Encelado.Bot.Engine; using Encelado.Engine;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// The commands accepted from the authorised Telegram chat (§7 of the 5.0 plan). Every /// The commands accepted from the authorised Telegram chat (§7 of the 5.0 plan). Every
@@ -1,9 +1,10 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using Encelado.Bot.Engine; using Encelado.Engine;
using Encelado.Core.Baskets.History;
using Encelado.Core.Notifications; using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Engine.Baskets;
/// <summary> /// <summary>
/// The texts Telegram receives (§7 of the 5.0 plan), built from a snapshot so the same /// The texts Telegram receives (§7 of the 5.0 plan), built from a snapshot so the same
@@ -0,0 +1,129 @@
using Encelado.Core.Baskets;
namespace Encelado.Engine.Configuration;
/// <summary>
/// Where the operator's files live and how they get there the first time.
/// <para>
/// The configuration folder holds <c>encelado.json</c>, <c>strategy.json</c>,
/// <c>instruments.json</c> and the encrypted keys; everything the bot produces
/// (<c>data/</c>, <c>knowledge/</c>, <c>reports/</c>, <c>logs/</c>) resolves against
/// it unless the configuration says otherwise. In the container it is <c>/config</c>
/// (and the data folder <c>/data</c>); anywhere else it is <c>ENCELADO_CONFIG_DIR</c>,
/// or <c>Documenti\Encelado</c> for a developer on Windows, where a backup catches it
/// and a reinstall cannot overwrite it.
/// </para>
/// </summary>
public static class AppPaths
{
/// <summary>The container's configuration volume (see <c>docs/DOCKER.md</c>).</summary>
public const string ContainerConfigDirectory = "/config";
/// <summary>The container's data volume.</summary>
public const string ContainerDataDirectory = "/data";
/// <summary>Loaded once at startup and shared by the host.</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;
/// <summary>What happened at first run, for the log; null when the files already existed.</summary>
public static string? SeedNote { get; private set; }
/// <summary>True when the process runs inside the container (the config volume is mounted).</summary>
public static bool InContainer =>
Environment.GetEnvironmentVariable("ENCELADO_IN_CONTAINER") is "1" or "true" ||
(!OperatingSystem.IsWindows() && Directory.Exists(ContainerConfigDirectory));
public static string ConfigDirectory
{
get
{
string? custom = Environment.GetEnvironmentVariable("ENCELADO_CONFIG_DIR");
if (!string.IsNullOrWhiteSpace(custom))
{
return custom.Trim();
}
if (InContainer)
{
return ContainerConfigDirectory;
}
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Encelado");
}
}
/// <summary>Reads the configuration (seeding it when missing), applies the container's folder layout and remembers the result.</summary>
public static BotConfig Load()
{
ConfigPath = ResolveConfigPath();
Config = ConfigLoader.Load(ConfigPath, out List<string> warnings);
ConfigWarnings = warnings;
// In the container the data volume is separate from the configuration volume:
// relative folders in the file resolve against /data, not /config.
if (InContainer && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ENCELADO_CONFIG_DIR")))
{
string data = Environment.GetEnvironmentVariable("ENCELADO_DATA_DIR") is { Length: > 0 } d ? d : ContainerDataDirectory;
Config.Run.BaseDirectory = ConfigDirectory;
if (!Path.IsPathRooted(Config.Run.DataDirectory)) { Config.Run.DataDirectory = Path.Combine(data, Config.Run.DataDirectory); }
if (!Path.IsPathRooted(Config.Run.KnowledgeDirectory)) { Config.Run.KnowledgeDirectory = Path.Combine(data, Config.Run.KnowledgeDirectory); }
if (!Path.IsPathRooted(Config.Run.ReportsDirectory)) { Config.Run.ReportsDirectory = Path.Combine(data, Config.Run.ReportsDirectory); }
if (!Path.IsPathRooted(Config.Logging.Directory)) { Config.Logging.Directory = Path.Combine(data, Config.Logging.Directory); }
}
SeedStrategyFile(Config.Run.StrategyPath);
return Config;
}
/// <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", 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}";
}
}
private static string ResolveConfigPath()
{
string directory = ConfigDirectory;
string target = Path.Combine(directory, "encelado.json");
if (File.Exists(target))
{
return target;
}
Directory.CreateDirectory(directory);
File.WriteAllText(target + ".tmp", ConfigDefaults.Json);
File.Move(target + ".tmp", target, overwrite: true);
SeedNote = $"nessuna configurazione trovata: creata quella di fabbrica in {target}";
return target;
}
}
/// <summary>The phrase that arms the live mode at every start, on the console (<c>--confirm-live</c>), in the container (<c>ENCELADO_CONFIRM_LIVE</c>) and in the web UI.</summary>
public static class LiveConfirmation
{
public const string Phrase = "CONFERMO LIVE";
public static bool IsConfirmed(string? text) => text is not null && text.Trim() == Phrase;
}
@@ -1,7 +1,7 @@
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Etoro; using Encelado.Etoro;
namespace Encelado.Bot.Configuration; namespace Encelado.Engine.Configuration;
/// <summary> /// <summary>
/// Everything the bot reads at startup. The file in Documents carries the operator's /// Everything the bot reads at startup. The file in Documents carries the operator's
@@ -157,6 +157,18 @@ public sealed class UiOptions
/// </summary> /// </summary>
public string TimeZone { get; set; } = ComputerZone; public string TimeZone { get; set; } = ComputerZone;
/// <summary>The currencies the display selector offers (D-33). Every amount stays in USD underneath.</summary>
public static readonly string[] Currencies = ["USD", "EUR", "GBP", "CHF", "JPY", "AUD", "CAD", "NZD"];
/// <summary>The currency the web UI, Telegram and the exports show amounts in; the ledger stays in USD.</summary>
public string DisplayCurrency { get; set; } = "USD";
/// <summary><c>dark</c> (default) or <c>light</c>.</summary>
public string Theme { get; set; } = "dark";
/// <summary>Whether the navigation rail starts expanded.</summary>
public bool NavExpanded { get; set; } = true;
/// <summary>The zone the window renders times in; never throws, the computer's zone is the fallback.</summary> /// <summary>The zone the window renders times in; never throws, the computer's zone is the fallback.</summary>
public TimeZoneInfo ResolveTimeZone(out string? warning) public TimeZoneInfo ResolveTimeZone(out string? warning)
{ {
@@ -186,6 +198,17 @@ public sealed class UiOptions
public void Validate() public void Validate()
{ {
TimeZone ??= ComputerZone; TimeZone ??= ComputerZone;
DisplayCurrency = (DisplayCurrency ?? "USD").Trim().ToUpperInvariant();
if (!Currencies.Contains(DisplayCurrency))
{
throw new InvalidOperationException($"ui.displayCurrency deve essere una fra {string.Join(", ", Currencies)}.");
}
Theme = (Theme ?? "dark").Trim().ToLowerInvariant();
if (Theme is not ("dark" or "light"))
{
throw new InvalidOperationException("ui.theme deve essere dark oppure light.");
}
} }
} }
@@ -1,6 +1,6 @@
using System.Globalization; using System.Globalization;
namespace Encelado.Bot.Configuration; namespace Encelado.Engine.Configuration;
/// <summary> /// <summary>
/// The factory configuration, and the ability to go back to it. /// The factory configuration, and the ability to go back to it.
@@ -133,8 +133,14 @@ public static class ConfigDefaults
}, },
"ui": { "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": "Fuso orario con cui l'interfaccia mostra gli orari. 'computer' = quello del sistema (nel container la variabile TZ); 'UTC'; oppure un id IANA (es. 'Europe/Rome'). Il file di log porta l'offset, il ledger è in UTC: cambiare questo valore non tocca nessun file.",
"timeZone": "computer" "timeZone": "computer",
"_displayCurrency": "Valuta in cui l'interfaccia, Telegram e le esportazioni mostrano gli importi (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD). I tassi vengono dalle quotazioni eToro già in polling; ledger e decisioni restano in USD. Anche ENCELADO_DISPLAY_CURRENCY.",
"displayCurrency": "USD",
"_theme": "dark oppure light.",
"theme": "dark",
"_navExpanded": "Se la barra di navigazione a sinistra parte aperta (etichette estese) o chiusa (solo icone).",
"navExpanded": true
}, },
"logging": { "logging": {
@@ -2,7 +2,7 @@ using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
namespace Encelado.Bot.Configuration; namespace Encelado.Engine.Configuration;
/// <summary> /// <summary>
/// Reads <c>encelado.json</c> by hand with <see cref="JsonDocument"/>. No reflection /// Reads <c>encelado.json</c> by hand with <see cref="JsonDocument"/>. No reflection
@@ -187,6 +187,9 @@ public static class ConfigLoader
switch (p.Name.ToLowerInvariant()) switch (p.Name.ToLowerInvariant())
{ {
case "timezone": o.TimeZone = Str(p); break; case "timezone": o.TimeZone = Str(p); break;
case "displaycurrency": o.DisplayCurrency = Str(p); break;
case "theme": o.Theme = Str(p); break;
case "navexpanded": o.NavExpanded = Bool(p); break;
default: warnings.Add($"chiave sconosciuta 'ui.{p.Name}'"); break; default: warnings.Add($"chiave sconosciuta 'ui.{p.Name}'"); break;
} }
} }
@@ -288,11 +291,17 @@ public static class ConfigLoader
config.Logging.Level = level.Trim(); config.Logging.Level = level.Trim();
} }
string? zone = Environment.GetEnvironmentVariable("ENCELADO_TIME_ZONE"); string? zone = Environment.GetEnvironmentVariable("ENCELADO_TIME_ZONE") ?? Environment.GetEnvironmentVariable("TZ");
if (!string.IsNullOrWhiteSpace(zone)) if (!string.IsNullOrWhiteSpace(zone) && config.Ui.TimeZone.Equals(UiOptions.ComputerZone, StringComparison.OrdinalIgnoreCase))
{ {
config.Ui.TimeZone = zone.Trim(); config.Ui.TimeZone = zone.Trim();
} }
string? currency = Environment.GetEnvironmentVariable("ENCELADO_DISPLAY_CURRENCY");
if (!string.IsNullOrWhiteSpace(currency))
{
config.Ui.DisplayCurrency = currency.Trim().ToUpperInvariant();
}
} }
private static IEnumerable<JsonProperty> Properties(JsonElement e, string section, List<string> warnings) private static IEnumerable<JsonProperty> Properties(JsonElement e, string section, List<string> warnings)
@@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
namespace Encelado.Bot.Configuration; namespace Encelado.Engine.Configuration;
/// <summary> /// <summary>
/// Targeted edits to <c>encelado.json</c> made from the settings screen. /// Targeted edits to <c>encelado.json</c> made from the settings screen.
@@ -28,15 +28,6 @@ public static class ConfigWriter
AllowTrailingCommas = true, 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> /// <summary>
/// Writes a batch of values addressed by dotted path, in one atomic save. /// Writes a batch of values addressed by dotted path, in one atomic save.
/// <para> /// <para>
@@ -0,0 +1,329 @@
using System.Buffers;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Encelado.Engine.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>
/// Where the eToro keys come from (§12 of the 5.0 plan). Two implementations: the
/// environment (<c>ETORO_API_KEY</c>/<c>ETORO_USER_KEY</c>, the default in the
/// container, read-only) and a file encrypted with AES-256-GCM under a passphrase from
/// <c>ENCELADO_KEY_PASSPHRASE</c>, kept in the configuration folder. DPAPI went with
/// the Windows desktop (ADR-0007): the bot runs in a Linux container.
/// </summary>
public interface IKeyStore
{
/// <summary>One line for the settings page: where the keys are and whether they can be written.</summary>
string Description { get; }
bool CanSave { get; }
EtoroKeys? Load(bool demo);
void Save(bool demo, EtoroKeys keys);
bool Clear(bool demo);
}
/// <summary>
/// A file encrypted with AES-256-GCM. The key is derived from the passphrase with
/// PBKDF2-SHA256 (a fresh salt at every write); the file carries a magic, the salt,
/// the nonce, the tag and the ciphertext of a small JSON with one entry per
/// environment. A wrong passphrase or a damaged file reads as "no keys", never as a crash.
/// </summary>
public sealed class EncryptedFileKeyStore(string path, string passphrase) : IKeyStore
{
private static readonly byte[] Magic = "ENC1"u8.ToArray();
private const int SaltSize = 16;
private const int NonceSize = 12;
private const int TagSize = 16;
private const int Iterations = 200_000;
public string Path { get; } = path ?? throw new ArgumentNullException(nameof(path));
public string Description => $"file cifrato {Path} (AES-256-GCM, passphrase da ENCELADO_KEY_PASSPHRASE)";
public bool CanSave => passphrase.Length > 0;
public bool Exists => File.Exists(Path);
public EtoroKeys? Load(bool demo)
{
Dictionary<string, EtoroKeys> all = LoadAll();
return all.TryGetValue(Key(demo), out EtoroKeys? found) ? found : null;
}
public void Save(bool demo, EtoroKeys keys)
{
ArgumentNullException.ThrowIfNull(keys);
if (!CanSave)
{
throw new InvalidOperationException("nessuna passphrase (ENCELADO_KEY_PASSPHRASE): le chiavi non possono essere salvate su file");
}
Dictionary<string, EtoroKeys> all = LoadAll();
all[Key(demo)] = keys;
Write(all);
}
public bool Clear(bool demo)
{
Dictionary<string, EtoroKeys> all = LoadAll();
if (!all.Remove(Key(demo)))
{
return false;
}
if (all.Count == 0)
{
try
{
File.Delete(Path);
}
catch (IOException)
{
// The caller reports the path; nothing more to do.
}
}
else
{
Write(all);
}
return true;
}
private static string Key(bool demo) => demo ? "demo" : "real";
private Dictionary<string, EtoroKeys> LoadAll()
{
Dictionary<string, EtoroKeys> result = new(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(Path) || passphrase.Length == 0)
{
return result;
}
byte[] raw;
try
{
raw = File.ReadAllBytes(Path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return result;
}
if (raw.Length < Magic.Length + SaltSize + NonceSize + TagSize || !raw.AsSpan(0, Magic.Length).SequenceEqual(Magic))
{
return result;
}
ReadOnlySpan<byte> salt = raw.AsSpan(Magic.Length, SaltSize);
ReadOnlySpan<byte> nonce = raw.AsSpan(Magic.Length + SaltSize, NonceSize);
ReadOnlySpan<byte> tag = raw.AsSpan(Magic.Length + SaltSize + NonceSize, TagSize);
ReadOnlySpan<byte> cipher = raw.AsSpan(Magic.Length + SaltSize + NonceSize + TagSize);
byte[] plaintext = new byte[cipher.Length];
byte[] key = Derive(salt);
try
{
using AesGcm aes = new(key, TagSize);
aes.Decrypt(nonce, cipher, tag, plaintext);
}
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
{
// Wrong passphrase, or a damaged file: treated as absent so the caller asks instead of crashing.
return result;
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
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 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();
}
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
byte[] nonce = RandomNumberGenerator.GetBytes(NonceSize);
byte[] tag = new byte[TagSize];
byte[] cipher = new byte[buffer.WrittenCount];
byte[] key = Derive(salt);
try
{
using AesGcm aes = new(key, TagSize);
aes.Encrypt(nonce, buffer.WrittenSpan, cipher, tag);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
byte[] payload = new byte[Magic.Length + SaltSize + NonceSize + TagSize + cipher.Length];
Magic.CopyTo(payload, 0);
salt.CopyTo(payload, Magic.Length);
nonce.CopyTo(payload, Magic.Length + SaltSize);
tag.CopyTo(payload, Magic.Length + SaltSize + NonceSize);
cipher.CopyTo(payload, Magic.Length + SaltSize + NonceSize + TagSize);
string? dir = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllBytes(Path + ".tmp", payload);
File.Move(Path + ".tmp", Path, overwrite: true);
if (!OperatingSystem.IsWindows())
{
try
{
File.SetUnixFileMode(Path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
// Best effort: the content is encrypted anyway.
}
}
}
private byte[] Derive(ReadOnlySpan<byte> salt) =>
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(passphrase), salt, Iterations, HashAlgorithmName.SHA256, 32);
}
/// <summary>Builds the store the environment allows and installs the keys into the configuration.</summary>
public static class KeyStores
{
public const string PassphraseVariable = "ENCELADO_KEY_PASSPHRASE";
public const string FileName = "etoro.keys.enc";
/// <summary>The encrypted file store of this configuration folder (writable only with a passphrase in the environment).</summary>
public static EncryptedFileKeyStore File(BotConfig config)
{
ArgumentNullException.ThrowIfNull(config);
return new EncryptedFileKeyStore(Path.Combine(config.Run.BaseDirectory, FileName), Environment.GetEnvironmentVariable(PassphraseVariable)?.Trim() ?? string.Empty);
}
/// <summary>
/// Installs keys into the configuration: from the environment first (already
/// applied by the loader), then from the encrypted file. Returns whether a pair was found.
/// </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;
}
EncryptedFileKeyStore store = File(config);
if (store.Load(config.Etoro.IsDemo) is { IsComplete: true } saved)
{
config.Etoro.ApiKey = saved.ApiKey;
config.Etoro.UserKey = saved.UserKey;
origin = $"file cifrato ({Mask(saved.ApiKey)}, {saved.SavedUtc:yyyy-MM-dd})";
return true;
}
origin = store.Exists && !store.CanSave
? $"nessuna chiave eToro: esiste {store.Path} ma manca {PassphraseVariable} per leggerlo"
: "nessuna chiave eToro";
return false;
}
/// <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));
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Il motore portabile (net10.0, nessun NuGet, nessuna dipendenza da Windows): il
ciclo di decisione, il registro degli ordini, il ledger, i feed, l'apprendimento,
la configurazione, il log, le notifiche. Lo ospita Encelado.Server (Kestrel, web UI,
riga di comando) dentro il container; era src/Encelado.Bot fino alla 4.0.0, con la
finestra WPF ritirata dalla 5.0 (ADR-0007).
-->
<PropertyGroup>
<RootNamespace>Encelado.Engine</RootNamespace>
<AssemblyName>Encelado.Engine</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
<ProjectReference Include="..\Encelado.Etoro\Encelado.Etoro.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Encelado.Tests" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using Encelado.Bot.Ui; using Encelado.Engine.Settings;
namespace Encelado.Bot.Engine; namespace Encelado.Engine;
public enum BotState public enum BotState
{ {
@@ -85,23 +85,15 @@ public sealed record QuoteRow(string Symbol, double Bid, double Ask, double Spre
public string SpreadDisplay => Bid > 0 ? SpreadPips.ToString("0.0", 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 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) 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> /// <summary>The event time in the operator's time zone (see <see cref="UiClock"/>).</summary>
public string TimeLocal => UiClock.Format(TimeUtc, "ddd dd/MM HH:mm"); public string TimeLocal => UiClock.Format(TimeUtc, "ddd dd/MM HH:mm");
public string InMinutes public string InMinutes
@@ -229,6 +221,15 @@ public sealed record BotSnapshot
/// <summary>API quota use and the age of the last quote, one line.</summary> /// <summary>API quota use and the age of the last quote, one line.</summary>
public string Counters { get; init; } = string.Empty; public string Counters { get; init; } = string.Empty;
/// <summary>The notification channel's one-line state (last delivery, queue, last error).</summary>
public string NotifierStatus { get; init; } = string.Empty;
/// <summary>The currency the UI shows amounts in (<c>ui.displayCurrency</c>); every number in this snapshot is USD.</summary>
public string DisplayCurrency { get; init; } = "USD";
/// <summary>USD per unit of every currency the polled quotes can price, for the presentation layer.</summary>
public IReadOnlyList<FxRate> FxRates { get; init; } = [];
public IReadOnlyList<EventRow> Events { get; init; } = []; public IReadOnlyList<EventRow> Events { get; init; } = [];
public IReadOnlyList<BasketRow> Baskets { get; init; } = []; public IReadOnlyList<BasketRow> Baskets { get; init; } = [];
@@ -238,6 +239,9 @@ public sealed record BotSnapshot
public ContextRow? Context { get; init; } public ContextRow? Context { get; init; }
} }
/// <summary>A conversion rate for the display currency: USD per one unit of <paramref name="Currency"/>, from the quote of the moment.</summary>
public sealed record FxRate(string Currency, double UsdPerUnit, DateTime TimeUtc);
/// <summary>An orphan or a foreign position as the bonifica lists it.</summary> /// <summary>An orphan or a foreign position as the bonifica lists it.</summary>
public sealed record PositionInfo(long PositionId, string Symbol, bool IsBuy, double Units, DateTime OpenedUtc, double UnrealizedPnl, string Origin, string Basket, string Reason); public sealed record PositionInfo(long PositionId, string Symbol, bool IsBuy, double Units, DateTime OpenedUtc, double UnrealizedPnl, string Origin, string Basket, string Reason);
@@ -1,9 +1,9 @@
using Encelado.Bot.Baskets; using Encelado.Engine.Baskets;
using Encelado.Bot.Configuration; using Encelado.Engine.Configuration;
using Encelado.Bot.Logging; using Encelado.Engine.Logging;
using Encelado.Bot.Ui; using Encelado.Engine.Settings;
namespace Encelado.Bot.Engine; namespace Encelado.Engine;
/// <summary> /// <summary>
/// Owns the engine's lifecycle so the window can start and stop trading without /// Owns the engine's lifecycle so the window can start and stop trading without
@@ -30,9 +30,6 @@ public sealed class BotSupervisor(BotConfig config, Func<BotConfig, bool, IEngin
public BotConfig Config => config; public BotConfig Config => config;
/// <summary>The channel the engines notify on; the host owns its lifetime.</summary>
public Core.Notifications.INotifier Notifier { get; } = notifier ?? Core.Notifications.NullNotifier.Instance;
/// <summary>Set by the shell once the operator has confirmed the live mode at start.</summary> /// <summary>Set by the shell once the operator has confirmed the live mode at start.</summary>
public bool StartConfirmed { get; set; } public bool StartConfirmed { get; set; }
@@ -1,4 +1,4 @@
namespace Encelado.Bot.Engine; namespace Encelado.Engine;
/// <summary>What the window and the headless runner can ask a running engine to do.</summary> /// <summary>What the window and the headless runner can ask a running engine to do.</summary>
public enum EngineCommandKind public enum EngineCommandKind
@@ -1,19 +1,13 @@
using Encelado.Bot.Engine; namespace Encelado.Engine;
namespace Encelado.Tests;
/// <summary> /// <summary>
/// Snapshot builders shared by the UI suites. /// A fully populated snapshot with plausible numbers: the tests render and serialise
/// <para> /// it, and <c>Encelado.Server --sample</c> serves it so the interface can be looked at
/// Central rather than copied into each test file: <see cref="BotSnapshot"/> has /// (and photographed for the documentation) without keys or a market.
/// required members, so every field added to it would otherwise break several files at
/// once and get filled in mechanically — which is how a "populated" snapshot quietly
/// stops populating the thing a binding test is supposed to exercise.
/// </para>
/// </summary> /// </summary>
internal static class TestSnapshots public static class SampleSnapshot
{ {
private static readonly DateTime Now = new(2026, 9, 16, 10, 0, 0, DateTimeKind.Utc); private static readonly DateTime Now = new(2026, 9, 23, 10, 0, 0, DateTimeKind.Utc);
/// <summary>The minimum that satisfies the record. Used where only the state matters.</summary> /// <summary>The minimum that satisfies the record. Used where only the state matters.</summary>
public static BotSnapshot Minimal(BotState state) => new() public static BotSnapshot Minimal(BotState state) => new()
@@ -25,13 +19,13 @@ internal static class TestSnapshots
Endpoint = "https://public-api.etoro.com", Endpoint = "https://public-api.etoro.com",
}; };
/// <summary>Every collection non-empty, so no binding goes unexercised.</summary> /// <summary>Every collection non-empty, so no view goes unexercised.</summary>
public static BotSnapshot Populated() => Minimal(BotState.Running) with public static BotSnapshot Populated() => Minimal(BotState.Running) with
{ {
StartedAtUtc = Now.AddHours(-3), StartedAtUtc = Now.AddHours(-3),
Uptime = TimeSpan.FromHours(3), Uptime = TimeSpan.FromHours(3),
Preset = "MODERATE", Preset = "MODERATE",
StrategyVersion = "v4.0.0 · strategia 1a2b3c4d5e6f7a8b · run 20260916-070000-abc123", StrategyVersion = "v5.0.0 · strategia 1a2b3c4d5e6f7a8b · run 20260923-070000-abc123",
ApiState = "connesso", ApiState = "connesso",
ApiLatencyMs = 143, ApiLatencyMs = 143,
ClockSkewSeconds = 0.4, ClockSkewSeconds = 0.4,
@@ -39,6 +33,7 @@ internal static class TestSnapshots
Equity = 109_450.25, Equity = 109_450.25,
Balance = 109_500, Balance = 109_500,
AvailableBalance = 97_200.10, AvailableBalance = 97_200.10,
UsedMargin = 12_300,
PeakEquity = 110_600, PeakEquity = 110_600,
DrawdownPct = 0.0104, DrawdownPct = 0.0104,
EquityStopPct = 0.09, EquityStopPct = 0.09,
@@ -47,21 +42,23 @@ internal static class TestSnapshots
TodayPnlPct = 0.0011, TodayPnlPct = 0.0011,
OpenPnl = -49.75, OpenPnl = -49.75,
OpenPnlPct = -0.00045, OpenPnlPct = -0.00045,
AccountOpenPnl = -61.20,
OpenBaskets = 1, OpenBaskets = 1,
MaxBaskets = 3, MaxBaskets = 3,
PendingBaskets = 0, PendingBaskets = 0,
PendingOrders = 0, PendingOrders = 0,
OrphanLegs = 1, OrphanLegs = 1,
ForeignPositions = 2, ForeignPositions = 2,
AccountOpenPnl = -61.20,
UsedMargin = 12_300,
CumulativeCashFlow = 0, CumulativeCashFlow = 0,
Unreconciled = false, Unreconciled = false,
Halted = false, Halted = false,
EquityStopped = false, EquityStopped = false,
KillSwitched = false, KillSwitched = false,
EntriesBlockedReason = null, EntriesBlockedReason = null,
Counters = "quote/min 20/110 · ordini/min 0/18 · ultima quotazione 2 s fa", Counters = "quote/min 20/110 · ordini/min 0/18 · esiti/min 0/55 · ultima quotazione 2 s fa",
NotifierStatus = "Telegram: ultimo invio 09:00:00 UTC, coda 0, inviati 12, falliti 0",
DisplayCurrency = "EUR",
FxRates = [new FxRate("USD", 1, Now), new FxRate("EUR", 1.155, Now), new FxRate("GBP", 1.34, Now), new FxRate("CHF", 1.2214, Now), new FxRate("JPY", 0.00675, Now), new FxRate("AUD", 0.66, Now), new FxRate("CAD", 0.724, Now), new FxRate("NZD", 0.585, Now)],
Events = Events =
[ [
@@ -72,23 +69,16 @@ internal static class TestSnapshots
Baskets = Baskets =
[ [
// Open, so the CHIUDI button renders.
new BasketRow("EURUSD/USDCHF", "EURUSD", "USDCHF", "EURCHF", "Open", 2, -49.75, -0.00045, -3.2, 10, -0.72, -0.55, 2.14, 3.1, 0.58, false, new BasketRow("EURUSD/USDCHF", "EURUSD", "USDCHF", "EURCHF", "Open", 2, -49.75, -0.00045, -3.2, 10, -0.72, -0.55, 2.14, 3.1, 0.58, false,
"USD 14:30 (fra 2,5 h)", true, string.Empty, "IN POSIZIONE — vendo EURCHF sintetico, z entrata +2,14, 6 barre", 2.14, 6, 0, true, 31), "USD 14:30 (fra 2,5 h)", true, string.Empty, "IN POSIZIONE — vendo EURCHF sintetico, z entrata +2,14, 6 barre", 2.14, 6, 0, true, 31),
// Flat and blocked, so the tooltip with the refusal renders.
new BasketRow("AUDUSD/USDCAD", "AUDUSD", "USDCAD", "AUDCAD", "Idle", 0, 0, 0, 0, 10, -0.61, -0.30, 0.42, 4.7, 0.49, false, new BasketRow("AUDUSD/USDCAD", "AUDUSD", "USDCAD", "AUDCAD", "Idle", 0, 0, 0, 0, 10, -0.61, -0.30, 0.42, 4.7, 0.49, false,
"AUD 01:30 (fra 15 h)", true, string.Empty, "|z| 0,42 sotto zIn 2,00", 0, 0, 0, false, 44), "AUD 01:30 (fra 15 h)", true, string.Empty, "|z| 0,42 sotto zIn 2,00", 0, 0, 0, false, 44),
new BasketRow("NZDUSD/EURNZD", "NZDUSD", "EURNZD", "EURUSD", "Idle", 0, 0, 0, 0, 10, -0.42, -0.86, -0.74, double.NaN, double.NaN, false, new BasketRow("NZDUSD/EURNZD", "NZDUSD", "EURNZD", "EURUSD", "Idle", 0, 0, 0, 0, 10, -0.42, -0.86, -0.74, double.NaN, double.NaN, false,
"—", true, string.Empty, "|z| 0,74 sotto zIn 2,00", 0, 0, 0, false, 70), "—", true, string.Empty, "|z| 0,74 sotto zIn 2,00", 0, 0, 0, false, 70),
// Disabled, so the grey row and the disabled reason render.
new BasketRow("USDCAD/EURUSD", "USDCAD", "EURUSD", "EURCAD", "Disabled", 0, 0, 0, 0, 10, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, false, new BasketRow("USDCAD/EURUSD", "USDCAD", "EURUSD", "EURCAD", "Disabled", 0, 0, 0, 0, 10, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, false,
"—", false, "stesso cross sintetico di EURAUD/AUDCAD, che è aperto (SameCrossPolicy = Exclusive)", "disattivato", 0, 0, 0, false, double.NaN), "—", false, "stesso cross sintetico di EURAUD/AUDCAD, che è aperto (SameCrossPolicy = Exclusive)", "disattivato", 0, 0, 0, false, double.NaN),
new BasketRow("EURAUD/AUDCAD", "EURAUD", "AUDCAD", "EURCAD", "PendingA", 1, 0, 0, 0, 10, -0.18, -0.69, 0.12, double.NaN, double.NaN, false,
new BasketRow("EURAUD/AUDCAD", "EURAUD", "AUDCAD", "EURCAD", "Idle", 0, 0, 0, 0, 10, -0.18, -0.69, 0.12, double.NaN, double.NaN, false, "—", true, string.Empty, "IN ATTESA dell'esito della gamba A (ordine nel registro dalle 09:45:02 UTC)", 1.9, 0, 0, false, 16),
"—", true, string.Empty, "|z| 0,12 sotto zIn 2,00", 0, 0, 0, false, 16),
], ],
Quotes = Quotes =
@@ -108,8 +98,8 @@ internal static class TestSnapshots
new CalendarRow(DateTime.UtcNow.AddHours(15), "AUD", "Employment Change", "High", "25.0K", "24.5K"), new CalendarRow(DateTime.UtcNow.AddHours(15), "AUD", "Employment Change", "High", "25.0K", "24.5K"),
], ],
"EURUSD/USDCHF: σ prevista 1-4 h 0,038 % (HAR-RV), media 30 g 0,041 %", "EURUSD/USDCHF: σ prevista 1-4 h 0,038 % (HAR-RV), media 30 g 0,041 %",
"logistica v3 in ombra: 148 basket visti, AUC mobile 0,54; ultimo ciclo: AUC walk-forward 0,54 [0,47; 0,61]", "disattivato (ADR-0006): logistica v0 in ombra: 0 basket visti, AUC mobile n/d; ledger senza basket chiusi: niente da addestrare",
"propone MODERATE nel terzile di vol 1 — medie CONS 0,50, MOD 0,52, AGG 0,49; 41 scelte", "propone MODERATE nel terzile di vol 1 — medie CONS 0,50, MOD 0,52, AGG 0,49; 41 scelte (solo proposta)",
"calendario aggiornato 09:40 UTC, 37 eventi questa settimana", "calendario aggiornato 09:40 UTC, 37 eventi questa settimana",
"notizie: 212 item nelle ultime 24 h da 9 feed"), "notizie: 212 item nelle ultime 24 h da 9 feed"),
}; };
@@ -0,0 +1,230 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace Encelado.Engine;
/// <summary>
/// The snapshot as the web UI, the API and the tests read it: JSON written by hand with
/// <see cref="Utf8JsonWriter"/> (no reflection, the same discipline as the ledger). Every
/// amount is in USD; the client converts for display with <c>fxRates</c>.
/// </summary>
public static class SnapshotJson
{
public static string Write(BotSnapshot s)
{
ArgumentNullException.ThrowIfNull(s);
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms))
{
Write(w, s);
}
return Encoding.UTF8.GetString(ms.ToArray());
}
public static void Write(Utf8JsonWriter w, BotSnapshot s)
{
ArgumentNullException.ThrowIfNull(w);
ArgumentNullException.ThrowIfNull(s);
DateTime now = DateTime.UtcNow;
w.WriteStartObject();
w.WriteString("nowUtc", now.ToString("O", CultureInfo.InvariantCulture));
w.WriteString("state", s.State.ToString().ToLowerInvariant());
w.WriteString("error", s.Error ?? string.Empty);
w.WriteString("startedAtUtc", s.StartedAtUtc?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty);
w.WriteNumber("uptimeSeconds", Math.Round(s.Uptime.TotalSeconds));
w.WriteString("mode", s.Mode);
w.WriteString("environmentKind", s.EnvironmentKind);
w.WriteString("executionMode", s.ExecutionMode);
w.WriteString("endpoint", s.Endpoint);
w.WriteString("preset", s.Preset);
w.WriteString("strategyVersion", s.StrategyVersion);
w.WriteString("apiState", s.ApiState);
Num(w, "apiLatencyMs", s.ApiLatencyMs);
Num(w, "clockSkewSeconds", s.ClockSkewSeconds);
Num(w, "equity", s.Equity);
Num(w, "balance", s.Balance);
Num(w, "availableBalance", s.AvailableBalance);
Num(w, "usedMargin", s.UsedMargin);
Num(w, "peakEquity", s.PeakEquity);
Num(w, "drawdownPct", s.DrawdownPct);
Num(w, "equityStopPct", s.EquityStopPct);
Num(w, "dailyLossPct", s.DailyLossPct);
Num(w, "todayPnl", s.TodayPnl);
Num(w, "todayPnlPct", s.TodayPnlPct);
Num(w, "openPnl", s.OpenPnl);
Num(w, "openPnlPct", s.OpenPnlPct);
Num(w, "accountOpenPnl", s.AccountOpenPnl);
Num(w, "cumulativeCashFlow", s.CumulativeCashFlow);
w.WriteNumber("openBaskets", s.OpenBaskets);
w.WriteNumber("maxBaskets", s.MaxBaskets);
w.WriteNumber("pendingBaskets", s.PendingBaskets);
w.WriteNumber("pendingOrders", s.PendingOrders);
w.WriteNumber("orphanLegs", s.OrphanLegs);
w.WriteNumber("foreignPositions", s.ForeignPositions);
w.WriteBoolean("unreconciled", s.Unreconciled);
w.WriteString("unreconciledReason", s.UnreconciledReason);
w.WriteBoolean("halted", s.Halted);
w.WriteString("haltReason", s.HaltReason ?? string.Empty);
w.WriteBoolean("haltedWithResidue", s.HaltedWithResidue);
w.WriteStartArray("haltResidue");
foreach (PositionInfo r in s.HaltResidue)
{
WritePosition(w, r);
}
w.WriteEndArray();
w.WriteBoolean("equityStopped", s.EquityStopped);
w.WriteBoolean("killSwitched", s.KillSwitched);
w.WriteString("entriesBlockedReason", s.EntriesBlockedReason ?? string.Empty);
w.WriteString("counters", s.Counters);
w.WriteString("notifierStatus", s.NotifierStatus);
w.WriteString("displayCurrency", s.DisplayCurrency);
w.WriteStartObject("fxRates");
foreach (FxRate r in s.FxRates)
{
w.WriteStartObject(r.Currency);
w.WriteNumber("usdPerUnit", Math.Round(r.UsdPerUnit, 8));
w.WriteString("timeUtc", r.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
w.WriteNumber("ageSeconds", r.TimeUtc == default ? -1 : Math.Round((now - r.TimeUtc).TotalSeconds));
w.WriteEndObject();
}
w.WriteEndObject();
w.WriteStartArray("baskets");
foreach (BasketRow b in s.Baskets)
{
w.WriteStartObject();
w.WriteString("name", b.Name);
w.WriteString("pairA", b.PairA);
w.WriteString("pairB", b.PairB);
w.WriteString("cross", b.Cross);
w.WriteString("state", b.State);
w.WriteString("stateLabel", b.StateLabel);
w.WriteNumber("openLegs", b.OpenLegs);
Num(w, "pnlUsd", b.PnlUsd);
Num(w, "pnlPct", b.PnlPct);
Num(w, "pips", b.Pips);
Num(w, "tpPips", b.TpPips);
Num(w, "rho", b.Rho);
Num(w, "rhoShort", b.RhoShort);
Num(w, "z", b.Z);
Num(w, "costPips", b.CostPips);
Num(w, "pMl", b.PMl);
w.WriteBoolean("mlActive", b.MlActive);
w.WriteString("nextEvent", b.NextEvent);
w.WriteBoolean("enabled", b.Enabled);
w.WriteString("disabledReason", b.DisabledReason);
w.WriteString("intent", b.Intent);
Num(w, "entryZ", b.EntryZ);
w.WriteNumber("barsHeld", b.BarsHeld);
w.WriteNumber("adds", b.Adds);
w.WriteBoolean("isOpen", b.IsOpen);
Num(w, "halfLife", b.HalfLife);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteStartArray("quotes");
foreach (QuoteRow q in s.Quotes)
{
w.WriteStartObject();
w.WriteString("symbol", q.Symbol);
Num(w, "bid", q.Bid);
Num(w, "ask", q.Ask);
Num(w, "spreadPips", q.SpreadPips);
w.WriteString("timeUtc", q.TimeUtc == default ? string.Empty : q.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
Num(w, "ageSeconds", q.AgeSeconds);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteStartArray("events");
foreach (EventRow e in s.Events)
{
w.WriteStartObject();
w.WriteString("time", e.Time);
w.WriteString("level", e.Level);
w.WriteString("message", e.Message);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteStartObject("context");
if (s.Context is { } c)
{
w.WriteStartArray("sentiment");
foreach (SentimentRow r in c.Sentiment)
{
w.WriteStartObject();
w.WriteString("currency", r.Currency);
Num(w, "net1h", r.Net1h);
Num(w, "net4h", r.Net4h);
Num(w, "net24h", r.Net24h);
Num(w, "hawkish", r.Hawkish);
Num(w, "riskOff", r.RiskOff);
w.WriteNumber("count24h", r.Count24h);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteStartArray("nextEvents");
foreach (CalendarRow r in c.NextEvents)
{
w.WriteStartObject();
w.WriteString("timeUtc", r.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
w.WriteString("currency", r.Currency);
w.WriteString("title", r.Title);
w.WriteString("impact", r.Impact);
w.WriteString("forecast", r.Forecast);
w.WriteString("previous", r.Previous);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteString("volForecast", c.VolForecast);
w.WriteString("mlState", c.MlState);
w.WriteString("banditProposal", c.BanditProposal);
w.WriteString("calendarState", c.CalendarState);
w.WriteString("newsState", c.NewsState);
}
w.WriteEndObject();
w.WriteEndObject();
}
public static void WritePosition(Utf8JsonWriter w, PositionInfo r)
{
ArgumentNullException.ThrowIfNull(w);
ArgumentNullException.ThrowIfNull(r);
w.WriteStartObject();
w.WriteNumber("positionId", r.PositionId);
w.WriteString("symbol", r.Symbol);
w.WriteBoolean("isBuy", r.IsBuy);
Num(w, "units", r.Units);
w.WriteString("openedUtc", r.OpenedUtc == default ? string.Empty : r.OpenedUtc.ToString("O", CultureInfo.InvariantCulture));
Num(w, "unrealizedPnl", r.UnrealizedPnl);
w.WriteString("origin", r.Origin);
w.WriteString("basket", r.Basket);
w.WriteString("reason", r.Reason);
w.WriteEndObject();
}
private static void Num(Utf8JsonWriter w, string name, double v)
{
if (double.IsFinite(v))
{
w.WriteNumber(name, Math.Round(v, 6));
}
else
{
w.WriteNull(name);
}
}
}
@@ -4,9 +4,9 @@ using System.IO;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text; using System.Text;
using System.Threading.Channels; using System.Threading.Channels;
using Encelado.Bot.Configuration; using Encelado.Engine.Configuration;
namespace Encelado.Bot.Logging; namespace Encelado.Engine.Logging;
public enum LogLevel : byte public enum LogLevel : byte
{ {
@@ -1,10 +1,8 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization; using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
namespace Encelado.Bot.Ui; namespace Encelado.Engine.Settings;
/// <summary>What a field accepts, which decides how it is parsed and validated.</summary> /// <summary>What a field accepts, which decides how it is parsed and validated.</summary>
public enum SettingKind public enum SettingKind
@@ -28,7 +26,7 @@ public enum SettingKind
/// documentation, and documentation is what people stop reading. /// documentation, and documentation is what people stop reading.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class SettingField : INotifyPropertyChanged public sealed class SettingField
{ {
private string _value = string.Empty; private string _value = string.Empty;
private string? _error; private string? _error;
@@ -117,9 +115,6 @@ public sealed class SettingField : INotifyPropertyChanged
} }
_value = value; _value = value;
Raise();
Raise(nameof(IsDirty));
Raise(nameof(IsObsolete));
Validate(); Validate();
} }
} }
@@ -135,8 +130,6 @@ public sealed class SettingField : INotifyPropertyChanged
} }
_error = value; _error = value;
Raise();
Raise(nameof(HasError));
} }
} }
@@ -144,8 +137,6 @@ public sealed class SettingField : INotifyPropertyChanged
public bool IsDirty => !IsReadOnly && _value != Original; public bool IsDirty => !IsReadOnly && _value != Original;
public bool IsEditable => !IsReadOnly;
public string FullTooltip => ReadOnlyReason is null public string FullTooltip => ReadOnlyReason is null
? Tooltip ? Tooltip
: $"{Tooltip}\n\nNON MODIFICABILE — {ReadOnlyReason}"; : $"{Tooltip}\n\nNON MODIFICABILE — {ReadOnlyReason}";
@@ -154,8 +145,6 @@ public sealed class SettingField : INotifyPropertyChanged
{ {
_value = value; _value = value;
Original = value; Original = value;
Raise(nameof(Value));
Raise(nameof(IsDirty));
// Validato subito, non solo quando qualcuno lo tocca: un valore diventato non // Validato subito, non solo quando qualcuno lo tocca: un valore diventato non
// valido perché l'aggiornamento ha tolto una strategia deve segnalarsi da sé // valido perché l'aggiornamento ha tolto una strategia deve segnalarsi da sé
@@ -163,8 +152,6 @@ public sealed class SettingField : INotifyPropertyChanged
Validate(); Validate();
} }
public void Revert() => Load(Original);
/// <summary>Checks the text in isolation. Cross-field rules are the config's own job.</summary> /// <summary>Checks the text in isolation. Cross-field rules are the config's own job.</summary>
public void Validate() public void Validate()
{ {
@@ -267,10 +254,7 @@ public sealed class SettingField : INotifyPropertyChanged
_ => value.ToString("0.######", 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> /// <summary>A titled block of fields on the settings page.</summary>
@@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using Encelado.Bot.Configuration; using Encelado.Engine.Configuration;
namespace Encelado.Bot.Ui; namespace Encelado.Engine.Settings;
/// <summary> /// <summary>
/// The settings form, as data: which fields exist, where each one lives in the JSON, /// The settings form, as data: which fields exist, where each one lives in the JSON,
@@ -173,8 +173,8 @@ public static class SettingsCatalogue
private static SettingGroup Window(BotConfig config) private static SettingGroup Window(BotConfig config)
{ {
SettingGroup g = new( SettingGroup g = new(
"Finestra", "Interfaccia",
"Come la finestra mostra le cose. Niente qui cambia quello che il bot fa."); "Come l'interfaccia mostra le cose. Niente qui cambia quello che il bot fa.");
g.Fields.Add(new SettingField g.Fields.Add(new SettingField
{ {
@@ -183,11 +183,42 @@ public static class SettingsCatalogue
Initial = config.Ui.TimeZone, Initial = config.Ui.TimeZone,
Kind = SettingKind.Choice, Kind = SettingKind.Choice,
Choices = UiClock.Choices(), Choices = UiClock.Choices(),
Tooltip = "Il fuso con cui la finestra mostra ogni orario (orologio in alto, eventi, righe di attività). " + Tooltip = "Il fuso con cui l'interfaccia 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: " + "'computer' usa quello del sistema (TZ nel container). Il file di log porta l'offset e il ledger è in UTC: " +
"cambiare questo valore non tocca nessun file. Ha effetto al prossimo avvio.", "cambiare questo valore non tocca nessun file. Ha effetto al prossimo avvio.",
}); });
g.Fields.Add(new SettingField
{
Path = "ui.displayCurrency",
Label = "Valuta di visualizzazione",
Initial = config.Ui.DisplayCurrency,
Kind = SettingKind.Choice,
Choices = UiOptions.Currencies,
Tooltip = "La valuta in cui l'interfaccia, Telegram e le esportazioni mostrano gli importi. I tassi vengono dalle quotazioni eToro " +
"già in polling (EURUSD, GBPUSD, USDCHF, USDJPY, AUDUSD, USDCAD, NZDUSD); ogni importo convertito porta nel tooltip il valore in USD " +
"e il tasso usato. Ledger, baskets.csv e ogni decisione restano in USD. Cambia subito, senza riavvio.",
});
g.Fields.Add(new SettingField
{
Path = "ui.theme",
Label = "Tema",
Initial = config.Ui.Theme,
Kind = SettingKind.Choice,
Choices = ["dark", "light"],
Tooltip = "Palette scura (predefinita) o chiara, secondo i ruoli di colore di Material Design 3.",
});
g.Fields.Add(new SettingField
{
Path = "ui.navExpanded",
Label = "Barra di navigazione aperta",
Initial = config.Ui.NavExpanded ? "sì" : "no",
Kind = SettingKind.Boolean,
Tooltip = "Se la barra a sinistra parte con le etichette estese (256 px) o con le sole icone (80 px). Il pulsante del menu la apre e la chiude comunque.",
});
return g; return g;
} }
@@ -0,0 +1,170 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Encelado.Engine.Configuration;
namespace Encelado.Engine.Settings;
/// <summary>
/// The settings page as an API: the catalogue as JSON, and a batch of changes applied
/// to <c>encelado.json</c> only when every field validates on its own and the whole
/// file validates as a configuration. Half a settings page never gets written.
/// </summary>
public static class SettingsService
{
/// <summary>The groups and fields of the catalogue, with the current values.</summary>
public static string Describe(BotConfig config, string configPath, string keysDescription, string strategyPath)
{
ArgumentNullException.ThrowIfNull(config);
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms))
{
w.WriteStartObject();
w.WriteString("configPath", configPath);
w.WriteString("strategyPath", strategyPath);
w.WriteString("keys", keysDescription);
w.WriteStartArray("groups");
foreach (SettingGroup g in SettingsCatalogue.Build(config))
{
w.WriteStartObject();
w.WriteString("title", g.Title);
w.WriteString("description", g.Description);
w.WriteStartArray("fields");
foreach (SettingField f in g.Fields)
{
w.WriteStartObject();
w.WriteString("path", f.Path);
w.WriteString("label", f.Label);
w.WriteString("value", f.Value);
w.WriteString("kind", f.Kind.ToString().ToLowerInvariant());
w.WriteString("suffix", f.Suffix);
w.WriteString("tooltip", f.FullTooltip);
w.WriteBoolean("readOnly", f.IsReadOnly);
w.WriteBoolean("allowEmpty", f.AllowEmpty);
w.WriteString("error", f.Error ?? string.Empty);
if (double.IsFinite(f.Minimum)) { w.WriteNumber("minimum", f.Minimum); }
if (double.IsFinite(f.Maximum)) { w.WriteNumber("maximum", f.Maximum); }
w.WriteStartArray("options");
foreach (string o in f.Options)
{
w.WriteStringValue(o);
}
w.WriteEndArray();
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteEndObject();
}
return Encoding.UTF8.GetString(ms.ToArray());
}
/// <summary>
/// Applies a batch of <c>path → value</c> edits: field validation first, then the
/// whole configuration on a throwaway copy, then one atomic write. Returns what
/// happened in words the page can show.
/// </summary>
public static (bool Ok, string Message, int Applied) Apply(BotConfig config, string configPath, IReadOnlyDictionary<string, string> edits)
{
ArgumentNullException.ThrowIfNull(config);
ArgumentNullException.ThrowIfNull(edits);
if (edits.Count == 0)
{
return (false, "nessuna modifica", 0);
}
Dictionary<string, SettingField> fields = SettingsCatalogue.Build(config).SelectMany(static g => g.Fields).ToDictionary(static f => f.Path, StringComparer.OrdinalIgnoreCase);
Dictionary<string, JsonNode?> changes = [];
List<string> problems = [];
foreach ((string path, string value) in edits)
{
if (!fields.TryGetValue(path, out SettingField? field))
{
problems.Add($"{path}: campo sconosciuto");
continue;
}
if (field.IsReadOnly)
{
problems.Add($"{path}: non modificabile");
continue;
}
field.Value = value ?? string.Empty;
if (field.HasError)
{
problems.Add($"{field.Label}: {field.Error}");
continue;
}
if (!field.IsDirty)
{
continue;
}
try
{
changes[field.Path] = field.ToJson();
}
catch (Exception ex) when (ex is FormatException or OverflowException or ArgumentException)
{
problems.Add($"{field.Label}: {ex.Message}");
}
}
if (problems.Count > 0)
{
return (false, string.Join("; ", problems), 0);
}
if (changes.Count == 0)
{
return (true, "nessun valore diverso da quello salvato", 0);
}
// The whole validator on a copy, not a subset: single values can each be
// reasonable while the combination is not, and finding that out at the next start
// is the worst moment.
string temporary = Path.Combine(Path.GetTempPath(), $"encelado-check-{Guid.NewGuid():N}.json");
try
{
File.Copy(configPath, temporary, overwrite: true);
ConfigWriter.Apply(temporary, changes);
ConfigLoader.Load(temporary, out _).Validate();
}
catch (Exception ex) when (ex is InvalidOperationException or IOException or ArgumentException or UnauthorizedAccessException or JsonException)
{
return (false, $"la combinazione di valori non è valida: {ex.Message}", 0);
}
finally
{
try
{
File.Delete(temporary);
}
catch (IOException)
{
// A leftover in the temp folder is not worth failing the save over.
}
}
try
{
ConfigWriter.Apply(configPath, changes);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or FileNotFoundException or ArgumentException)
{
return (false, $"salvataggio non riuscito: {ex.Message}", 0);
}
return (true, string.Create(CultureInfo.InvariantCulture, $"{changes.Count} valore/i salvati in {configPath}; i valori del motore hanno effetto al prossimo avvio, quelli dell'interfaccia subito"), changes.Count);
}
}
@@ -1,6 +1,6 @@
using System.Globalization; using System.Globalization;
namespace Encelado.Bot.Ui; namespace Encelado.Engine.Settings;
/// <summary> /// <summary>
/// The one place that turns a UTC instant into the time the operator wants to read. /// The one place that turns a UTC instant into the time the operator wants to read.
@@ -41,19 +41,6 @@ public static class UiClock
public static string Format(DateTime utc, string format) => public static string Format(DateTime utc, string format) =>
utc == default ? "—" : ToZone(utc).ToString(format, CultureInfo.CurrentCulture); 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> /// <summary>The zones offered in the settings page: the computer's, UTC, then every zone Windows knows.</summary>
public static IReadOnlyList<string> Choices() public static IReadOnlyList<string> Choices()
{ {
@@ -0,0 +1,60 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
L'eseguibile: ospita il motore (Encelado.Engine) e serve l'interfaccia web e l'API su
Kestrel. Nessun pacchetto NuGet: Microsoft.AspNetCore.App è un framework reference
dell'SDK. Gira nel container (docs/DOCKER.md) e, per lo sviluppo, da riga di comando
o da VS Code (F5). L'interfaccia è HTML, CSS e JavaScript incorporati come risorse.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Encelado.Server</RootNamespace>
<AssemblyName>Encelado.Server</AssemblyName>
<!-- Kestrel and the minimal hosting use reflection in places the analyzers flag;
the server is published framework-dependent, never trimmed or AOT-compiled. -->
<IsAotCompatible>false</IsAotCompatible>
<IsTrimmable>false</IsTrimmable>
<InvariantGlobalization>true</InvariantGlobalization>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
<ProjectReference Include="..\Encelado.Engine\Encelado.Engine.csproj" />
<ProjectReference Include="..\Encelado.Etoro\Encelado.Etoro.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Encelado.Tests" />
</ItemGroup>
<!--
Commit e data di build finiscono in Impostazioni ▸ Informazioni e in /api/info.
Il commit si legge da git a compilazione, salvo che arrivi già da fuori
(-p:GitCommit=…, come fa il Dockerfile: nell'immagine non c'è .git). Senza git
e senza proprietà resta "n/d", e la compilazione non se ne lamenta.
-->
<Target Name="EnceladoBuildMetadata" BeforeTargets="GetAssemblyAttributes">
<Exec Condition="'$(GitCommit)' == ''" Command="git rev-parse --short=12 HEAD" ConsoleToMSBuild="true" ContinueOnError="true" IgnoreExitCode="true" StandardOutputImportance="low" StandardErrorImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommit" />
</Exec>
<PropertyGroup>
<GitCommit Condition="'$(GitCommit)' == ''">n/d</GitCommit>
<EnceladoBuildDate Condition="'$(EnceladoBuildDate)' == ''">$([System.DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm'))Z</EnceladoBuildDate>
</PropertyGroup>
<ItemGroup>
<AssemblyMetadata Include="Commit" Value="$(GitCommit)" />
<AssemblyMetadata Include="BuildDate" Value="$(EnceladoBuildDate)" />
</ItemGroup>
</Target>
<ItemGroup>
<!-- The web UI travels inside the assembly: no static-files folder to mount, no
path to get wrong in the container. -->
<EmbeddedResource Include="Web\wwwroot\**" LogicalName="wwwroot/%(RecursiveDir)%(Filename)%(Extension)" />
<!-- The factory configuration beside the binary, for the seeding and for the tests. -->
<None Include="..\..\config\*.json" Exclude="..\..\config\*.local.json" Link="config\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+274
View File
@@ -0,0 +1,274 @@
using System.Globalization;
using System.Net.Http;
using Encelado.Core.Baskets;
using Encelado.Core.Notifications;
using Encelado.Engine;
using Encelado.Engine.Baskets;
using Encelado.Engine.Configuration;
using Encelado.Engine.Logging;
using Encelado.Engine.Settings;
using Encelado.Server.Web;
using Microsoft.AspNetCore.Builder;
namespace Encelado.Server;
/// <summary>
/// The process: loads the configuration, starts the log, the notifier, the supervisor
/// and the web host, starts the engine (unless told otherwise), then waits for SIGTERM,
/// Ctrl+C, <c>stop</c> on the console or the minutes to run out. One instance per data
/// folder, enforced by the engine's lock.
/// <para>
/// Arguments: <c>--health</c> (probe the running server and exit 0/1), <c>--sample</c>
/// (serve a sample snapshot, no market), <c>--no-autostart</c>, <c>--bonifica</c>,
/// <c>--minutes N</c>, <c>--confirm-live "CONFERMO LIVE"</c>, <c>--port N</c>.
/// Environment: <c>ENCELADO_WEB_PORT</c>, <c>ENCELADO_WEB_TOKEN</c>,
/// <c>ENCELADO_CONFIRM_LIVE</c>, <c>ENCELADO_AUTOSTART</c>, plus those of the configuration loader.
/// </para>
/// </summary>
public static class Program
{
public static async Task<int> Main(string[] args)
{
if (Has(args, "--health"))
{
return await HealthAsync().ConfigureAwait(false);
}
BotConfig config;
try
{
config = AppPaths.Load();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Impossibile leggere la configurazione: {ex.Message}");
return 2;
}
config.Logging.Console = true;
Log.Initialize(config.Logging);
UiClock.Zone = config.Ui.ResolveTimeZone(out string? zoneWarning);
Log.Info($"Encelado {typeof(Program).Assembly.GetName().Version?.ToString(3)} — configurazione {AppPaths.ConfigPath}{(AppPaths.InContainer ? " (container)" : string.Empty)}");
foreach (string warning in AppPaths.ConfigWarnings)
{
Log.Warn($"configurazione: {warning}");
}
if (zoneWarning is not null)
{
Log.Warn(zoneWarning);
}
if (AppPaths.SeedNote is { } seeded)
{
Log.Warn(seeded);
}
bool sample = Has(args, "--sample");
bool bonifica = Has(args, "--bonifica");
bool autostart = !Has(args, "--no-autostart") && Environment.GetEnvironmentVariable("ENCELADO_AUTOSTART") is not ("0" or "false") && !sample;
int minutes = IntArg(args, "--minutes");
string? liveConfirmation = Arg(args, "--confirm-live") ?? Environment.GetEnvironmentVariable("ENCELADO_CONFIRM_LIVE");
int port = IntArg(args, "--port") is > 0 and var p ? p : WebHost.Port;
string token = WebHost.Token;
INotifier notifier = Notifications.Create(config);
await using BotSupervisor supervisor = new(config, (c, confirmed) => new BasketEngine(c, confirmed, null, notifier) { OrphanPolicy = bonifica ? OrphanPolicy.Report : OrphanPolicy.Close }, notifier);
if (notifier is TelegramNotifier telegram)
{
TelegramCommands commands = new(supervisor, () => notifier.Status);
telegram.CommandHandler = commands.HandleAsync;
telegram.StartCommands();
}
LogBuffer buffer = new(config.Logging.BufferedLines);
supervisor.EventLogged += buffer.Add;
supervisor.AttachLogSink();
await using HistoryService history = new(config);
WebContext ctx = new()
{
Config = config,
Supervisor = supervisor,
Log = buffer,
History = history,
ConfigPath = AppPaths.ConfigPath,
Sample = sample,
Start = confirm => StartAsync(supervisor, config, confirm ?? liveConfirmation),
Learn = () => LearnAsync(config),
};
WebApplication web = WebHost.Build(ctx, port, token, listenAll: token.Length > 0);
try
{
await web.StartAsync().ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
Log.Error($"il server web non parte sulla porta {port}", ex);
return 6;
}
Log.Info(token.Length > 0
? $"interfaccia web su http://localhost:{port}/ (in ascolto su tutte le interfacce, token da {WebHost.TokenVariable})"
: $"interfaccia web su http://localhost:{port}/ (solo localhost: senza {WebHost.TokenVariable} il server non ascolta fuori dal computer)");
using CancellationTokenSource stopping = new();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Log.Info("Ctrl+C: arresto");
stopping.Cancel();
};
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
{
if (!stopping.IsCancellationRequested)
{
Log.Info("SIGTERM: arresto");
stopping.Cancel();
}
};
if (sample)
{
Log.Warn("modalità dimostrativa (--sample): lo snapshot è finto, il motore non parte");
}
else if (autostart)
{
CommandResult started = await StartAsync(supervisor, config, liveConfirmation).ConfigureAwait(false);
if (!started.Ok)
{
Log.Error($"avvio del motore non riuscito: {started.Message}. Il server web resta attivo per correggere la configurazione.", null);
}
else if (bonifica)
{
await HeadlessRunner.BonificaAsync(supervisor, stopping.Token).ConfigureAwait(false);
}
}
else
{
Log.Info("motore non avviato (--no-autostart o ENCELADO_AUTOSTART=0): premi AVVIA nell'interfaccia");
}
if (minutes > 0)
{
Log.Info($"arresto automatico fra {minutes} minuti");
stopping.CancelAfter(TimeSpan.FromMinutes(minutes));
}
Task input = Task.Run(() => HeadlessRunner.ReadCommandsAsync(supervisor, stopping), stopping.Token);
DateTime lastStatus = DateTime.MinValue;
try
{
while (!stopping.IsCancellationRequested)
{
await Task.Delay(1000, stopping.Token).ConfigureAwait(false);
if (DateTime.UtcNow - lastStatus >= TimeSpan.FromSeconds(config.Run.StatusSeconds) && supervisor.State == BotState.Running && !sample)
{
lastStatus = DateTime.UtcNow;
HeadlessRunner.PrintStatus(supervisor.Snapshot());
}
}
}
catch (OperationCanceledException)
{
// Stop requested.
}
await supervisor.StopAsync().ConfigureAwait(false);
try
{
await web.StopAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException)
{
// Shutting down anyway.
}
if (notifier is IAsyncDisposable disposable)
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
await Log.FlushAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
await Log.ShutdownAsync().ConfigureAwait(false);
return 0;
}
/// <summary>Starts the engine after the gates: keys present, the live phrase when the mode is live.</summary>
private static async Task<CommandResult> StartAsync(BotSupervisor supervisor, BotConfig config, string? liveConfirmation)
{
if (!KeyStores.Resolve(config, out string origin))
{
return new CommandResult(false, $"{origin}: imposta ETORO_API_KEY e ETORO_USER_KEY nel container, oppure salva le chiavi da Impostazioni con {KeyStores.PassphraseVariable} nell'ambiente");
}
Log.Info($"chiavi eToro: {origin}");
ExecutionMode mode = config.Run.Mode;
if (mode.IsLive())
{
if (!LiveConfirmation.IsConfirmed(liveConfirmation))
{
return new CommandResult(false, $"la modalità Live richiede la frase {LiveConfirmation.Phrase} (--confirm-live, ENCELADO_CONFIRM_LIVE o la finestra di conferma)");
}
Log.Warn("avvio in Live sul conto REALE confermato");
}
supervisor.StartConfirmed = true;
return await supervisor.StartAsync().ConfigureAwait(false);
}
/// <summary>The learning cycle on demand: reads the ledger and writes the knowledge base, without touching the running engine.</summary>
private static Task<CommandResult> LearnAsync(BotConfig config)
{
try
{
BasketStrategyConfig strategy = BasketStrategyConfig.Load(config.Run.StrategyPath, out _);
using Ledger ledger = new(Path.Combine(config.Run.DataPath, "ledger"));
using LearningState learning = new(config.Run.DataPath, config.Run.KnowledgePath, ledger, strategy);
learning.RunCycle(DateTime.UtcNow);
return Task.FromResult(new CommandResult(true, $"ciclo eseguito: {learning.Describe()}"));
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException)
{
return Task.FromResult(new CommandResult(false, $"ciclo non eseguito: {ex.Message}"));
}
}
/// <summary>The container's health probe: asks the running server, no engine of its own.</summary>
private static async Task<int> HealthAsync()
{
try
{
using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(5) };
string body = await client.GetStringAsync($"http://127.0.0.1:{WebHost.Port.ToString(CultureInfo.InvariantCulture)}/api/health").ConfigureAwait(false);
bool ok = body.Contains("\"ok\":true", StringComparison.Ordinal);
Console.WriteLine(body);
return ok ? 0 : 1;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
Console.Error.WriteLine($"server non raggiungibile: {ex.Message}");
return 1;
}
}
private static bool Has(string[] args, string flag) => args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase));
private static string? Arg(string[] args, string flag)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals(flag, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
private static int IntArg(string[] args, string flag) =>
int.TryParse(Arg(args, flag), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : 0;
}
+82
View File
@@ -0,0 +1,82 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace Encelado.Server.Web;
/// <summary>Small helpers over <see cref="Utf8JsonWriter"/> and <see cref="JsonDocument"/>: no reflection anywhere in the API.</summary>
internal static class Json
{
public static string Object(Action<Utf8JsonWriter> body)
{
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms))
{
w.WriteStartObject();
body(w);
w.WriteEndObject();
}
return Encoding.UTF8.GetString(ms.ToArray());
}
public static string Result(bool ok, string message, Action<Utf8JsonWriter>? extra = null) => Object(w =>
{
w.WriteBoolean("ok", ok);
w.WriteString("message", message);
extra?.Invoke(w);
});
public static void Num(Utf8JsonWriter w, string name, double v)
{
if (double.IsFinite(v))
{
w.WriteNumber(name, Math.Round(v, 6));
}
else
{
w.WriteNull(name);
}
}
public static void Time(Utf8JsonWriter w, string name, DateTime? t) =>
w.WriteString(name, t is { } v && v != default ? v.ToString("O", CultureInfo.InvariantCulture) : string.Empty);
/// <summary>Reads a small JSON body into flat string pairs (numbers and booleans as their JSON text).</summary>
public static async Task<Dictionary<string, string>> BodyAsync(Stream body, CancellationToken ct)
{
Dictionary<string, string> map = new(StringComparer.OrdinalIgnoreCase);
using StreamReader reader = new(body, Encoding.UTF8);
string text = await reader.ReadToEndAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(text))
{
return map;
}
try
{
using JsonDocument doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
{
return map;
}
foreach (JsonProperty p in doc.RootElement.EnumerateObject())
{
map[p.Name] = p.Value.ValueKind switch
{
JsonValueKind.String => p.Value.GetString() ?? string.Empty,
JsonValueKind.Null => string.Empty,
JsonValueKind.Object => p.Value.GetRawText(),
_ => p.Value.GetRawText(),
};
}
}
catch (JsonException)
{
// An unreadable body is an empty body: the endpoint reports what is missing.
}
return map;
}
}
@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
using EngineLog = Encelado.Engine.Logging.Log;
namespace Encelado.Server.Web;
/// <summary>Routes what Kestrel and the hosting layer log into the bot's own log (warnings and errors only).</summary>
public sealed class LogBridgeProvider : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new Bridge(categoryName);
public void Dispose()
{
// Nothing to release.
}
private sealed class Bridge(string category) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
string message = $"[web] {category}: {formatter(state, exception)}";
if (logLevel >= LogLevel.Error)
{
EngineLog.Error(message, exception);
}
else
{
EngineLog.Warn(message);
}
}
}
}
@@ -0,0 +1,65 @@
using Encelado.Engine;
namespace Encelado.Server.Web;
/// <summary>
/// The lines the Log page can show: a bounded ring of the most recent ones, fed by the
/// supervisor's event stream. The file on disk stays complete; this is the window.
/// </summary>
public sealed class LogBuffer(int capacity)
{
private readonly Lock _gate = new();
private readonly Queue<(long Seq, EventRow Row)> _rows = new();
private readonly int _capacity = Math.Max(100, capacity);
private long _seq;
public void Add(EventRow row)
{
ArgumentNullException.ThrowIfNull(row);
lock (_gate)
{
_rows.Enqueue((++_seq, row));
while (_rows.Count > _capacity)
{
_rows.Dequeue();
}
}
}
/// <summary>The newest lines that pass the filter, oldest first; <paramref name="afterSeq"/> returns only what arrived after a sequence number.</summary>
public (long LastSeq, List<EventRow> Rows) Read(string? level, string? search, int limit, long afterSeq = 0)
{
lock (_gate)
{
IEnumerable<(long Seq, EventRow Row)> q = _rows;
if (afterSeq > 0)
{
q = q.Where(r => r.Seq > afterSeq);
}
if (!string.IsNullOrEmpty(level) && level != "tutti")
{
int min = Rank(level);
q = q.Where(r => Rank(r.Row.Level) >= min);
}
if (!string.IsNullOrWhiteSpace(search))
{
q = q.Where(r => r.Row.Message.Contains(search, StringComparison.OrdinalIgnoreCase));
}
List<(long Seq, EventRow Row)> list = [.. q.TakeLast(Math.Clamp(limit, 1, _capacity))];
return (_seq, [.. list.Select(static r => r.Row)]);
}
}
private static int Rank(string level) => level switch
{
"trace" => 0,
"debug" => 1,
"info" => 2,
"warn" => 3,
"error" => 4,
_ => 0,
};
}
+735
View File
@@ -0,0 +1,735 @@
using System.Globalization;
using System.Net;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Encelado.Core.Baskets;
using Encelado.Core.Baskets.History;
using Encelado.Core.Broker;
using Encelado.Engine;
using Encelado.Engine.Baskets;
using Encelado.Engine.Configuration;
using Encelado.Engine.Logging;
using Encelado.Engine.Settings;
using Encelado.Etoro;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Encelado.Server.Web;
/// <summary>What the web layer needs from the host: the supervisor, the log window, the history and the configuration.</summary>
public sealed class WebContext
{
public required BotConfig Config { get; init; }
public required BotSupervisor Supervisor { get; init; }
public required LogBuffer Log { get; init; }
public required HistoryService History { get; init; }
public required string ConfigPath { get; init; }
/// <summary>Serve the sample snapshot instead of the engine's (<c>--sample</c>): screenshots and UI work without a market.</summary>
public bool Sample { get; init; }
public DateTime StartedUtc { get; init; } = DateTime.UtcNow;
/// <summary>Called by the start endpoint; the host decides how the engine is built (live confirmation, bonifica).</summary>
public required Func<string?, Task<CommandResult>> Start { get; init; }
/// <summary>The learning cycle on demand (Impostazioni → Ricerca).</summary>
public required Func<Task<CommandResult>> Learn { get; init; }
public BotSnapshot Snapshot() => Sample ? SampleSnapshot.Populated() with { Uptime = DateTime.UtcNow - StartedUtc } : Supervisor.Snapshot();
}
/// <summary>
/// The web host of §11 of the 5.0 plan: Kestrel, the embedded UI, a JSON API written
/// by hand, Server-Sent Events with one snapshot per second, commands over POST, and a
/// local token (<c>ENCELADO_WEB_TOKEN</c>) kept in an HttpOnly cookie. Without a token
/// the server listens on localhost only.
/// </summary>
public static class WebHost
{
public const string TokenVariable = "ENCELADO_WEB_TOKEN";
public const string PortVariable = "ENCELADO_WEB_PORT";
public const string CookieName = "encelado_token";
public const int DefaultPort = 8080;
public static int Port => int.TryParse(Environment.GetEnvironmentVariable(PortVariable), NumberStyles.Integer, CultureInfo.InvariantCulture, out int p) && p is > 0 and < 65536 ? p : DefaultPort;
public static string Token => Environment.GetEnvironmentVariable(TokenVariable)?.Trim() ?? string.Empty;
public static WebApplication Build(WebContext ctx, int port, string token, bool listenAll)
{
ArgumentNullException.ThrowIfNull(ctx);
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { ApplicationName = "Encelado" });
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new LogBridgeProvider());
builder.WebHost.ConfigureKestrel(o =>
{
o.AddServerHeader = false;
if (listenAll)
{
o.ListenAnyIP(port);
}
else if (port == 0)
{
// A free port on the loopback: the tests use it (ListenLocalhost refuses a dynamic port).
o.Listen(IPAddress.Loopback, 0);
}
else
{
o.ListenLocalhost(port);
}
});
WebApplication app = builder.Build();
app.Use(Auth(token));
MapStatic(app, token);
MapApi(app, ctx);
return app;
}
// -----------------------------------------------------------------------
// Authentication
// -----------------------------------------------------------------------
private static Func<HttpContext, RequestDelegate, Task> Auth(string token) => async (http, next) =>
{
string path = http.Request.Path.Value ?? "/";
if (token.Length == 0 || path == "/api/health" || path == "/login" || path == "/icon.svg" || path == "/manifest.webmanifest")
{
await next(http).ConfigureAwait(false);
return;
}
bool ok = (http.Request.Cookies.TryGetValue(CookieName, out string? cookie) && Constant(cookie, token))
|| (http.Request.Headers.Authorization.ToString() is { Length: > 7 } auth && auth.StartsWith("Bearer ", StringComparison.Ordinal) && Constant(auth[7..].Trim(), token));
if (ok)
{
await next(http).ConfigureAwait(false);
return;
}
if (path.StartsWith("/api/", StringComparison.Ordinal))
{
http.Response.StatusCode = 401;
await Text(http, Json.Result(false, "token mancante o errato"), "application/json").ConfigureAwait(false);
return;
}
http.Response.Redirect("/login");
};
private static bool Constant(string a, string b)
{
byte[] x = Encoding.UTF8.GetBytes(a);
byte[] y = Encoding.UTF8.GetBytes(b);
return x.Length == y.Length && CryptographicOperations.FixedTimeEquals(x, y);
}
// -----------------------------------------------------------------------
// Static assets (embedded)
// -----------------------------------------------------------------------
private static void MapStatic(WebApplication app, string token)
{
app.MapGet("/", Serve("wwwroot/index.html", "text/html; charset=utf-8"));
app.MapGet("/index.html", Serve("wwwroot/index.html", "text/html; charset=utf-8"));
app.MapGet("/app.css", Serve("wwwroot/app.css", "text/css; charset=utf-8"));
app.MapGet("/app.js", Serve("wwwroot/app.js", "text/javascript; charset=utf-8"));
app.MapGet("/icon.svg", Serve("wwwroot/icon.svg", "image/svg+xml"));
app.MapGet("/manifest.webmanifest", Serve("wwwroot/manifest.webmanifest", "application/manifest+json"));
app.MapGet("/login", Serve("wwwroot/login.html", "text/html; charset=utf-8"));
app.MapPost("/login", async http =>
{
Dictionary<string, string> body = await FormOrJsonAsync(http).ConfigureAwait(false);
if (token.Length > 0 && body.TryGetValue("token", out string? given) && Constant(given.Trim(), token))
{
http.Response.Cookies.Append(CookieName, token, new CookieOptions { HttpOnly = true, SameSite = SameSiteMode.Strict, IsEssential = true, Path = "/", MaxAge = TimeSpan.FromDays(30) });
http.Response.Redirect("/");
return;
}
http.Response.StatusCode = 401;
await Text(http, "<!doctype html><meta charset=utf-8><p>Token errato. <a href=\"/login\">Riprova</a>.</p>", "text/html; charset=utf-8").ConfigureAwait(false);
});
}
/// <summary>Reads an embedded asset. Cached in memory after the first read: the assembly is the file system.</summary>
public static byte[]? Asset(string logicalName)
{
Assembly asm = typeof(WebHost).Assembly;
using Stream? s = asm.GetManifestResourceStream(logicalName);
if (s is null)
{
return null;
}
using MemoryStream ms = new();
s.CopyTo(ms);
return ms.ToArray();
}
private static readonly Dictionary<string, byte[]> AssetCache = [];
private static readonly Lock AssetGate = new();
private static RequestDelegate Serve(string logicalName, string contentType) => async http =>
{
byte[]? bytes;
lock (AssetGate)
{
if (!AssetCache.TryGetValue(logicalName, out bytes))
{
bytes = Asset(logicalName);
if (bytes is not null)
{
AssetCache[logicalName] = bytes;
}
}
}
if (bytes is null)
{
http.Response.StatusCode = 404;
return;
}
http.Response.ContentType = contentType;
http.Response.Headers.CacheControl = "no-cache";
await http.Response.Body.WriteAsync(bytes, http.RequestAborted).ConfigureAwait(false);
};
// -----------------------------------------------------------------------
// API
// -----------------------------------------------------------------------
private static void MapApi(WebApplication app, WebContext ctx)
{
app.MapGet("/api/health", async http => await Text(http, Health(ctx), "application/json").ConfigureAwait(false));
app.MapGet("/api/snapshot", async http => await Text(http, SnapshotJson.Write(ctx.Snapshot()), "application/json").ConfigureAwait(false));
app.MapGet("/api/stream", http => StreamAsync(http, ctx));
app.MapGet("/api/log", async http =>
{
string? level = http.Request.Query["level"];
string? q = http.Request.Query["q"];
int limit = int.TryParse(http.Request.Query["limit"], NumberStyles.Integer, CultureInfo.InvariantCulture, out int l) ? l : 500;
long after = long.TryParse(http.Request.Query["after"], NumberStyles.Integer, CultureInfo.InvariantCulture, out long a) ? a : 0;
(long seq, List<EventRow> rows) = ctx.Log.Read(level, q, limit, after);
await Text(http, Json.Object(w =>
{
w.WriteNumber("lastSeq", seq);
w.WriteString("file", Log.FilePath ?? string.Empty);
w.WriteStartArray("rows");
foreach (EventRow r in rows)
{
w.WriteStartObject();
w.WriteString("time", r.Time);
w.WriteString("level", r.Level);
w.WriteString("message", r.Message);
w.WriteEndObject();
}
w.WriteEndArray();
}), "application/json").ConfigureAwait(false);
});
app.MapPost("/api/commands/{name}", async http => await CommandAsync(http, ctx).ConfigureAwait(false));
app.MapGet("/api/history/orders", async http => await HistoryOrdersAsync(http, ctx).ConfigureAwait(false));
app.MapGet("/api/history/positions", async http => await HistoryPositionsAsync(http, ctx).ConfigureAwait(false));
app.MapGet("/api/history/periods", async http => await HistoryPeriodsAsync(http, ctx).ConfigureAwait(false));
app.MapGet("/api/settings", async http =>
{
EncryptedFileKeyStore store = KeyStores.File(ctx.Config);
bool env = KeyStores.Clean(Environment.GetEnvironmentVariable("ETORO_API_KEY")) is not null;
string keys = env
? "chiavi dalle variabili d'ambiente ETORO_API_KEY e ETORO_USER_KEY (sola lettura)"
: store.Exists
? (store.CanSave ? $"chiavi nel file cifrato {store.Path}" : $"file cifrato {store.Path} presente ma manca {KeyStores.PassphraseVariable}: non leggibile")
: (store.CanSave ? $"nessuna chiave salvata; verrà creato {store.Path}" : $"nessuna chiave: imposta ETORO_API_KEY e ETORO_USER_KEY, oppure {KeyStores.PassphraseVariable} per salvarle qui");
await Text(http, SettingsService.Describe(ctx.Config, ctx.ConfigPath, keys, ctx.Config.Run.StrategyPath), "application/json").ConfigureAwait(false);
});
app.MapPost("/api/settings", async http =>
{
Dictionary<string, string> edits = await Json.BodyAsync(http.Request.Body, http.RequestAborted).ConfigureAwait(false);
(bool ok, string message, int applied) = SettingsService.Apply(ctx.Config, ctx.ConfigPath, edits);
if (ok && applied > 0)
{
// The UI-only values take effect now: the next snapshot carries them.
foreach ((string path, string value) in edits)
{
if (path.Equals("ui.displayCurrency", StringComparison.OrdinalIgnoreCase)) { ctx.Config.Ui.DisplayCurrency = value.Trim().ToUpperInvariant(); }
if (path.Equals("ui.theme", StringComparison.OrdinalIgnoreCase)) { ctx.Config.Ui.Theme = value.Trim().ToLowerInvariant(); }
if (path.Equals("ui.navExpanded", StringComparison.OrdinalIgnoreCase)) { ctx.Config.Ui.NavExpanded = value is "sì" or "si" or "true"; }
}
Log.Info(message);
}
http.Response.StatusCode = ok ? 200 : 400;
await Text(http, Json.Result(ok, message, w => w.WriteNumber("applied", applied)), "application/json").ConfigureAwait(false);
});
app.MapPost("/api/settings/keys", async http => await SaveKeysAsync(http, ctx).ConfigureAwait(false));
app.MapDelete("/api/settings/keys", async http =>
{
bool removed = KeyStores.File(ctx.Config).Clear(ctx.Config.Etoro.IsDemo);
ctx.Config.Etoro.ApiKey = string.Empty;
ctx.Config.Etoro.UserKey = string.Empty;
Log.Info(removed ? "chiavi eToro salvate rimosse" : "non c'erano chiavi eToro salvate da rimuovere");
await Text(http, Json.Result(true, removed ? "chiavi rimosse" : "nessuna chiave salvata da rimuovere"), "application/json").ConfigureAwait(false);
});
app.MapPost("/api/settings/restore", async http =>
{
if (ctx.Supervisor.State is BotState.Running or BotState.Starting)
{
http.Response.StatusCode = 409;
await Text(http, Json.Result(false, "ferma il bot prima di ripristinare la configurazione"), "application/json").ConfigureAwait(false);
return;
}
try
{
string? backup = ConfigDefaults.Restore(ctx.ConfigPath);
Log.Info(backup is null ? "configurazione ripristinata ai valori predefiniti" : $"configurazione ripristinata; la precedente è in {backup}");
await Text(http, Json.Result(true, backup is null ? "configurazione ripristinata; riavvia il container" : $"configurazione ripristinata (copia precedente in {backup}); riavvia il container"), "application/json").ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
http.Response.StatusCode = 500;
await Text(http, Json.Result(false, ex.Message), "application/json").ConfigureAwait(false);
}
});
app.MapGet("/api/info", async http => await Text(http, Info(ctx), "application/json").ConfigureAwait(false));
app.MapGet("/api/research", async http => await Text(http, Research(ctx), "application/json").ConfigureAwait(false));
}
// -----------------------------------------------------------------------
// Streams, commands, history
// -----------------------------------------------------------------------
private static async Task StreamAsync(HttpContext http, WebContext ctx)
{
http.Response.ContentType = "text/event-stream";
http.Response.Headers.CacheControl = "no-cache";
http.Response.Headers.Connection = "keep-alive";
http.Response.Headers["X-Accel-Buffering"] = "no";
CancellationToken ct = http.RequestAborted;
try
{
while (!ct.IsCancellationRequested)
{
string json = SnapshotJson.Write(ctx.Snapshot());
await http.Response.WriteAsync("event: snapshot\ndata: ", ct).ConfigureAwait(false);
await http.Response.WriteAsync(json, ct).ConfigureAwait(false);
await http.Response.WriteAsync("\n\n", ct).ConfigureAwait(false);
await http.Response.Body.FlushAsync(ct).ConfigureAwait(false);
await Task.Delay(1000, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// The client went away.
}
}
private static async Task CommandAsync(HttpContext http, WebContext ctx)
{
string name = http.Request.RouteValues["name"]?.ToString()?.ToLowerInvariant() ?? string.Empty;
Dictionary<string, string> body = await Json.BodyAsync(http.Request.Body, http.RequestAborted).ConfigureAwait(false);
string arg = body.GetValueOrDefault("argument") ?? string.Empty;
string reason = body.GetValueOrDefault("reason") ?? string.Empty;
CancellationToken ct = http.RequestAborted;
CommandResult result;
if (ctx.Sample && name is not ("start" or "stop"))
{
result = new CommandResult(false, "modalità dimostrativa (--sample): i comandi non vengono eseguiti");
}
else
{
result = name switch
{
"start" => await ctx.Start(body.GetValueOrDefault("confirm")).ConfigureAwait(false),
"stop" => await ctx.Supervisor.StopAsync().ConfigureAwait(false),
"close" => await ctx.Supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Close, arg, reason.Length > 0 ? reason : "chiusura manuale dalla web UI"), ct).ConfigureAwait(false),
"kill" => await ctx.Supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, body.GetValueOrDefault("foreign") is "true" or "1" ? "esterne" : string.Empty, "kill-switch dalla web UI"), ct).ConfigureAwait(false),
"preset" => await ctx.Supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.SetPreset, arg, "cambio preset dalla web UI"), ct).ConfigureAwait(false),
"reset" => await ctx.Supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.ResetEquityStop, string.Empty, reason), ct).ConfigureAwait(false),
"residue" => await ctx.Supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.CloseResidue, arg.Length > 0 ? arg : "all", "chiusura dei residui dalla web UI"), ct).ConfigureAwait(false),
"pause" => await ctx.Supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Pause, string.Empty, "pausa dalla web UI"), ct).ConfigureAwait(false),
"resume" => await ctx.Supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Resume, string.Empty, "ripresa dalla web UI"), ct).ConfigureAwait(false),
"bonifica" => await ctx.Supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Bonifica, arg.Length > 0 ? arg : "list", "bonifica dalla web UI"), ct).ConfigureAwait(false),
"learn" => await ctx.Learn().ConfigureAwait(false),
_ => new CommandResult(false, $"comando {name} sconosciuto"),
};
}
http.Response.StatusCode = result.Ok ? 200 : 400;
await Text(http, Json.Result(result.Ok, result.Message, w =>
{
if (result.Payload is IReadOnlyList<PositionInfo> positions)
{
w.WriteStartArray("positions");
foreach (PositionInfo p in positions)
{
SnapshotJson.WritePosition(w, p);
}
w.WriteEndArray();
}
else if (result.Payload is IReadOnlyList<string> steps)
{
w.WriteStartArray("steps");
foreach (string s in steps)
{
w.WriteStringValue(s);
}
w.WriteEndArray();
}
}), "application/json").ConfigureAwait(false);
}
private static (DateTime From, DateTime To) Range(HttpContext http)
{
DateTime to = DateTime.TryParse(http.Request.Query["to"], CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t) ? t : DateTime.UtcNow.AddDays(1);
DateTime from = DateTime.TryParse(http.Request.Query["from"], CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime f) ? f : DateTime.UtcNow.AddDays(-90);
return (from, to);
}
private static async Task HistoryOrdersAsync(HttpContext http, WebContext ctx)
{
(List<PositionRecord> _, List<OrderRecord> lines) = await ctx.History.LoadAsync(DateTime.UtcNow.AddDays(-90), http.RequestAborted).ConfigureAwait(false);
(DateTime from, DateTime to) = Range(http);
string? basket = http.Request.Query["basket"];
string? symbol = http.Request.Query["symbol"];
string? outcome = http.Request.Query["outcome"];
int limit = int.TryParse(http.Request.Query["limit"], NumberStyles.Integer, CultureInfo.InvariantCulture, out int l) ? l : 500;
List<OrderRecord> rows = [.. HistoryBuilder.LatestOrders(lines)
.Where(o => o.Ts >= from && o.Ts < to)
.Where(o => string.IsNullOrEmpty(basket) || o.Basket.Equals(basket, StringComparison.OrdinalIgnoreCase))
.Where(o => string.IsNullOrEmpty(symbol) || o.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase))
.Where(o => string.IsNullOrEmpty(outcome) || o.Resolution.ToString().Equals(outcome, StringComparison.OrdinalIgnoreCase))
.Take(Math.Clamp(limit, 1, 5000))];
if (http.Request.Query["format"] == "csv")
{
await Csv(http, "ordini.csv", HistoryBuilder.OrdersCsv(rows)).ConfigureAwait(false);
return;
}
await Text(http, Json.Object(w =>
{
w.WriteString("problem", ctx.History.LastProblem);
w.WriteStartArray("rows");
foreach (OrderRecord o in rows)
{
w.WriteStartObject();
Json.Time(w, "ts", o.Ts);
w.WriteString("basketId", o.BasketId);
w.WriteString("basket", o.Basket);
w.WriteString("symbol", o.Symbol);
w.WriteString("side", o.IsBuy ? "long" : "short");
w.WriteString("leg", o.Leg.ToString());
Json.Num(w, "requestedUnits", o.RequestedUnits);
Json.Num(w, "executedUnits", o.ExecutedUnits);
Json.Num(w, "requestedPrice", o.RequestedPrice);
Json.Num(w, "fillRate", o.FillRate);
Json.Num(w, "slippagePips", o.SlippagePips);
w.WriteString("status", o.Status);
w.WriteString("resolution", o.Resolution.ToString());
w.WriteNumber("orderId", o.OrderId);
w.WriteNumber("positionId", o.PositionId);
Json.Num(w, "fees", o.Fees);
w.WriteString("motivazione", o.Motivazione);
w.WriteEndObject();
}
w.WriteEndArray();
}), "application/json").ConfigureAwait(false);
}
private static async Task HistoryPositionsAsync(HttpContext http, WebContext ctx)
{
(List<PositionRecord> positions, _) = await ctx.History.LoadAsync(DateTime.UtcNow.AddDays(-90), http.RequestAborted).ConfigureAwait(false);
(DateTime from, DateTime to) = Range(http);
string? origin = http.Request.Query["origin"];
List<PositionRecord> rows = [.. positions
.Where(p => p.IsOpen || ((p.ClosedUtc ?? p.OpenedUtc) >= from && (p.ClosedUtc ?? p.OpenedUtc) < to))
.Where(p => string.IsNullOrEmpty(origin) || p.Origin.Equals(origin, StringComparison.OrdinalIgnoreCase))];
if (http.Request.Query["format"] == "csv")
{
await Csv(http, "posizioni.csv", HistoryBuilder.PositionsCsv(rows)).ConfigureAwait(false);
return;
}
await Text(http, Json.Object(w =>
{
w.WriteString("problem", ctx.History.LastProblem);
w.WriteStartArray("rows");
foreach (PositionRecord p in rows)
{
w.WriteStartObject();
w.WriteNumber("positionId", p.PositionId);
w.WriteString("symbol", p.Symbol);
w.WriteString("side", p.IsBuy ? "long" : "short");
Json.Num(w, "units", p.Units);
Json.Num(w, "openRate", p.OpenRate);
Json.Num(w, "closeRate", p.CloseRate);
Json.Time(w, "openedUtc", p.OpenedUtc);
Json.Time(w, "closedUtc", p.ClosedUtc);
Json.Num(w, "pnlGrossUsd", p.PnlGrossUsd);
Json.Num(w, "feesUsd", p.FeesUsd);
Json.Num(w, "pnlNetUsd", p.PnlNetUsd);
Json.Num(w, "pips", p.Pips);
w.WriteString("origin", p.Origin);
w.WriteString("basketId", p.BasketId);
w.WriteString("basket", p.Basket);
w.WriteString("exitReason", p.ExitReason);
w.WriteBoolean("isOpen", p.IsOpen);
Json.Num(w, "durationMinutes", p.DurationMinutes);
w.WriteString("motivazione", p.Motivazione);
w.WriteEndObject();
}
w.WriteEndArray();
}), "application/json").ConfigureAwait(false);
}
private static async Task HistoryPeriodsAsync(HttpContext http, WebContext ctx)
{
(List<PositionRecord> positions, _) = await ctx.History.LoadAsync(DateTime.UtcNow.AddDays(-400), http.RequestAborted).ConfigureAwait(false);
(DateTime From, DateTime To)? custom = http.Request.Query.ContainsKey("from") && http.Request.Query.ContainsKey("to") ? Range(http) : null;
List<PeriodStats> periods = HistoryBuilder.Periods(positions, DateTime.UtcNow, custom);
if (http.Request.Query["format"] == "csv")
{
await Csv(http, "periodi.csv", HistoryBuilder.PeriodsCsv(periods)).ConfigureAwait(false);
return;
}
List<EquityPoint> curve = HistoryBuilder.EquityCurve(positions);
await Text(http, Json.Object(w =>
{
w.WriteString("problem", ctx.History.LastProblem);
w.WriteString("equitySvg", HistoryBuilder.EquitySvg(curve));
w.WriteStartArray("rows");
foreach (PeriodStats p in periods)
{
w.WriteStartObject();
w.WriteString("periodo", p.Periodo);
Json.Time(w, "fromUtc", p.FromUtc);
Json.Time(w, "toUtc", p.ToUtc);
w.WriteNumber("nBasket", p.NBasket);
w.WriteNumber("nPosizioni", p.NPosizioni);
w.WriteNumber("vinti", p.Vinti);
w.WriteNumber("persi", p.Persi);
Json.Num(w, "winRate", p.WinRate);
Json.Num(w, "pnlLordo", p.PnlLordo);
Json.Num(w, "fee", p.Fee);
Json.Num(w, "pnlNetto", p.PnlNetto);
Json.Num(w, "mediaPerBasket", p.MediaPerBasket);
Json.Num(w, "maxDd", p.MaxDrawdownUsd);
Json.Num(w, "movimentiDiCassa", p.MovimentiDiCassa);
w.WriteString("motivazione", p.Motivazione);
w.WriteEndObject();
}
w.WriteEndArray();
}), "application/json").ConfigureAwait(false);
}
private static async Task SaveKeysAsync(HttpContext http, WebContext ctx)
{
Dictionary<string, string> body = await Json.BodyAsync(http.Request.Body, http.RequestAborted).ConfigureAwait(false);
string? api = KeyStores.Clean(body.GetValueOrDefault("apiKey"));
string? user = KeyStores.Clean(body.GetValueOrDefault("userKey"));
if (api is null || user is null)
{
http.Response.StatusCode = 400;
await Text(http, Json.Result(false, "servono entrambe le chiavi (x-api-key e x-user-key)"), "application/json").ConfigureAwait(false);
return;
}
EncryptedFileKeyStore store = KeyStores.File(ctx.Config);
if (!store.CanSave)
{
http.Response.StatusCode = 400;
await Text(http, Json.Result(false, $"nessuna passphrase nell'ambiente ({KeyStores.PassphraseVariable}): le chiavi non possono essere salvate su file; usa ETORO_API_KEY e ETORO_USER_KEY"), "application/json").ConfigureAwait(false);
return;
}
// Verified before being saved: a wrong pair costs a 401 at the next start, and that is the worst moment.
EtoroOptions options = new() { Environment = ctx.Config.Etoro.Environment, BaseUrl = ctx.Config.Etoro.BaseUrl, ApiKey = api, UserKey = user, UserAgent = ctx.Config.Etoro.UserAgent };
try
{
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted);
cts.CancelAfter(TimeSpan.FromSeconds(20));
await using EtoroBroker broker = new(options);
string username = await broker.VerifyAsync(cts.Token).ConfigureAwait(false);
AccountSnapshot account = await broker.GetAccountAsync(cts.Token).ConfigureAwait(false);
store.Save(ctx.Config.Etoro.IsDemo, new EtoroKeys(api, user, DateTime.UtcNow));
ctx.Config.Etoro.ApiKey = api;
ctx.Config.Etoro.UserKey = user;
Log.Info($"chiavi eToro verificate ({KeyStores.Mask(api)}, utente {username}) e salvate in {store.Path}");
await Text(http, Json.Result(true, string.Create(CultureInfo.InvariantCulture, $"chiavi verificate: utente {username}, equity {account.Equity:N2} {account.Currency}; salvate in {store.Path}")), "application/json").ConfigureAwait(false);
}
catch (Exception ex) when (ex is BrokerException or HttpRequestException or OperationCanceledException or InvalidOperationException)
{
http.Response.StatusCode = 400;
await Text(http, Json.Result(false, $"eToro ha rifiutato le chiavi o non risponde: {ex.Message}"), "application/json").ConfigureAwait(false);
}
}
// -----------------------------------------------------------------------
// Health, info, research
// -----------------------------------------------------------------------
private static string Health(WebContext ctx)
{
BotSnapshot s = ctx.Snapshot();
string heartbeatPath = Path.Combine(ctx.Config.Run.DataPath, "state", "heartbeat.json");
Heartbeat? hb = HeartbeatFile.Read(heartbeatPath);
double age = hb is null ? -1 : (DateTime.UtcNow - hb.Utc).TotalSeconds;
bool running = s.State == BotState.Running;
bool heartbeatFresh = !running || (hb is not null && age < 3 * 30 + 15);
bool disk = DiskWritable(ctx.Config.Run.DataPath);
bool ok = disk && heartbeatFresh;
return Json.Object(w =>
{
w.WriteBoolean("ok", ok);
w.WriteString("state", s.State.ToString().ToLowerInvariant());
w.WriteString("apiState", s.ApiState);
w.WriteNumber("heartbeatAgeSeconds", Math.Round(age));
w.WriteBoolean("diskWritable", disk);
w.WriteNumber("uptimeSeconds", Math.Round((DateTime.UtcNow - ctx.StartedUtc).TotalSeconds));
});
}
private static bool DiskWritable(string directory)
{
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)
{
return false;
}
}
private static string Info(WebContext ctx)
{
Assembly asm = typeof(WebHost).Assembly;
string version = asm.GetName().Version?.ToString(3) ?? "?";
string informational = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? version;
string commit = asm.GetCustomAttributes<AssemblyMetadataAttribute>().FirstOrDefault(static a => a.Key == "Commit")?.Value ?? "n/d";
string built = asm.GetCustomAttributes<AssemblyMetadataAttribute>().FirstOrDefault(static a => a.Key == "BuildDate")?.Value ?? "n/d";
BotSnapshot s = ctx.Snapshot();
return Json.Object(w =>
{
w.WriteString("name", "Encelado");
w.WriteString("author", "Alberto Balbo");
w.WriteString("version", version);
w.WriteString("informationalVersion", informational);
w.WriteString("commit", commit);
w.WriteString("buildDate", built);
w.WriteString("license", "uso privato dell'autore; nessuna licenza di ridistribuzione");
w.WriteString("docs", "https://github.com/ (vedi docs/ nel repository Gitea)");
w.WriteString("dotnet", Environment.Version.ToString());
w.WriteString("os", Environment.OSVersion.ToString());
w.WriteBoolean("container", AppPaths.InContainer);
w.WriteString("strategyVersion", s.StrategyVersion);
w.WriteString("configPath", ctx.ConfigPath);
w.WriteString("strategyPath", ctx.Config.Run.StrategyPath);
w.WriteString("dataPath", ctx.Config.Run.DataPath);
w.WriteString("knowledgePath", ctx.Config.Run.KnowledgePath);
w.WriteString("reportsPath", ctx.Config.Run.ReportsPath);
w.WriteString("logPath", Log.FilePath ?? ctx.Config.Logging.ResolveDirectory());
w.WriteNumber("uptimeSeconds", Math.Round((DateTime.UtcNow - ctx.StartedUtc).TotalSeconds));
w.WriteString("counters", s.Counters);
w.WriteString("timeZone", UiClock.ZoneName);
w.WriteString("timeZoneLabel", UiClock.Label);
});
}
private static string Research(WebContext ctx)
{
BotSnapshot s = ctx.Snapshot();
BasketStrategyConfig? strategy = null;
try
{
strategy = File.Exists(ctx.Config.Run.StrategyPath) ? BasketStrategyConfig.Load(ctx.Config.Run.StrategyPath, out _) : null;
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or JsonException)
{
Log.Warn($"strategy.json non leggibile: {ex.Message}");
}
string knowledge = ctx.Config.Run.KnowledgePath;
return Json.Object(w =>
{
w.WriteBoolean("learningEnabled", strategy?.Learning.Enabled ?? false);
w.WriteBoolean("weeklyCycle", strategy?.Learning.WeeklyCycle ?? false);
w.WriteBoolean("challenger", strategy?.Learning.Challenger ?? false);
w.WriteString("mlState", s.Context?.MlState ?? "—");
w.WriteString("banditProposal", s.Context?.BanditProposal ?? "—");
w.WriteString("volForecast", s.Context?.VolForecast ?? "—");
w.WriteString("criterion", "riattivazione (ADR-0006): almeno 300 basket chiusi in Demo e P&L netto forward ≥ 0 sulla pre-registrazione");
w.WriteStartArray("knowledgeFiles");
if (Directory.Exists(knowledge))
{
foreach (string f in Directory.GetFiles(knowledge).OrderBy(static f => f, StringComparer.Ordinal))
{
FileInfo fi = new(f);
w.WriteStartObject();
w.WriteString("name", fi.Name);
w.WriteNumber("bytes", fi.Length);
w.WriteString("modifiedUtc", fi.LastWriteTimeUtc.ToString("O", CultureInfo.InvariantCulture));
w.WriteEndObject();
}
}
w.WriteEndArray();
});
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static async Task Text(HttpContext http, string body, string contentType)
{
http.Response.ContentType = contentType;
http.Response.Headers.CacheControl = "no-store";
await http.Response.WriteAsync(body, http.RequestAborted).ConfigureAwait(false);
}
private static async Task Csv(HttpContext http, string fileName, string csv)
{
http.Response.ContentType = "text/csv; charset=utf-8";
http.Response.Headers.ContentDisposition = $"attachment; filename=\"{fileName}\"";
await http.Response.WriteAsync(csv, http.RequestAborted).ConfigureAwait(false);
}
private static async Task<Dictionary<string, string>> FormOrJsonAsync(HttpContext http)
{
if (http.Request.HasFormContentType)
{
Microsoft.AspNetCore.Http.IFormCollection form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
return form.ToDictionary(static f => f.Key, static f => f.Value.ToString(), StringComparer.OrdinalIgnoreCase);
}
return await Json.BodyAsync(http.Request.Body, http.RequestAborted).ConfigureAwait(false);
}
}
@@ -0,0 +1,285 @@
/* Encelado — Material Design 3 senza librerie. I token sono documentati in docs/UI_GUIDELINES.md. */
:root {
--font: Roboto, "Segoe UI", system-ui, -apple-system, sans-serif;
--mono: "Cascadia Mono", "JetBrains Mono", Consolas, ui-monospace, monospace;
--radius: 12px;
--radius-lg: 16px;
--rail-w: 80px;
--rail-w-open: 256px;
--topbar-h: 56px;
--gap: 16px;
--dur: 180ms;
--ease: cubic-bezier(.2,0,0,1);
/* Dark scheme (default) */
--primary: #adc6ff;
--on-primary: #002e69;
--primary-container: #1f4e9c;
--on-primary-container: #d8e2ff;
--surface: #101418;
--surface-dim: #101418;
--surface-bright: #363a3e;
--surface-container-lowest: #0b0e12;
--surface-container-low: #181c20;
--surface-container: #1c2024;
--surface-container-high: #262a2f;
--surface-container-highest: #31353a;
--on-surface: #e0e2e8;
--on-surface-variant: #c3c6d0;
--outline: #8d9099;
--outline-variant: #43474e;
--error: #ffb4ab;
--on-error: #690005;
--error-container: #93000a;
--on-error-container: #ffdad6;
--up: #7fd39a;
--down: #ff8a80;
--warn: #f5b74f;
--scrim: rgba(0,0,0,.5);
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 2px 8px rgba(0,0,0,.25);
}
:root[data-theme="light"] {
--primary: #005ac1;
--on-primary: #ffffff;
--primary-container: #d8e2ff;
--on-primary-container: #001a41;
--surface: #f8f9ff;
--surface-dim: #d9dae0;
--surface-bright: #f8f9ff;
--surface-container-lowest: #ffffff;
--surface-container-low: #f2f3f9;
--surface-container: #eceef4;
--surface-container-high: #e6e8ee;
--surface-container-highest: #e0e2e8;
--on-surface: #191c20;
--on-surface-variant: #43474e;
--outline: #74777f;
--outline-variant: #c3c6d0;
--error: #ba1a1a;
--on-error: #ffffff;
--error-container: #ffdad6;
--on-error-container: #410002;
--up: #1b7f3b;
--down: #c62828;
--warn: #9a6400;
--scrim: rgba(0,0,0,.35);
--shadow: 0 1px 2px rgba(0,0,0,.15), 0 2px 6px rgba(0,0,0,.08);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) { color-scheme: light; }
}
* { box-sizing: border-box; }
html { height: 100%; }
body {
margin: 0; min-height: 100%; font-family: var(--font); font-size: 14px; line-height: 1.45;
background: var(--surface); color: var(--on-surface);
font-variant-numeric: tabular-nums;
}
.num, .data td.num, .data th.num, .value, .clock, .log td { font-variant-numeric: tabular-nums; }
.data td.num, .data th.num { text-align: right; }
h1, h2, h3 { margin: 0; font-weight: 500; }
h2 { font-size: 16px; letter-spacing: .1px; }
a { color: var(--primary); }
code { font-family: var(--mono); font-size: .92em; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.skip { position: absolute; left: -999px; top: 8px; background: var(--primary); color: var(--on-primary); padding: 8px 12px; border-radius: 8px; z-index: 100; }
.skip:focus { left: 8px; }
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
[hidden] { display: none !important; }
/* ---------- Navigation rail ---------- */
.rail {
position: fixed; inset: 0 auto 0 0; width: var(--rail-w); background: var(--surface-container-low);
display: flex; flex-direction: column; align-items: stretch; padding: 8px 0; gap: 4px; z-index: 30;
transition: width var(--dur) var(--ease), transform var(--dur) var(--ease);
}
body.nav-open .rail { width: var(--rail-w-open); }
.rail .label { display: none; white-space: nowrap; }
body.nav-open .rail .label { display: inline; }
body.nav-open .rail .short { display: none; }
.rail .brand { display: flex; align-items: center; gap: 12px; padding: 8px 26px; font-weight: 500; font-size: 16px; color: var(--on-surface); }
.rail-items { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.rail-item {
display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 10px 8px; margin: 0 12px;
border-radius: 999px; color: var(--on-surface-variant); text-decoration: none; font-size: 12px; position: relative;
}
body.nav-open .rail-item { flex-direction: row; gap: 12px; padding: 12px 16px; font-size: 14px; }
.rail-item svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.rail-item:hover { background: color-mix(in srgb, var(--on-surface) 8%, transparent); }
.rail-item[aria-current="page"] { background: var(--primary-container); color: var(--on-primary-container); }
.rail-bottom { margin-top: auto; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 8px; }
body.nav-open .rail-bottom { align-items: flex-start; padding-left: 20px; }
.icon-btn { background: none; border: 0; color: var(--on-surface-variant); width: 40px; height: 40px; border-radius: 50%; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.icon-btn svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.icon-btn:hover { background: color-mix(in srgb, var(--on-surface) 8%, transparent); }
.icon-btn.menu { margin: 0 auto; }
body.nav-open .icon-btn.menu { margin: 0 0 0 20px; }
.scrim { position: fixed; inset: 0; background: var(--scrim); z-index: 25; }
/* ---------- Shell and top bar ---------- */
.shell { margin-left: var(--rail-w); min-height: 100vh; transition: margin-left var(--dur) var(--ease); }
body.nav-open .shell { margin-left: var(--rail-w-open); }
.top-bar {
position: sticky; top: 0; z-index: 20; height: var(--topbar-h); display: flex; align-items: center; gap: 12px; padding: 0 16px;
background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--outline-variant);
}
.top-bar .title { font-size: 20px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.clocks { display: flex; gap: 12px; color: var(--on-surface-variant); font-size: 13px; }
.progress { height: 3px; background: transparent; overflow: hidden; }
.progress .bar { height: 100%; width: 30%; background: var(--primary); transform: translateX(-100%); }
.progress.active .bar { animation: slide 1.2s infinite linear; }
@keyframes slide { to { transform: translateX(400%); } }
.content { padding: var(--gap); max-width: 1500px; }
.content:focus, .content:focus-visible { outline: none; } /* focused by the router for screen readers, never a ring around the whole page */
.page { display: flex; flex-direction: column; gap: var(--gap); }
/* ---------- Buttons, chips, inputs ---------- */
.btn {
font: inherit; font-weight: 500; letter-spacing: .1px; border-radius: 999px; padding: 0 20px; height: 40px; border: 0; cursor: pointer;
background: transparent; color: var(--primary); display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; position: relative; overflow: hidden;
}
.btn::after { content: ""; position: absolute; inset: 0; background: currentColor; opacity: 0; transition: opacity var(--dur); }
.btn:hover::after { opacity: .08; }
.btn:active::after { opacity: .12; }
.btn:disabled { opacity: .38; cursor: not-allowed; }
.btn.filled { background: var(--primary); color: var(--on-primary); }
.btn.tonal { background: var(--surface-container-highest); color: var(--on-surface); }
.btn.outlined { border: 1px solid var(--outline); }
.btn.text { padding: 0 12px; }
.btn.danger { color: var(--error); }
.btn.filled.danger { background: var(--error-container); color: var(--on-error-container); }
.btn.power.running { background: var(--surface-container-highest); color: var(--on-surface); }
.btn.small { height: 32px; padding: 0 12px; font-size: 13px; }
.chip { display: inline-flex; align-items: center; height: 28px; padding: 0 10px; border-radius: 8px; font-size: 12px; font-weight: 500; letter-spacing: .3px; border: 1px solid var(--outline-variant); color: var(--on-surface-variant); }
.chip.env.demo { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: transparent; }
.chip.env.paper { background: color-mix(in srgb, var(--primary) 18%, transparent); color: var(--primary); border-color: transparent; }
.chip.env.live { background: var(--error-container); color: var(--on-error-container); border-color: transparent; }
.chip.state.running { color: var(--up); }
.chip.state.halted { color: var(--down); }
.chip.state.pending { color: var(--warn); }
.chip.st { height: 24px; padding: 0 8px; font-size: 11px; text-transform: lowercase; }
.chip.st.open, .chip.st.running { background: color-mix(in srgb, var(--up) 18%, transparent); color: var(--up); border-color: transparent; }
.chip.st.pending { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: transparent; }
.chip.st.error { background: var(--error-container); color: var(--on-error-container); border-color: transparent; }
.chip.st.off { opacity: .6; }
select, input[type="text"], input[type="search"], input[type="password"], input[type="date"], input[type="number"] {
font: inherit; color: var(--on-surface); background: var(--surface-container-lowest); border: 1px solid var(--outline); border-radius: 8px; height: 36px; padding: 0 10px; min-width: 0;
}
select:disabled, input:disabled { opacity: .5; }
label { display: inline-flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--on-surface-variant); }
.switch { flex-direction: row; align-items: center; gap: 8px; }
.currency-pick select { height: 32px; }
/* ---------- Cards ---------- */
.card { background: var(--surface-container); border-radius: var(--radius); padding: 16px; box-shadow: none; }
.card.kpi { padding: 14px 16px; background: var(--surface-container-high); }
.card.kpi .label { font-size: 12px; color: var(--on-surface-variant); letter-spacing: .3px; }
.card.kpi .value { font-size: 24px; font-weight: 500; margin: 4px 0 2px; white-space: nowrap; }
.card .sub, .sub { font-size: 12px; color: var(--on-surface-variant); }
.card .body { font-size: 14px; margin: 6px 0; }
.card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; flex-wrap: wrap; }
.card-head h2 { flex: 1; }
.card-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.card-actions .sub { margin-right: auto; }
.kpi-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.context-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.settings-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; align-items: start; }
.settings-grid > #settings-card { grid-row: span 6; }
.up { color: var(--up); } .down { color: var(--down); } .warn { color: var(--warn); }
.banner { border-radius: var(--radius); padding: 10px 14px; background: var(--error-container); color: var(--on-error-container); display: flex; align-items: center; gap: 12px; }
.banner.warn { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--on-surface); border: 1px solid color-mix(in srgb, var(--warn) 50%, transparent); }
.banner-actions { margin-left: auto; }
.hint { font-size: 12px; color: var(--warn); }
details.card summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px; }
details.card summary::before { content: "▸"; color: var(--on-surface-variant); transition: transform var(--dur); }
details.card[open] summary::before { transform: rotate(90deg); }
details.card summary h2 { display: inline; }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0 0; font-size: 13px; }
.kv dt { color: var(--on-surface-variant); }
.kv dd { margin: 0; overflow-wrap: anywhere; }
/* ---------- Tables ---------- */
.table-wrap { overflow: auto; max-height: 60vh; border-radius: 8px; }
.data { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 13px; }
.data.compact th, .data.compact td { padding: 6px 10px; }
.data th { position: sticky; top: 0; background: var(--surface-container-high); text-align: left; font-weight: 500; color: var(--on-surface-variant); z-index: 1; border-bottom: 1px solid var(--outline-variant); }
.data td { border-bottom: 1px solid var(--outline-variant); vertical-align: middle; }
.data tbody tr:hover { background: color-mix(in srgb, var(--on-surface) 5%, transparent); }
.data tr.disabled { opacity: .55; }
.data .mono { font-family: var(--mono); font-size: 12px; }
.log-wrap { overflow: auto; max-height: calc(100vh - 220px); }
.log td { font-family: var(--mono); font-size: 12px; padding: 2px 8px; border-bottom: 0; white-space: pre-wrap; }
.log tr.warn td { color: var(--warn); } .log tr.error td { color: var(--down); } .log tr.debug td, .log tr.trace td { color: var(--on-surface-variant); }
.log td.t { color: var(--on-surface-variant); white-space: nowrap; }
.cards-list { display: none; flex-direction: column; gap: 8px; }
.basket-card { background: var(--surface-container-high); border-radius: var(--radius); padding: 12px; display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; }
.basket-card .name { font-weight: 500; }
.basket-card .metrics { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; font-size: 12px; color: var(--on-surface-variant); }
.basket-card .metrics b { display: block; color: var(--on-surface); font-weight: 500; }
.activity-list { list-style: none; margin: 8px 0 0; padding: 0; max-height: 240px; overflow: auto; font-family: var(--mono); font-size: 12px; }
.activity-list li { padding: 2px 0; display: grid; grid-template-columns: 70px 1fr; gap: 8px; }
.activity-list li.warn { color: var(--warn); } .activity-list li.error { color: var(--down); }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--outline-variant); }
.tab { background: none; border: 0; color: var(--on-surface-variant); font: inherit; font-weight: 500; padding: 12px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
.tab[aria-selected="true"] { color: var(--primary); border-bottom-color: var(--primary); }
.filters { gap: 10px; }
.equity-chart { color: var(--primary); margin: 8px 0 12px; }
.equity-chart svg { width: 100%; height: 160px; display: block; }
/* ---------- Settings form ---------- */
.setting-group { margin: 12px 0 20px; }
.setting-group h3 { font-size: 14px; margin-bottom: 2px; }
.setting-group .desc { font-size: 12px; color: var(--on-surface-variant); margin-bottom: 10px; }
.setting-row { display: grid; grid-template-columns: 220px 260px 1fr; gap: 10px; align-items: center; padding: 6px 0; }
.setting-row .lbl { font-size: 13px; color: var(--on-surface-variant); }
.setting-row .suffix { font-size: 12px; color: var(--on-surface-variant); }
.setting-row .err { font-size: 12px; color: var(--down); }
.setting-row input.dirty, .setting-row select.dirty { border-color: var(--primary); }
.setting-row input.bad, .setting-row select.bad { border-color: var(--down); }
.form-row { margin: 8px 0; } .form-row label { width: 100%; } .form-row input { width: 100%; }
/* ---------- Dialogs and snackbar ---------- */
.dialog { border: 0; border-radius: 28px; background: var(--surface-container-high); color: var(--on-surface); padding: 24px; max-width: 560px; width: min(92vw, 560px); box-shadow: var(--shadow); }
.dialog.wide { max-width: 720px; width: min(92vw, 720px); }
.dialog::backdrop { background: var(--scrim); }
.dialog h2 { font-size: 22px; font-weight: 400; margin-bottom: 12px; }
.dialog p { margin: 8px 0; color: var(--on-surface-variant); }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.prompt-label { width: 100%; } .prompt-label input { width: 100%; }
.dialog ul.plain { margin: 6px 0; padding-left: 18px; font-family: var(--mono); font-size: 12px; }
.steps { padding-left: 20px; margin: 8px 0; } .steps li { margin: 8px 0; } .steps li.ko { color: var(--down); } .steps li.ok { color: var(--up); }
.snackbar { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: var(--surface-container-highest); color: var(--on-surface); padding: 12px 18px; border-radius: 8px; box-shadow: var(--shadow); z-index: 50; max-width: 90vw; }
.snackbar.error { background: var(--error-container); color: var(--on-error-container); }
/* ---------- Login ---------- */
.login-body { display: grid; place-items: center; min-height: 100vh; }
.login-card { width: min(92vw, 420px); display: flex; flex-direction: column; gap: 12px; align-items: flex-start; }
.login-card form { display: flex; flex-direction: column; gap: 12px; width: 100%; } .login-card input { width: 100%; }
/* ---------- Window classes (M3): compact < 600, medium < 840, expanded ---------- */
@media (max-width: 839px) {
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.context-grid { grid-template-columns: 1fr; }
.settings-grid { grid-template-columns: 1fr; }
.setting-row { grid-template-columns: 1fr; gap: 4px; }
}
@media (max-width: 599px) {
:root { --gap: 12px; }
.rail { transform: translateX(-100%); width: var(--rail-w-open); }
body.nav-open .rail { transform: none; }
.shell, body.nav-open .shell { margin-left: 0; }
.top-bar { padding-left: 56px; }
.top-bar .clocks { display: none; }
.menu-fab { position: fixed; top: 8px; left: 8px; z-index: 21; }
.kpi-grid { grid-template-columns: 1fr 1fr; }
#baskets-table { display: none; }
.cards-list { display: flex; }
.card { padding: 12px; }
.content { padding-left: 16px; padding-right: 16px; }
}
@media (min-width: 600px) { .menu-fab { display: none; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }
@@ -0,0 +1,414 @@
/* Encelado — interfaccia web. Vanilla JS, nessuna libreria. Legge lo snapshot via SSE
(uno al secondo) e manda comandi via POST /api/commands/<nome>. Ogni importo arriva in
USD e viene convertito solo qui, con il tasso dello snapshot, per la visualizzazione. */
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const state = { snapshot: null, page: "dashboard", currency: "USD", zone: "", lastLogSeq: 0, tab: "ordini", settings: null, dirty: {}, busy: false };
const DEC = { USD: 2, EUR: 2, GBP: 2, CHF: 2, JPY: 0, AUD: 2, CAD: 2, NZD: 2 };
const SYM = { USD: "$", EUR: "€", GBP: "£", CHF: "CHF ", JPY: "¥", AUD: "A$", CAD: "C$", NZD: "NZ$" };
// ---------------------------------------------------------------- helpers
const fmtNum = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v)) ? "—" : Number(v).toLocaleString("it-IT", { minimumFractionDigits: d, maximumFractionDigits: d });
const fmtSigned = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v)) ? "—" : (v > 0 ? "+" : "") + fmtNum(v, d);
const fmtPct = (v) => (v === null || v === undefined) ? "—" : (v * 100).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " %";
const cls = (v) => v > 0 ? "up" : v < 0 ? "down" : "";
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const rate = () => {
const s = state.snapshot; const c = state.currency;
if (!s || c === "USD" || !s.fxRates || !s.fxRates[c]) return { r: 1, age: 0, known: c === "USD" };
return { r: s.fxRates[c].usdPerUnit, age: s.fxRates[c].ageSeconds, known: true };
};
/** Converts USD to the display currency; the title carries the USD value and the rate. */
const money = (usd, signed = false) => {
if (usd === null || usd === undefined) return "—";
const { r, age, known } = rate(); const c = known ? state.currency : "USD"; const d = DEC[c] ?? 2;
const v = known ? usd / r : usd;
return (signed ? fmtSigned(v, d) : fmtNum(v, d)) + (c === "USD" ? "" : " " + c);
};
const moneyTitle = (usd) => {
const { r, age, known } = rate();
if (!known || state.currency === "USD") return `${fmtNum(usd)} USD`;
const ageText = age > 120 ? ` (tasso di ${Math.round(age / 60)} minuti fa)` : "";
return `${fmtNum(usd)} USD · tasso 1 ${state.currency} = ${fmtNum(r, 5)} USD${ageText}`;
};
const timeInZone = (iso) => {
if (!iso) return "—";
const d = new Date(iso);
try { return d.toLocaleString("it-IT", { timeZone: state.zone || undefined, hour12: false }); } catch { return d.toLocaleString("it-IT", { hour12: false }); }
};
const shortTime = (iso) => {
if (!iso) return "—";
const d = new Date(iso);
try { return d.toLocaleString("it-IT", { timeZone: state.zone || undefined, day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }); } catch { return d.toLocaleString("it-IT"); }
};
const snack = (text, error = false) => {
const el = $("snackbar"); el.textContent = text; el.className = "snackbar" + (error ? " error" : ""); el.hidden = false;
clearTimeout(snack.t); snack.t = setTimeout(() => { el.hidden = true; }, error ? 8000 : 4000);
};
const busy = (on) => { state.busy = on; $("progress").classList.toggle("active", on); $("progress").setAttribute("aria-hidden", on ? "false" : "true"); };
const api = async (path, opts = {}) => {
const res = await fetch(path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts));
if (res.status === 401) { location.href = "/login"; throw new Error("accesso richiesto"); }
const text = await res.text();
try { return { ok: res.ok, data: JSON.parse(text) }; } catch { return { ok: res.ok, data: { message: text } }; }
};
const command = async (name, body = {}) => {
busy(true);
try {
const { ok, data } = await api(`/api/commands/${name}`, { method: "POST", body: JSON.stringify(body) });
snack(data.message || (ok ? "fatto" : "errore"), !ok);
return { ok, data };
} catch (e) { snack(String(e.message || e), true); return { ok: false, data: {} }; }
finally { busy(false); }
};
// ---------------------------------------------------------------- dialogs
const confirm = (title, text, extraHtml = "", okLabel = "Conferma", danger = false) => new Promise((resolve) => {
const dlg = $("dlg-confirm"); $("dlg-confirm-title").textContent = title; $("dlg-confirm-text").textContent = text; $("dlg-confirm-extra").innerHTML = extraHtml;
const ok = $("dlg-confirm-ok"); ok.textContent = okLabel; ok.className = "btn filled" + (danger ? " danger" : "");
dlg.returnValue = "cancel";
dlg.addEventListener("close", () => resolve(dlg.returnValue === "ok" ? dlg : null), { once: true });
dlg.showModal();
});
const prompt = (title, text, label, hint, validate) => new Promise((resolve) => {
const dlg = $("dlg-prompt"); $("dlg-prompt-title").textContent = title; $("dlg-prompt-text").textContent = text; $("dlg-prompt-label").textContent = label; $("dlg-prompt-hint").textContent = hint || "";
const input = $("dlg-prompt-input"); input.value = ""; const ok = $("dlg-prompt-ok");
const check = () => { const err = validate ? validate(input.value) : null; ok.disabled = !!err; $("dlg-prompt-hint").textContent = err || hint || ""; };
input.oninput = check; check();
dlg.returnValue = "cancel";
dlg.addEventListener("close", () => resolve(dlg.returnValue === "ok" ? input.value : null), { once: true });
dlg.showModal(); input.focus();
});
// ---------------------------------------------------------------- navigation
const PAGES = { dashboard: "Dashboard", storico: "Storico ordini", log: "Log", impostazioni: "Impostazioni" };
const navigate = () => {
const hash = location.hash.replace(/^#\/?/, "") || "dashboard";
const page = PAGES[hash] ? hash : "dashboard";
state.page = page;
document.querySelectorAll(".page").forEach((p) => { p.hidden = p.id !== "page-" + page; });
document.querySelectorAll(".rail-item").forEach((a) => { if (a.dataset.page === page) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); });
$("page-title").textContent = PAGES[page];
if (window.innerWidth < 600) setNav(false);
if (page === "storico") loadHistory();
if (page === "log") { state.lastLogSeq = 0; $("log-body").innerHTML = ""; loadLog(); }
if (page === "impostazioni") { loadSettings(); loadInfo(); loadResearch(); }
$("main").focus({ preventScroll: true });
};
const setNav = (open) => {
document.body.classList.toggle("nav-open", open);
$("menu").setAttribute("aria-expanded", String(open));
$("scrim").hidden = !(open && window.innerWidth < 600);
try { localStorage.setItem("ui.navExpanded", open ? "1" : "0"); } catch { /* per-viewer convenience only */ }
};
$("menu").addEventListener("click", () => setNav(!document.body.classList.contains("nav-open")));
$("scrim").addEventListener("click", () => setNav(false));
window.addEventListener("hashchange", navigate);
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && window.innerWidth < 600) setNav(false); });
// ---------------------------------------------------------------- top bar
const tick = () => {
const now = new Date();
$("clock-utc").textContent = now.toISOString().substring(11, 19) + " UTC";
try { $("clock-zone").textContent = now.toLocaleTimeString("it-IT", { timeZone: state.zone || undefined, hour12: false }) + " " + (state.zoneLabel || ""); } catch { $("clock-zone").textContent = now.toLocaleTimeString("it-IT", { hour12: false }); }
};
setInterval(tick, 1000); tick();
$("currency").addEventListener("change", async () => {
state.currency = $("currency").value; render();
const { ok, data } = await api("/api/settings", { method: "POST", body: JSON.stringify({ "ui.displayCurrency": state.currency }) });
if (!ok) snack(data.message, true);
});
$("power").addEventListener("click", async () => {
const s = state.snapshot; if (!s) return;
if (s.state === "running" || s.state === "starting") {
const ok = await confirm("Fermare il bot?", "I basket aperti restano sul conto con gli stop nativi (closeOnShutdown = no) finché il bot non riparte.", "", "Ferma");
if (ok) await command("stop");
return;
}
let confirmText = null;
if (s.environmentKind === "live" || s.executionMode === "Live") {
confirmText = await prompt("Avvio sul conto REALE", "La modalità Live opera con denaro vero. Scrivi la frase esatta per confermare.", "Frase di conferma", "CONFERMO LIVE", (v) => v.trim() === "CONFERMO LIVE" ? null : "scrivi esattamente CONFERMO LIVE");
if (confirmText === null) return;
}
await command("start", { confirm: confirmText || "" });
});
$("kill").addEventListener("click", async () => {
const s = state.snapshot; if (!s) return;
const extra = s.foreignPositions > 0
? `<label class="switch"><input type="checkbox" id="kill-foreign" /><span>Chiudi anche le ${s.foreignPositions} posizioni ESTERNE (non aperte dal bot)</span></label>`
: "";
const dlg = await confirm("Kill-switch", "Annulla gli ordini pendenti, chiude tutte le posizioni del bot (basket, gambe in attesa, orfane) e blocca le nuove entrate fino a un reset. Niente viene dichiarato chiuso senza rilettura del conto.", extra, "Esegui il kill-switch", true);
if (!dlg) return;
const foreign = !!(document.getElementById("kill-foreign") && document.getElementById("kill-foreign").checked);
const { data } = await command("kill", { foreign: foreign ? "true" : "false" });
if (data.positions && data.positions.length) snack(`RESIDUO: ${data.positions.map((p) => p.positionId + " " + p.symbol).join(", ")}`, true);
});
$("banner-reset").addEventListener("click", () => openReset());
$("preset").addEventListener("change", async () => { const { ok } = await command("preset", { argument: $("preset").value }); if (!ok) render(); });
// ---------------------------------------------------------------- reset wizard
const openReset = async () => {
const s = state.snapshot || {};
const steps = $("reset-steps"); steps.innerHTML = "";
const li = (text, k = "") => { const el = document.createElement("li"); el.className = k; el.textContent = text; steps.appendChild(el); return el; };
li(`1. Stato: ${s.haltReason || "nessun blocco"}; residui ${s.haltResidue ? s.haltResidue.length : 0}, ordini pendenti ${s.pendingOrders || 0}, entrate bloccate: ${s.entriesBlockedReason || "no"}`);
if (s.haltResidue && s.haltResidue.length) {
const el = li("Posizioni del bot ancora sul conto: chiudile prima del reset.", "ko");
const btn = document.createElement("button"); btn.className = "btn tonal small"; btn.type = "button"; btn.textContent = "Chiudi ora";
btn.addEventListener("click", async () => { const { ok } = await command("residue", { argument: "all" }); if (ok) { el.className = "ok"; el.textContent = "Residui chiusi."; } });
el.appendChild(document.createElement("br")); el.appendChild(btn);
}
li("2. File STOP: rimosso automaticamente dal reset (se ricompare, il kill-switch riparte).");
li("3. Motivazione: obbligatoria, almeno dieci caratteri, scritta nel ledger come riga «correzione».");
li("4. Riconciliazione completa, riscaldamento delle serie, picco di equity riportato all'equity corrente al netto dei movimenti di cassa.");
li("5. Ripartenza con le entrate bloccate per il riscaldamento (recovery.warmupMinutes).");
const go = document.createElement("li"); const btn = document.createElement("button"); btn.className = "btn filled"; btn.type = "button"; btn.textContent = "Esegui il ripristino…";
btn.addEventListener("click", async () => {
const reason = await prompt("Motivazione del reset", "Perché ritieni di poter ripartire? Il testo finisce nel ledger.", "Motivazione", "almeno dieci caratteri", (v) => v.trim().length >= 10 ? null : "scrivi almeno dieci caratteri");
if (reason === null) return;
const { ok, data } = await command("reset", { reason });
steps.innerHTML = ""; (data.steps || [data.message]).forEach((t) => li(t, ok ? "ok" : (t.includes("residui") || t.includes("mancante") || t.includes("NON") ? "ko" : "")));
});
go.appendChild(btn); steps.appendChild(go);
$("dlg-reset").showModal();
};
$("reset-open").addEventListener("click", openReset);
$("reset-close").addEventListener("click", () => $("dlg-reset").close());
// ---------------------------------------------------------------- rendering
const stateChip = (s) => {
const chip = $("state-chip"); chip.className = "chip state";
if (s.haltedWithResidue) { chip.textContent = "bloccato · residuo"; chip.classList.add("halted"); }
else if (s.halted) { chip.textContent = "bloccato"; chip.classList.add("halted"); }
else if (s.state === "running") { chip.textContent = "in esecuzione"; chip.classList.add("running"); }
else if (s.state === "starting" || s.state === "stopping") { chip.textContent = s.state === "starting" ? "avvio…" : "arresto…"; chip.classList.add("pending"); }
else if (s.state === "faulted") { chip.textContent = "errore"; chip.classList.add("halted"); }
else chip.textContent = "fermo";
};
const banner = (s) => {
const el = $("banner"); const txt = $("banner-text"); const reset = $("banner-reset"); reset.hidden = true; el.className = "banner";
let text = null, warn = false;
if (s.state === "faulted" && s.error) text = "ERRORE — " + s.error;
else if (s.haltedWithResidue) { text = `KILL-SWITCH CON RESIDUO — ${s.haltReason}. Posizioni ancora sul conto: ${s.haltResidue.map((r) => `${r.positionId} ${r.symbol} ${r.isBuy ? "long" : "short"} (${r.origin})`).join(", ")}.`; reset.hidden = false; }
else if (s.equityStopped) { text = `EQUITY STOP — ${s.haltReason}. Serve un reset con motivazione.`; reset.hidden = false; }
else if (s.halted) { text = `OPERATIVITÀ SOSPESA — ${s.haltReason}`; reset.hidden = false; }
else if (s.state === "running" && s.entriesBlockedReason) { text = `Nuove entrate bloccate: ${s.entriesBlockedReason}. Le uscite restano attive.`; warn = true; }
else if (s.state === "running" && s.unreconciled) { text = `Posizioni non riconciliate: ${s.unreconciledReason}.`; warn = true; }
else if (s.state === "running" && s.orphanLegs > 0) { text = `${s.orphanLegs} gambe orfane del bot sul conto: vengono adottate e chiuse alla riconciliazione.`; warn = true; }
if (text) { txt.textContent = text; el.classList.toggle("warn", warn); el.hidden = false; } else el.hidden = true;
};
const basketRow = (b) => {
const st = (b.state || "").toLowerCase(); const k = !b.enabled ? "off" : st === "open" ? "open" : st.startsWith("pending") ? "pending" : st === "error" ? "error" : "";
const pnl = b.isOpen ? `<span class="${cls(b.pnlUsd)}" title="${esc(moneyTitle(b.pnlUsd))} · ${fmtPct(b.pnlPct)}">${money(b.pnlUsd, true)}</span>` : "—";
const tip = esc(b.enabled ? b.intent : b.disabledReason) + (Number.isFinite(b.pMl) ? ` · p_ML ${fmtNum(b.pMl)}${b.mlActive ? "" : " (ombra)"}` : "");
return `<tr class="${b.enabled ? "" : "disabled"}" title="${tip}">
<td><b>${esc(b.name)}</b> <span class="sub">(${esc(b.cross)})</span></td>
<td><span class="chip st ${k}">${esc(b.stateLabel)}</span></td>
<td class="num" title="z-score del cross sintetico sulla finestra">${fmtSigned(b.z)}</td>
<td class="num" title="ρ_W: correlazione rolling dei rendimenti (ρ_20 ${fmtNum(b.rhoShort)})">${fmtNum(b.rho)}</td>
<td class="num" title="semiperiodo OLS in barre">${b.halfLife === null || b.halfLife === undefined ? "—" : fmtNum(b.halfLife, 0)}</td>
<td class="num" title="pip di basket = somma dei pip delle due gambe">${b.isOpen ? fmtSigned(b.pips, 1) : "—"}</td>
<td class="num" title="take-profit di basket in pip">${fmtNum(b.tpPips, 0)}</td>
<td class="num">${pnl}</td>
<td class="num" title="costo stimato del giro in pip-equivalenti di A (spread + markup + overnight)">${fmtNum(b.costPips, 1)}</td>
<td class="sub">${esc(b.nextEvent)}</td>
<td>${b.isOpen ? `<button class="btn text small danger" data-close="${esc(b.name)}" type="button">Chiudi</button>` : ""}</td></tr>`;
};
const basketCard = (b) => `<div class="basket-card" title="${esc(b.enabled ? b.intent : b.disabledReason)}">
<div><span class="name">${esc(b.name)}</span> <span class="sub">(${esc(b.cross)})</span></div><span class="chip st ${b.isOpen ? "open" : (b.state || "").startsWith("Pending") ? "pending" : b.enabled ? "" : "off"}">${esc(b.stateLabel)}</span>
<div class="metrics"><span>z<b>${fmtSigned(b.z)}</b></span><span>ρ<b>${fmtNum(b.rho)}</b></span><span>Pips<b>${b.isOpen ? fmtSigned(b.pips, 1) : "—"}</b></span><span>P&amp;L<b class="${cls(b.pnlUsd)}">${b.isOpen ? money(b.pnlUsd, true) : "—"}</b></span></div>
${b.isOpen ? `<div><button class="btn text small danger" data-close="${esc(b.name)}" type="button">Chiudi</button></div>` : ""}</div>`;
const render = () => {
const s = state.snapshot; if (!s) return;
$("env-chip").textContent = s.mode; $("env-chip").className = "chip env " + s.environmentKind;
stateChip(s);
const running = s.state === "running" || s.state === "starting";
const power = $("power"); power.textContent = running ? "FERMA" : "AVVIA"; power.classList.toggle("running", running); power.disabled = s.state === "starting" || s.state === "stopping" || state.busy;
$("kill").disabled = s.state !== "running";
$("preset").value = s.preset === "—" ? "MODERATE" : s.preset; $("preset").disabled = s.state !== "running";
if ($("currency").value !== state.currency) $("currency").value = state.currency;
banner(s);
$("k-equity").textContent = money(s.equity); $("k-equity").title = moneyTitle(s.equity); $("k-equity-sub").textContent = `saldo ${money(s.balance)}`;
$("k-today").textContent = money(s.todayPnl, true); $("k-today").className = "value num " + cls(s.todayPnl); $("k-today").title = moneyTitle(s.todayPnl); $("k-today-sub").textContent = fmtPct(s.todayPnlPct);
$("k-open").textContent = money(s.accountOpenPnl, true); $("k-open").className = "value num " + cls(s.accountOpenPnl); $("k-open").title = moneyTitle(s.accountOpenPnl); $("k-open-sub").textContent = `di cui basket ${money(s.openPnl, true)}`;
$("k-dd").textContent = fmtPct(s.drawdownPct); $("k-dd").className = "value num " + (s.drawdownPct >= s.equityStopPct * 0.7 ? "down" : ""); $("k-dd-sub").textContent = `picco ${money(s.peakEquity)} · stop a ${fmtPct(s.equityStopPct)}${s.cumulativeCashFlow ? ` · cassa ${money(s.cumulativeCashFlow, true)}` : ""}`;
$("k-baskets").textContent = s.maxBaskets ? `${s.openBaskets} / ${s.maxBaskets}` : String(s.openBaskets);
const sub = $("k-baskets-sub"); sub.textContent = `in attesa ${s.pendingBaskets} · orfane ${s.orphanLegs} · esterne ${s.foreignPositions}`; sub.className = "sub " + (s.orphanLegs > 0 ? "down" : "");
$("k-margin").textContent = money(s.usedMargin); $("k-margin").title = `${fmtNum(s.usedMargin)} USD impegnati, ${fmtNum(s.availableBalance)} USD disponibili`;
$("k-margin-sub").textContent = `disponibile ${money(s.availableBalance)}${s.usedMargin > 0 ? ` · equity / margine ${fmtNum(s.equity / s.usedMargin, 2)}` : ""}`;
$("baskets-body").innerHTML = s.baskets.map(basketRow).join("");
$("baskets-cards").innerHTML = s.baskets.map(basketCard).join("");
const next = (s.context && s.context.nextEvents || []).find((e) => new Date(e.timeUtc) >= new Date());
$("ctx-event").textContent = next ? `${next.currency} ${next.title} · ${shortTime(next.timeUtc)} (${next.impact})` : "nessun evento ad alto impatto in vista";
$("ctx-calendar").textContent = s.context ? `${s.context.calendarState} · ${s.context.volForecast}` : "—";
$("ctx-api").textContent = `${s.apiState}${Number.isFinite(s.apiLatencyMs) ? ` · ${fmtNum(s.apiLatencyMs, 0)} ms` : ""} · scarto orologio ${fmtSigned(s.clockSkewSeconds, 1)} s`;
$("ctx-counters").textContent = s.counters; $("ctx-telegram").textContent = s.notifierStatus || "—"; $("ctx-news").textContent = s.context ? s.context.newsState : "—";
$("activity").innerHTML = s.events.slice().reverse().map((e) => `<li class="${esc(e.level)}"><span>${esc(e.time)}</span><span>${esc(e.message)}</span></li>`).join("");
if (!state.zone) { state.zoneLabel = ""; }
};
document.addEventListener("click", async (e) => {
const btn = e.target.closest("[data-close]"); if (!btn) return;
const name = btn.dataset.close;
if (await confirm("Chiusura basket", `Chiudere entrambe le gambe di ${name} al prezzo di mercato?`, "", "Chiudi")) await command("close", { argument: name });
});
// ---------------------------------------------------------------- stream
let es = null; let pollTimer = null;
const apply = (s) => { state.snapshot = s; if (!state.currencyTouched) { state.currency = s.displayCurrency || "USD"; } render(); };
// ?once=1: a single snapshot and no stream — for the screenshots of the documentation
// (a headless browser with an open event stream never considers the page loaded).
const once = new URLSearchParams(location.search).has("once");
const connect = () => {
if (once) { api("/api/snapshot").then(({ data }) => apply(data)).catch(() => {}); return; }
if (!("EventSource" in window)) { poll(); return; }
es = new EventSource("/api/stream");
es.addEventListener("snapshot", (ev) => { try { apply(JSON.parse(ev.data)); } catch { /* a partial frame */ } });
es.onerror = () => { es.close(); es = null; setTimeout(connect, 3000); };
};
const poll = async () => { try { const { data } = await api("/api/snapshot"); apply(data); } catch { /* retry */ } pollTimer = setTimeout(poll, 2000); };
$("currency").addEventListener("change", () => { state.currencyTouched = true; });
// ---------------------------------------------------------------- history
const q = (obj) => Object.entries(obj).filter(([, v]) => v).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
const ordersFilter = () => ({ basket: $("f-basket").value.trim(), symbol: $("f-symbol").value.trim(), outcome: $("f-outcome").value, from: $("f-from").value, to: $("f-to").value ? $("f-to").value + "T23:59:59Z" : "" });
const loadHistory = async () => {
busy(true);
try {
if (state.tab === "ordini") {
const { data } = await api("/api/history/orders?" + q(ordersFilter()));
$("history-problem").hidden = !data.problem; $("history-problem").textContent = data.problem || "";
$("orders-body").innerHTML = data.rows.map((o) => `<tr title="${esc(o.motivazione)}"><td>${shortTime(o.ts)}</td><td>${esc(o.basket)}<div class="sub mono">${esc(o.basketId)}</div></td><td>${esc(o.symbol)}</td><td>${esc(o.side)}</td><td>${esc(o.leg)}</td><td class="num">${fmtNum(o.requestedUnits, 2)}</td><td class="num ${o.executedUnits && Math.abs(o.executedUnits - o.requestedUnits) > o.requestedUnits * 0.01 ? "warn" : ""}">${o.executedUnits ? fmtNum(o.executedUnits, 2) : "—"}</td><td class="num">${o.requestedPrice ? fmtNum(o.requestedPrice, 5) : "—"}</td><td class="num">${o.fillRate ? fmtNum(o.fillRate, 5) : "—"}</td><td class="num">${o.slippagePips === null ? "—" : fmtSigned(o.slippagePips, 1)}</td><td>${esc(o.status)}</td><td><span class="chip st ${o.resolution === "Filled" ? "open" : o.resolution === "Pending" ? "pending" : o.resolution === "Rejected" ? "error" : ""}">${esc(o.resolution)}</span></td><td class="num mono">${o.orderId || "—"}</td><td class="num mono">${o.positionId || "—"}</td><td class="num">${o.fees ? fmtNum(o.fees) : "—"}</td></tr>`).join("") || `<tr><td colspan="15" class="sub">nessun ordine nel periodo</td></tr>`;
} else if (state.tab === "posizioni") {
const { data } = await api("/api/history/positions?" + q({ origin: $("p-origin").value }));
$("history-problem").hidden = !data.problem; $("history-problem").textContent = data.problem || "";
$("positions-body").innerHTML = data.rows.map((p) => `<tr title="${esc(p.motivazione)}"><td><span class="chip st ${p.origin === "basket" ? "open" : p.origin === "orfana-bot" ? "error" : ""}">${esc(p.origin)}</span>${p.isOpen ? ' <span class="chip st pending">aperta</span>' : ""}</td><td>${esc(p.basket)}<div class="sub mono">${esc(p.basketId)}</div></td><td>${esc(p.symbol)}</td><td>${p.origin === "movimento di cassa" ? "—" : esc(p.side)}</td><td class="num">${p.units ? fmtNum(p.units, 2) : "—"}</td><td>${shortTime(p.openedUtc)}</td><td>${p.closedUtc ? shortTime(p.closedUtc) : "—"}</td><td class="num ${cls(p.pnlGrossUsd)}" title="${esc(moneyTitle(p.pnlGrossUsd))}">${money(p.pnlGrossUsd, true)}</td><td class="num">${fmtNum(p.feesUsd)}</td><td class="num ${cls(p.pnlNetUsd)}" title="${esc(moneyTitle(p.pnlNetUsd))}">${money(p.pnlNetUsd, true)}</td><td class="num">${p.pips === null ? "—" : fmtSigned(p.pips, 1)}</td><td class="num">${p.origin === "movimento di cassa" ? "—" : fmtNum(p.durationMinutes, 0) + " min"}</td><td>${esc(p.exitReason)}</td></tr>`).join("") || `<tr><td colspan="13" class="sub">nessuna posizione</td></tr>`;
} else {
const custom = $("per-from").value && $("per-to").value ? { from: $("per-from").value, to: $("per-to").value + "T23:59:59Z" } : {};
const { data } = await api("/api/history/periods?" + q(custom));
$("history-problem").hidden = !data.problem; $("history-problem").textContent = data.problem || "";
$("equity-chart").innerHTML = data.equitySvg;
$("periods-body").innerHTML = data.rows.map((p) => `<tr title="${esc(p.motivazione)}"><td>${esc(p.periodo)}<div class="sub">${shortTime(p.fromUtc).slice(0, 10)}${shortTime(p.toUtc).slice(0, 10)}</div></td><td class="num">${p.nBasket}</td><td class="num">${p.nPosizioni}</td><td class="num">${p.vinti}</td><td class="num">${p.persi}</td><td class="num">${p.winRate === null ? "—" : fmtPct(p.winRate)}</td><td class="num ${cls(p.pnlLordo)}">${money(p.pnlLordo, true)}</td><td class="num">${money(p.fee)}</td><td class="num ${cls(p.pnlNetto)}" title="${esc(moneyTitle(p.pnlNetto))}">${money(p.pnlNetto, true)}</td><td class="num">${p.mediaPerBasket === null ? "—" : money(p.mediaPerBasket, true)}</td><td class="num down">${money(p.maxDd)}</td><td class="num" title="movimenti di cassa del periodo, esclusi dal P&amp;L">${p.movimentiDiCassa ? money(p.movimentiDiCassa, true) : "—"}</td></tr>`).join("");
}
} catch (e) { snack(String(e.message || e), true); }
finally { busy(false); }
};
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((x) => x.setAttribute("aria-selected", String(x === t)));
state.tab = t.dataset.tab;
["ordini", "posizioni", "periodi"].forEach((n) => { $("tab-" + n).hidden = n !== state.tab; });
loadHistory();
}));
$("f-apply").addEventListener("click", loadHistory); $("p-apply").addEventListener("click", loadHistory); $("per-apply").addEventListener("click", loadHistory);
$("export-orders").addEventListener("click", () => { location.href = "/api/history/orders?format=csv&" + q(ordersFilter()); });
$("export-positions").addEventListener("click", () => { location.href = "/api/history/positions?format=csv&" + q({ origin: $("p-origin").value }); });
$("export-periods").addEventListener("click", () => { location.href = "/api/history/periods?format=csv"; });
// ---------------------------------------------------------------- log
const loadLog = async () => {
if (state.page !== "log") return;
try {
const { data } = await api("/api/log?" + q({ level: $("log-level").value, q: $("log-search").value.trim(), limit: 2000, after: state.lastLogSeq }));
if (data.file) $("log-file").textContent = data.file;
if (data.rows.length) {
const body = $("log-body");
body.insertAdjacentHTML("beforeend", data.rows.map((r) => `<tr class="${esc(r.level)}"><td class="t">${esc(r.time)}</td><td>${esc(r.message)}</td></tr>`).join(""));
while (body.rows.length > 5000) body.deleteRow(0);
if ($("log-follow").checked) $("log-wrap").scrollTop = $("log-wrap").scrollHeight;
}
state.lastLogSeq = data.lastSeq;
} catch { /* next tick */ }
};
if (!once) setInterval(loadLog, 1500);
const refilterLog = () => { state.lastLogSeq = 0; $("log-body").innerHTML = ""; loadLog(); };
$("log-level").addEventListener("change", refilterLog); $("log-search").addEventListener("input", () => { clearTimeout(refilterLog.t); refilterLog.t = setTimeout(refilterLog, 300); });
// ---------------------------------------------------------------- settings
const loadSettings = async () => {
try {
const { data } = await api("/api/settings"); state.settings = data; state.dirty = {};
$("settings-path").textContent = data.configPath; $("keys-status").textContent = data.keys;
$("settings-groups").innerHTML = data.groups.map((g) => `<section class="setting-group"><h3>${esc(g.title)}</h3><div class="desc">${esc(g.description)}</div>${g.fields.map(fieldHtml).join("")}</section>`).join("");
updateSaveState();
} catch (e) { snack(String(e.message || e), true); }
};
const fieldHtml = (f) => {
const id = "f_" + f.path.replace(/[^a-z0-9]/gi, "_");
const input = f.options.length
? `<select id="${id}" data-path="${esc(f.path)}" ${f.readOnly ? "disabled" : ""}>${f.options.map((o) => `<option ${o === f.value ? "selected" : ""}>${esc(o)}</option>`).join("")}</select>`
: `<input id="${id}" data-path="${esc(f.path)}" type="text" value="${esc(f.value)}" ${f.readOnly ? "readonly" : ""} />`;
return `<div class="setting-row" title="${esc(f.tooltip)}"><label class="lbl" for="${id}">${esc(f.label)}</label>${input}<span><span class="suffix">${esc(f.suffix)}</span> <span class="err">${esc(f.error)}</span></span></div>`;
};
const updateSaveState = () => {
const n = Object.keys(state.dirty).length; $("settings-save").disabled = n === 0;
$("settings-status").textContent = n === 0 ? "Nessuna modifica da salvare" : `${n} modifica/e non salvate — l'interfaccia le applica subito, il motore al prossimo avvio`;
};
$("settings-groups").addEventListener("input", (e) => {
const el = e.target.closest("[data-path]"); if (!el) return;
const path = el.dataset.path; const field = state.settings.groups.flatMap((g) => g.fields).find((f) => f.path === path);
const value = el.value; if (value === field.value) delete state.dirty[path]; else state.dirty[path] = value;
el.classList.toggle("dirty", path in state.dirty); updateSaveState();
});
$("settings-revert").addEventListener("click", loadSettings);
$("settings-save").addEventListener("click", async () => {
busy(true);
try {
const { ok, data } = await api("/api/settings", { method: "POST", body: JSON.stringify(state.dirty) });
snack(data.message, !ok); if (ok) { if ("ui.theme" in state.dirty) applyTheme(state.dirty["ui.theme"]); if ("ui.displayCurrency" in state.dirty) { state.currency = state.dirty["ui.displayCurrency"]; state.currencyTouched = true; render(); } loadSettings(); }
} finally { busy(false); }
});
$("keys-save").addEventListener("click", async () => {
busy(true);
try { const { ok, data } = await api("/api/settings/keys", { method: "POST", body: JSON.stringify({ apiKey: $("key-api").value, userKey: $("key-user").value }) }); snack(data.message, !ok); if (ok) { $("key-api").value = ""; $("key-user").value = ""; loadSettings(); } }
finally { busy(false); }
});
$("keys-forget").addEventListener("click", async () => {
if (!(await confirm("Rimozione chiavi", "Rimuovere le chiavi eToro salvate nel file cifrato per l'ambiente corrente?", "", "Rimuovi", true))) return;
const { ok, data } = await api("/api/settings/keys", { method: "DELETE" }); snack(data.message, !ok); loadSettings();
});
$("restore-defaults").addEventListener("click", async () => {
if (!(await confirm("Ripristino dei valori predefiniti", "Riscrivere l'intera configurazione con i valori di fabbrica? Vengono persi i valori cambiati e i commenti scritti nel file; il file attuale viene salvato con la data accanto. Le chiavi e strategy.json non vengono toccati.", "", "Ripristina", true))) return;
const { ok, data } = await api("/api/settings/restore", { method: "POST" }); snack(data.message, !ok); if (ok) loadSettings();
});
const applyTheme = (t) => { document.documentElement.dataset.theme = t === "light" ? "light" : "dark"; try { localStorage.setItem("ui.theme", document.documentElement.dataset.theme); } catch { /* per-viewer */ } };
const loadInfo = async () => {
try {
const { data } = await api("/api/info");
const kv = (pairs) => pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
$("about-kv").innerHTML = kv([["Nome", data.name], ["Autore", data.author], ["Versione", data.version], ["Data di build", data.buildDate], ["Commit", data.commit], ["Licenza", data.license], ["Documentazione", "docs/ nel repository (RUNBOOK, ARCHITECTURE, DOCKER, UI_GUIDELINES)"]]);
$("diag-kv").innerHTML = kv([["Strategia e run", data.strategyVersion], ["Configurazione", data.configPath], ["Strategia", data.strategyPath], ["Dati", data.dataPath], ["Conoscenza", data.knowledgePath], ["Rapporti", data.reportsPath], ["Log", data.logPath], [".NET", data.dotnet], ["Sistema", data.os + (data.container ? " (container)" : "")], ["Uptime del server", Math.round(data.uptimeSeconds / 60) + " min"], ["Quote API", data.counters], ["Fuso orario", data.timeZone + " (" + data.timeZoneLabel + ")"]]);
state.zone = data.timeZone.startsWith("(") ? "" : data.timeZone.split(" (")[0]; state.zoneLabel = data.timeZoneLabel;
if (state.zone === "UTC" || /fuso del computer/.test(data.timeZone)) state.zone = data.timeZone === "UTC" ? "UTC" : "";
} catch (e) { snack(String(e.message || e), true); }
};
const loadResearch = async () => {
try {
const { data } = await api("/api/research");
const kv = (pairs) => pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
$("research-kv").innerHTML = kv([["Apprendimento a runtime", data.learningEnabled ? "attivo" : "disattivato (ADR-0006): tutto in ombra"], ["Ciclo settimanale nel bot", data.weeklyCycle ? "sì" : "no"], ["Challenger MLP", data.challenger ? "sì" : "no"], ["Modello in ombra", data.mlState], ["Bandit", data.banditProposal], ["Volatilità", data.volForecast], ["Criterio", data.criterion], ["File in knowledge/", data.knowledgeFiles.map((f) => f.name).join(", ") || "nessuno"]]);
} catch { /* not yet */ }
};
$("learn").addEventListener("click", async () => { await command("learn"); loadResearch(); });
$("bonifica-list").addEventListener("click", async () => {
const { data } = await command("bonifica", { argument: "list" });
const rows = data.positions || [];
$("bonifica-list-out").innerHTML = rows.length ? `<ul class="plain">${rows.map((p) => `<li>${esc(p.origin)} ${p.positionId} ${esc(p.symbol)} ${p.isBuy ? "long" : "short"} ${fmtNum(p.units, 2)} P&amp;L ${fmtSigned(p.unrealizedPnl)}${esc(p.reason)} ${p.origin === "orfana-bot" ? `<button class="btn text small danger" data-bonifica="${p.positionId}" type="button">Chiudi</button>` : ""}</li>`).join("")}</ul>` : `<p class="sub">niente da bonificare</p>`;
});
$("bonifica-list-out").addEventListener("click", async (e) => {
const b = e.target.closest("[data-bonifica]"); if (!b) return;
if (await confirm("Bonifica", `Chiudere la posizione orfana ${b.dataset.bonifica}?`, "", "Chiudi", true)) { await command("bonifica", { argument: "close:" + b.dataset.bonifica }); $("bonifica-list").click(); }
});
// ---------------------------------------------------------------- boot
(() => {
let open = true;
try { const saved = localStorage.getItem("ui.navExpanded"); if (saved !== null) open = saved === "1"; const th = localStorage.getItem("ui.theme"); if (th) document.documentElement.dataset.theme = th; } catch { /* defaults */ }
if (window.innerWidth < 600) open = false;
setNav(open);
api("/api/info").then(({ data }) => { if (data.timeZone) loadInfo(); }).catch(() => {});
api("/api/settings").then(({ data }) => { const t = data.groups.flatMap((g) => g.fields).find((f) => f.path === "ui.theme"); if (t) applyTheme(t.value); const nav = data.groups.flatMap((g) => g.fields).find((f) => f.path === "ui.navExpanded"); if (nav && localStorage.getItem("ui.navExpanded") === null && window.innerWidth >= 600) setNav(nav.value === "sì"); }).catch(() => {});
navigate();
connect();
})();
})();
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Encelado">
<defs>
<radialGradient id="g" cx="40%" cy="35%" r="65%">
<stop offset="0" stop-color="#cfe4ff"/>
<stop offset="0.6" stop-color="#5b8def"/>
<stop offset="1" stop-color="#1f3b73"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="28" fill="url(#g)"/>
<path d="M14 40 L24 30 L31 36 L42 22 L50 28" fill="none" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="24" cy="30" r="2.5" fill="#fff"/><circle cx="31" cy="36" r="2.5" fill="#fff"/><circle cx="42" cy="22" r="2.5" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="it" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>Encelado</title>
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<a class="skip" href="#main">Vai al contenuto</a>
<div class="scrim" id="scrim" hidden="hidden"></div>
<nav class="rail" id="rail" aria-label="Navigazione principale">
<button class="icon-btn menu" id="menu" type="button" aria-label="Apri o chiudi la navigazione" aria-expanded="true">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<div class="brand"><img src="/icon.svg" alt="" width="28" height="28" /><span class="label">Encelado</span></div>
<ul class="rail-items" role="list">
<li><a href="#/dashboard" data-page="dashboard" class="rail-item" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-5H4zM14 8h6V4h-6z"/></svg><span class="short">Dash</span><span class="label">Dashboard</span></a></li>
<li><a href="#/storico" data-page="storico" class="rail-item"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8v5l3 2M21 12a9 9 0 1 1-3-6.7M21 3v6h-6"/></svg><span class="short">Storico</span><span class="label">Storico ordini</span></a></li>
<li><a href="#/log" data-page="log" class="rail-item"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h14v16H5zM8 9h8M8 13h8M8 17h5"/></svg><span class="short">Log</span><span class="label">Log</span></a></li>
<li><a href="#/impostazioni" data-page="impostazioni" class="rail-item"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span class="short">Imp.</span><span class="label">Impostazioni</span></a></li>
</ul>
<div class="rail-bottom">
<span class="chip env" id="env-chip" title="Ambiente di esecuzione">DEMO</span>
<span class="chip state" id="state-chip" title="Stato del motore">fermo</span>
</div>
</nav>
<div class="shell">
<header class="top-bar">
<h1 class="title" id="page-title">Dashboard</h1>
<div class="clocks" aria-live="off">
<span class="clock" id="clock-utc" title="Ora UTC: quella del ledger">--:--:-- UTC</span>
<span class="clock" id="clock-zone" title="Ora nel fuso scelto in Impostazioni">--:--:--</span>
</div>
<label class="currency-pick"><span class="sr-only">Valuta di visualizzazione</span>
<select id="currency" aria-label="Valuta di visualizzazione" title="Valuta con cui mostrare gli importi: il ledger resta in USD">
<option>USD</option><option>EUR</option><option>GBP</option><option>CHF</option><option>JPY</option><option>AUD</option><option>CAD</option><option>NZD</option>
</select>
</label>
<button class="btn filled power" id="power" type="button">AVVIA</button>
<button class="btn outlined danger" id="kill" type="button" disabled="disabled" title="Chiude tutte le posizioni del bot e blocca le nuove entrate fino a un reset">KILL-SWITCH</button>
</header>
<div class="progress" id="progress" role="progressbar" aria-hidden="true"><div class="bar"></div></div>
<main id="main" class="content" tabindex="-1">
<!-- ================= Dashboard ================= -->
<section class="page" id="page-dashboard">
<div class="banner" id="banner" hidden="hidden" role="status"><span id="banner-text"></span><span class="banner-actions"><button class="btn text" id="banner-reset" type="button" hidden="hidden">Sblocca…</button></span></div>
<div class="kpi-grid">
<article class="card kpi" title="Equity = saldo + P&amp;L non realizzato: il numero su cui si calcolano rischio per basket, equity stop e perdita giornaliera."><div class="label">Equity</div><div class="value num" id="k-equity"></div><div class="sub" id="k-equity-sub">saldo —</div></article>
<article class="card kpi" title="P&amp;L chiuso di oggi (giornata UTC) e in percentuale dell'equity di inizio giornata. Al 3 % di perdita il bot non apre più fino a domani."><div class="label">P&amp;L oggi</div><div class="value num" id="k-today"></div><div class="sub" id="k-today-sub"></div></article>
<article class="card kpi" title="P&amp;L aperto del conto (tutte le posizioni, come lo riporta eToro); sotto, la parte dovuta ai basket del bot."><div class="label">P&amp;L aperto (conto)</div><div class="value num" id="k-open"></div><div class="sub" id="k-open-sub">di cui basket —</div></article>
<article class="card kpi" title="Distanza dal picco di equity al netto dei movimenti di cassa. All'equity stop il bot chiude tutto e si blocca."><div class="label">Drawdown</div><div class="value num" id="k-dd"></div><div class="sub" id="k-dd-sub">picco —</div></article>
<article class="card kpi" title="Basket aperti sul massimo del preset. In attesa: ingressi con una gamba senza esito. Orfane: posizioni del bot senza basket, adottate e chiuse (rosso se ce ne sono). Esterne: posizioni non aperte dal bot, mai toccate."><div class="label">Basket aperti</div><div class="value num" id="k-baskets"></div><div class="sub" id="k-baskets-sub">in attesa 0 · orfane 0 · esterne 0</div></article>
<article class="card kpi" title="Margine impegnato dalle posizioni del conto e cassa disponibile per nuovi ordini. La size di un basket è il minimo fra rischio e margine (strategy.json → risk)."><div class="label">Margine usato</div><div class="value num" id="k-margin"></div><div class="sub" id="k-margin-sub"></div></article>
</div>
<article class="card table-card">
<div class="card-head"><h2>Basket</h2><div class="preset-pick"><label for="preset">Preset</label><select id="preset" aria-label="Preset"><option>CONSERVATIVE</option><option>MODERATE</option><option>AGGRESSIVE</option></select></div></div>
<div class="table-wrap"><table class="data compact" id="baskets-table">
<thead><tr><th scope="col">Basket (cross)</th><th scope="col">Stato</th><th scope="col" class="num">z</th><th scope="col" class="num">ρ</th><th scope="col" class="num">HL</th><th scope="col" class="num">Pips</th><th scope="col" class="num">TP</th><th scope="col" class="num">P&amp;L</th><th scope="col" class="num">Costo</th><th scope="col">Prossimo evento</th><th scope="col"></th></tr></thead>
<tbody id="baskets-body"></tbody>
</table></div>
<div class="cards-list" id="baskets-cards"></div>
</article>
<div class="context-grid">
<article class="card"><h2>Prossimo evento</h2><div id="ctx-event" class="body"></div><div class="sub" id="ctx-calendar"></div></article>
<article class="card"><h2>Collegamento eToro</h2><div id="ctx-api" class="body"></div><div class="sub" id="ctx-counters"></div></article>
<article class="card"><h2>Telegram</h2><div id="ctx-telegram" class="body"></div><div class="sub" id="ctx-news"></div></article>
</div>
<article class="card activity"><h2>Attività</h2><ul id="activity" class="activity-list" role="list"></ul></article>
</section>
<!-- ================= Storico ================= -->
<section class="page" id="page-storico" hidden="hidden">
<div class="tabs" role="tablist" aria-label="Viste dello storico">
<button class="tab" role="tab" aria-selected="true" data-tab="ordini" type="button">Ordini</button>
<button class="tab" role="tab" aria-selected="false" data-tab="posizioni" type="button">Posizioni</button>
<button class="tab" role="tab" aria-selected="false" data-tab="periodi" type="button">Profitti per periodo</button>
</div>
<div class="hint" id="history-problem" hidden="hidden"></div>
<article class="card table-card" id="tab-ordini" role="tabpanel">
<div class="card-head filters">
<label>Basket <input id="f-basket" type="text" placeholder="EURUSD/USDCHF" /></label>
<label>Strumento <input id="f-symbol" type="text" placeholder="EURUSD" /></label>
<label>Esito <select id="f-outcome"><option value="">tutti</option><option>Filled</option><option>Rejected</option><option>Cancelled</option><option>Pending</option></select></label>
<label>Da <input id="f-from" type="date" /></label>
<label>A <input id="f-to" type="date" /></label>
<button class="btn tonal" id="f-apply" type="button">Applica</button>
<button class="btn text" id="export-orders" type="button">Esporta CSV</button>
</div>
<div class="table-wrap"><table class="data compact"><thead><tr><th scope="col">Quando</th><th scope="col">Basket</th><th scope="col">Strumento</th><th scope="col">Verso</th><th scope="col">Gamba</th><th scope="col" class="num">Unità rich.</th><th scope="col" class="num">Unità eseg.</th><th scope="col" class="num">Prezzo rich.</th><th scope="col" class="num">Prezzo eseg.</th><th scope="col" class="num">Slippage</th><th scope="col">Stato</th><th scope="col">Esito</th><th scope="col" class="num">Ordine</th><th scope="col" class="num">Posizione</th><th scope="col" class="num">Fee</th></tr></thead><tbody id="orders-body"></tbody></table></div>
</article>
<article class="card table-card" id="tab-posizioni" role="tabpanel" hidden="hidden">
<div class="card-head filters">
<label>Origine <select id="p-origin"><option value="">tutte</option><option value="basket">basket</option><option value="orfana-bot">orfana-bot</option><option value="esterna">esterna</option><option value="movimento di cassa">movimento di cassa</option></select></label>
<button class="btn tonal" id="p-apply" type="button">Applica</button>
<button class="btn text" id="export-positions" type="button">Esporta CSV</button>
</div>
<div class="table-wrap"><table class="data compact"><thead><tr><th scope="col">Origine</th><th scope="col">Basket</th><th scope="col">Strumento</th><th scope="col">Verso</th><th scope="col" class="num">Unità</th><th scope="col">Apertura</th><th scope="col">Chiusura</th><th scope="col" class="num">P&amp;L lordo</th><th scope="col" class="num">Fee</th><th scope="col" class="num">P&amp;L netto</th><th scope="col" class="num">Pip</th><th scope="col" class="num">Durata</th><th scope="col">Motivo</th></tr></thead><tbody id="positions-body"></tbody></table></div>
</article>
<article class="card table-card" id="tab-periodi" role="tabpanel" hidden="hidden">
<div class="card-head filters">
<label>Da <input id="per-from" type="date" /></label>
<label>A <input id="per-to" type="date" /></label>
<button class="btn tonal" id="per-apply" type="button">Intervallo personalizzato</button>
<button class="btn text" id="export-periods" type="button">Esporta CSV</button>
</div>
<div class="equity-chart" id="equity-chart" aria-label="Curva dell'equity realizzata"></div>
<div class="table-wrap"><table class="data compact"><thead><tr><th scope="col">Periodo</th><th scope="col" class="num">Basket</th><th scope="col" class="num">Posizioni</th><th scope="col" class="num">Vinti</th><th scope="col" class="num">Persi</th><th scope="col" class="num">Win rate</th><th scope="col" class="num">P&amp;L lordo</th><th scope="col" class="num">Fee</th><th scope="col" class="num">P&amp;L netto</th><th scope="col" class="num">Media/basket</th><th scope="col" class="num">Max DD</th><th scope="col" class="num">Cassa</th></tr></thead><tbody id="periods-body"></tbody></table></div>
</article>
</section>
<!-- ================= Log ================= -->
<section class="page" id="page-log" hidden="hidden">
<article class="card table-card">
<div class="card-head filters">
<label>Livello <select id="log-level"><option value="tutti">tutti</option><option>info</option><option>warn</option><option>error</option></select></label>
<label>Cerca <input id="log-search" type="search" placeholder="testo, basket, strumento" /></label>
<label class="switch"><input id="log-follow" type="checkbox" checked="checked" /><span>Segui</span></label>
<span class="sub" id="log-file"></span>
</div>
<div class="log-wrap" id="log-wrap"><table class="data compact log"><tbody id="log-body"></tbody></table></div>
</article>
</section>
<!-- ================= Impostazioni ================= -->
<section class="page" id="page-impostazioni" hidden="hidden">
<div class="settings-grid">
<article class="card" id="settings-card">
<div class="card-head"><h2>Configurazione</h2><span class="sub" id="settings-path"></span></div>
<div id="settings-groups"></div>
<div class="card-actions"><span class="sub" id="settings-status">Nessuna modifica</span><button class="btn text" id="settings-revert" type="button">Annulla modifiche</button><button class="btn filled" id="settings-save" type="button" disabled="disabled">Salva</button></div>
</article>
<article class="card">
<h2>Chiavi eToro</h2>
<p class="sub" id="keys-status"></p>
<div class="form-row"><label>x-api-key <input id="key-api" type="password" autocomplete="off" /></label></div>
<div class="form-row"><label>x-user-key <input id="key-user" type="password" autocomplete="off" /></label></div>
<div class="card-actions"><button class="btn text danger" id="keys-forget" type="button">Rimuovi le chiavi salvate</button><button class="btn tonal" id="keys-save" type="button">Verifica e salva</button></div>
</article>
<article class="card">
<h2>Ripristino</h2>
<p class="sub">Dopo un kill-switch o un equity stop il bot resta bloccato. Il ripristino è una procedura in cinque passi: stato, file STOP, motivazione, riconciliazione, ripartenza con entrate bloccate per il riscaldamento.</p>
<div class="card-actions"><button class="btn tonal" id="reset-open" type="button">Avvia il ripristino…</button></div>
<p class="sub">Ripristino della configurazione ai valori di fabbrica (backup automatico, solo a bot fermo):</p>
<div class="card-actions"><button class="btn text danger" id="restore-defaults" type="button">Ripristina i valori predefiniti</button></div>
</article>
<details class="card" id="research">
<summary><h2>Ricerca</h2></summary>
<dl class="kv" id="research-kv"></dl>
<div class="card-actions"><button class="btn tonal" id="learn" type="button">Esegui ciclo di apprendimento</button></div>
</details>
<details class="card" id="diagnostics">
<summary><h2>Diagnostica</h2></summary>
<dl class="kv" id="diag-kv"></dl>
<div class="card-actions"><button class="btn tonal" id="bonifica-list" type="button">Bonifica: elenca le orfane</button></div>
<div id="bonifica-list-out"></div>
</details>
<article class="card" id="about">
<h2>Informazioni</h2>
<dl class="kv" id="about-kv"></dl>
</article>
</div>
</section>
</main>
</div>
<!-- ================= Dialogs ================= -->
<dialog id="dlg-confirm" class="dialog" aria-labelledby="dlg-confirm-title">
<form method="dialog">
<h2 id="dlg-confirm-title">Conferma</h2>
<p id="dlg-confirm-text"></p>
<div id="dlg-confirm-extra"></div>
<div class="dialog-actions"><button class="btn text" value="cancel" type="submit">Annulla</button><button class="btn filled" id="dlg-confirm-ok" value="ok" type="submit">Conferma</button></div>
</form>
</dialog>
<dialog id="dlg-prompt" class="dialog" aria-labelledby="dlg-prompt-title">
<form method="dialog">
<h2 id="dlg-prompt-title">Motivazione</h2>
<p id="dlg-prompt-text"></p>
<label class="prompt-label"><span id="dlg-prompt-label">Testo</span><input id="dlg-prompt-input" type="text" autocomplete="off" /></label>
<p class="sub" id="dlg-prompt-hint"></p>
<div class="dialog-actions"><button class="btn text" value="cancel" type="submit">Annulla</button><button class="btn filled" id="dlg-prompt-ok" value="ok" type="submit">OK</button></div>
</form>
</dialog>
<dialog id="dlg-reset" class="dialog wide" aria-labelledby="dlg-reset-title">
<h2 id="dlg-reset-title">Ripristino</h2>
<ol class="steps" id="reset-steps" role="list"></ol>
<div class="dialog-actions"><button class="btn text" id="reset-close" type="button">Chiudi</button></div>
</dialog>
<div class="snackbar" id="snackbar" role="status" aria-live="polite" hidden="hidden"></div>
<script src="/app.js"></script>
</body>
</html>
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="it" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Encelado — accesso</title>
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/app.css" />
</head>
<body class="login-body">
<main class="login-card card">
<img src="/icon.svg" alt="" width="48" height="48" />
<h1>Encelado</h1>
<p class="sub">Inserisci il token locale (variabile <code>ENCELADO_WEB_TOKEN</code> del container). Resta in un cookie HttpOnly per trenta giorni.</p>
<form method="post" action="/login">
<label>Token <input name="token" type="password" autocomplete="current-password" autofocus="autofocus" required="required" /></label>
<button class="btn filled" type="submit">Entra</button>
</form>
</main>
</body>
</html>
@@ -0,0 +1,10 @@
{
"name": "Encelado",
"short_name": "Encelado",
"description": "Correlation Baskets su eToro",
"start_url": "/",
"display": "standalone",
"background_color": "#101418",
"theme_color": "#101418",
"icons": [{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }]
}
+2 -1
View File
@@ -1,5 +1,6 @@
using Encelado.Bot.Baskets; using Encelado.Engine.Baskets;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Baskets.History;
using Encelado.Core.Baskets.Data; using Encelado.Core.Baskets.Data;
using Encelado.Core.Broker; using Encelado.Core.Broker;
@@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Encelado.Bot.Configuration; using Encelado.Engine.Configuration;
using Encelado.Bot.Ui; using Encelado.Engine.Settings;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
namespace Encelado.Tests; namespace Encelado.Tests;
@@ -404,7 +404,7 @@ public class UiClockTests
TimeZoneInfo previous = UiClock.Zone; TimeZoneInfo previous = UiClock.Zone;
try try
{ {
UiClock.Zone = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time"); UiClock.Zone = TimeZoneInfo.CreateCustomTimeZone("Test+09", TimeSpan.FromHours(9), "Test+09", "Test+09");
DateTime utc = new(2026, 9, 16, 10, 0, 0, DateTimeKind.Utc); DateTime utc = new(2026, 9, 16, 10, 0, 0, DateTimeKind.Utc);
Assert.Equal("19:00:00", UiClock.Format(utc, "HH:mm:ss")); Assert.Equal("19:00:00", UiClock.Format(utc, "HH:mm:ss"));
@@ -1,139 +0,0 @@
using Encelado.Bot.Configuration;
namespace Encelado.Tests;
/// <summary>
/// Every test redirects the store to a scratch directory via ENCELADO_HOME, so the
/// developer's real saved keys are never read, written or deleted.
/// </summary>
public sealed class EtoroKeyStoreTests : IDisposable
{
private readonly string _home;
private readonly string? _previousHome;
public EtoroKeyStoreTests()
{
_previousHome = Environment.GetEnvironmentVariable("ENCELADO_HOME");
_home = Path.Combine(Path.GetTempPath(), $"encelado-store-{Guid.NewGuid():N}");
Environment.SetEnvironmentVariable("ENCELADO_HOME", _home);
}
public void Dispose()
{
Environment.SetEnvironmentVariable("ENCELADO_HOME", _previousHome);
if (Directory.Exists(_home))
{
Directory.Delete(_home, recursive: true);
}
}
[Fact]
public void HonoursTheHomeOverride() =>
Assert.Equal(Path.Combine(_home, "etoro.dat"), EtoroKeyStore.FilePath);
[Fact]
public void KeysRoundTripPerEnvironment()
{
Assert.False(EtoroKeyStore.Exists);
Assert.Null(EtoroKeyStore.Load(demo: true));
EtoroKeyStore.Save(demo: true, new EtoroKeys("api-demo", "user-demo", DateTime.UtcNow));
EtoroKeyStore.Save(demo: false, new EtoroKeys("api-real", "user-real", DateTime.UtcNow));
Assert.True(EtoroKeyStore.Exists);
Assert.Equal("api-demo", EtoroKeyStore.Load(demo: true)!.ApiKey);
Assert.Equal("user-real", EtoroKeyStore.Load(demo: false)!.UserKey);
Assert.True(EtoroKeyStore.Clear(demo: true));
Assert.Null(EtoroKeyStore.Load(demo: true));
Assert.NotNull(EtoroKeyStore.Load(demo: false));
Assert.True(EtoroKeyStore.Clear(demo: false));
Assert.False(EtoroKeyStore.Exists);
Assert.False(EtoroKeyStore.Clear(demo: false));
}
[Fact]
public void KeysAreNotReadableAsPlainTextOnWindows()
{
EtoroKeyStore.Save(demo: true, new EtoroKeys("api-key-value", "user-key-value", DateTime.UtcNow));
string onDisk = File.ReadAllText(EtoroKeyStore.FilePath);
if (EtoroKeyStore.IsEncrypted)
{
Assert.DoesNotContain("user-key-value", onDisk, StringComparison.Ordinal);
}
else
{
Assert.Contains("user-key-value", onDisk, StringComparison.Ordinal);
}
}
[Fact]
public void ACorruptFileIsTreatedAsAbsentRatherThanThrowing()
{
Directory.CreateDirectory(_home);
File.WriteAllBytes(EtoroKeyStore.FilePath, [0x00, 0x01, 0x02, 0x03, 0x04]);
Assert.Null(EtoroKeyStore.Load(demo: true));
EtoroKeyStore.Save(demo: true, new EtoroKeys("new-api", "new-user", DateTime.UtcNow));
Assert.Equal("new-api", EtoroKeyStore.Load(demo: true)!.ApiKey);
}
[Fact]
public void ResolvePrefersTheEnvironmentThenTheStore()
{
BotConfig config = new();
Assert.False(EtoroKeyStore.Resolve(config, out string origin));
Assert.Contains("nessuna", origin, StringComparison.OrdinalIgnoreCase);
EtoroKeyStore.Save(demo: true, new EtoroKeys("api-stored", "user-stored", DateTime.UtcNow));
Assert.True(EtoroKeyStore.Resolve(config, out origin));
Assert.Equal("api-stored", config.Etoro.ApiKey);
Assert.Contains("salvate", origin, StringComparison.OrdinalIgnoreCase);
BotConfig fromEnv = new();
fromEnv.Etoro.ApiKey = "api-env";
fromEnv.Etoro.UserKey = "user-env";
Assert.True(EtoroKeyStore.Resolve(fromEnv, out origin));
Assert.Equal("api-env", fromEnv.Etoro.ApiKey);
Assert.Contains("ambiente", origin, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("PKABCDEFGH1234", "PKABCD********")]
[InlineData("PKAB", "****")]
[InlineData("ab", "**")]
[InlineData("", "(vuota)")]
[InlineData(null, "(vuota)")]
public void MaskKeepsOnlyThePrefix(string? input, string expected) =>
Assert.Equal(expected, EtoroKeyStore.Mask(input));
[Theory]
[InlineData(" PKKEY123 ", "PKKEY123")]
[InlineData("PKKEY123", "PKKEY123")]
[InlineData("PKKEY123", "PKKEY123")]
[InlineData("P\0K\0K\0E\0Y\0", "PKKEY")]
[InlineData("PKKEY123\r\n", "PKKEY123")]
public void CleanStripsInvisibleCharactersFromPastedKeys(string raw, string expected) =>
Assert.Equal(expected, EtoroKeyStore.Clean(raw));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\r\n")]
public void CleanReturnsNullWhenNothingUsableRemains(string? raw) =>
Assert.Null(EtoroKeyStore.Clean(raw));
[Fact]
public void MaskNeverEchoesAWholeSecret()
{
const string secret = "abcdefghijklmnopqrstuvwxyz0123456789";
string masked = EtoroKeyStore.Mask(secret);
Assert.DoesNotContain(secret, masked, StringComparison.Ordinal);
Assert.DoesNotContain(secret[4..], masked, StringComparison.Ordinal);
Assert.StartsWith("abcd", masked, StringComparison.Ordinal);
}
}

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