13 Commits
Author SHA1 Message Date
Alby96andClaude Fable 5.1 e2e471f12c Workflow Gitea Actions (ci e release) e guida all'installazione su Unraid
ci.yml compila con gli avvisi come errori, esegue i test e costruisce l'immagine a
ogni push; release.yml pubblica l'immagine sul registro di Gitea e crea la release
con il template Unraid a ogni tag, in modo idempotente rispetto alla catena locale.
Sul server non c'è ancora un runner: docs/DOCKER.md spiega come registrarlo e quale
segreto serve. Il README di deploy/unraid guida passo per passo l'installazione,
compreso il registro in HTTP da dichiarare a Docker di Unraid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:19:43 +02:00
Alby96andClaude Fable 5.1 e9ace47501 5.0: avvio in locale nella sandbox deploy/local, script di avvio e screenshot, arresto pulito del container verificato
Per vedere e provare il bot senza Unraid: F5 in VS Code, scripts/run-dev e
scripts/run-docker usano tutti deploy/local come /config e /data, con la stessa
disposizione del container, così chiavi e configurazione valgono in ogni modo.
ENCELADO_DATA_DIR separa i dati anche fuori dal container. scripts/screenshots
rigenera docs/img dal server campione; /api/health in --sample non finge più un
motore acceso. docker stop ora termina in un secondo con «SIGTERM: arresto»
(PosixSignalRegistration), provato sull'immagine 5.0.0 costruita dalla catena.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 13:59:56 +02:00
Alby96andClaude Fable 5.1 d7559d36a5 5.0 Fase 8: immagine Docker come pacchetto, template Unraid, catena senza Inno Setup
Il container è la sola forma di esecuzione (ADR-0008): Dockerfile multi-stage con i
test dentro la build, entrypoint con TZ e PUID/PGID, compose per le prove locali,
healthcheck via --health. Il template Unraid installa dall'immagine sul registro di
Gitea con l'icona servita dal repository (D-29). La catena di rilascio pubblica la
cartella portabile, costruisce l'immagine, tagga a pacchetto pronto e spinge
l'immagine sul registro con lo stesso token della release; l'installatore Windows
non ha più senso e viene tolto.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 13:26:12 +02:00
Alby96andClaude Fable 5.1 9c1bab5224 5.0 Fase 9: documenti, screenshot, risposte D-28/29/36/37, piano concluso
README con gli screenshot e l'avvio rapido Docker; CLAUDE.md, architettura, runbook,
problemi noti, glossario, fonti dei dati e apprendimento riscritti per il container e
l'interfaccia web; il post-mortem spiega gli accrediti del demo (uno per ordine
ridotto, dal ledger ricevuto); le skill seguono la catena nuova. Lo stato dice che il
piano 5.0 è completo e che il rilascio 5.0.0 resta una decisione dell'utente.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 13:24:30 +02:00
Alby96andClaude Fable 5.1 a69efe7df3 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>
2026-09-23 13:20:59 +02:00
Alby96andClaude Fable 5.1 21fa04690a 5.0: apprendimento in ombra (ADR-0006), skill di progetto, documenti della sessione
Con il backtest negativo e zero basket chiusi nel forward test, un meta-modello
attivo e un bandit che cambia il preset da solo sono rumore: learning.enabled
= false di fabbrica lascia il ledger, la calibrazione, la previsione di
volatilità e la logistica in ombra, e spegne ciclo settimanale e challenger
finché non valgono 300 basket chiusi e un P&L forward non negativo. Le skill
in .claude/skills dicono come si comincia, si verifica, si diagnostica, si
chiude e si rilascia una sessione. CLAUDE.md, glossario e problemi noti
aggiornati alla 5.0; catena Verifica verde con 219 test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 11:27:46 +02:00
Alby96andClaude Fable 5.1 9f040ade76 5.0 Fase 5: notifiche e comandi Telegram
Il bot riferisce al telefono: stato ogni ora, riepilogo giornaliero ed eventi
(avvio e arresto, basket aperti e chiusi, gambe in attesa, ordini risolti,
orfane, kill-switch, equity stop, perdita giornaliera, margin guard, recupero,
reset, preset, API in errore, scarto orologio) e accetta comandi dalla sola
chat autorizzata (/stato, /posizioni, /storico, /pausa, /riprendi, /chiudi,
/kill CONFERMO, /reset). Il canale è solo HttpClient: coda non bloccante, un
messaggio al secondo, retry con backoff e retry_after, long polling da un solo
task; il token vive nell'ambiente. Ogni comando eseguito, da qualunque origine,
è una riga «comando» nel ledger. Test (s)-(u).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 11:23:18 +02:00
Alby96andClaude Fable 5.1 c5f2b7d852 5.0 Fase 4: recupero dopo inattività, heartbeat e lock di istanza
Un riavvio, una sospensione del PC o un container fermo lasciavano i basket
aperti senza che nessuno applicasse le regole di uscita per il tempo perso.
Ora un heartbeat ogni 30 s misura l'inattività; oltre la soglia il bot blocca
le entrate, risolve gli ordini senza esito, riconcilia, riscalda le serie con le
barre perse e rivaluta ogni basket aperto come a una chiusura di barra
ordinaria (chiudi o tieni), chiude le orfane, riporta le esterne, scrive
reports/recupero_<run_id>.csv e riapre le entrate dopo il riscaldamento; a
mercato chiuso aspetta. Un lock tenuto in esclusiva impedisce due istanze
sulla stessa cartella dati. Sezione recovery in strategy.json. Test (r).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 11:14:06 +02:00
Alby96andClaude Fable 5.1 aaec965241 5.0 Fase 3: kill-switch che chiude davvero e ripristino in cinque passi
Il kill-switch della 4.0.0 chiudeva gli slot, non le posizioni, e «riusciva»
con venti gambe ancora sul conto. Ora annulla gli ordini senza esito e ne
attende la risoluzione, chiude basket, gambe in attesa e orfane (le esterne
solo su richiesta o con risk.closeForeignOnKill), rilegge il conto finché le
posizioni del bot non sono sparite e, se qualcosa resta, dichiara
Halted-Residuo con l'elenco invece di «tutto chiuso». Il reset è una procedura
in cinque passi (stato, file STOP, motivazione, riconciliazione con
riscaldamento e picco, ripartenza con entrate bloccate) rifiutata finché il
conto non è piatto. INotifier per le notifiche della Fase 5. Test (v)-(x).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 11:08:47 +02:00
Alby96andClaude Fable 5.1 327d3c6981 5.0 Fase 2: il margine come vincolo di primo livello del sizing
Un solo basket sizato a rischio aveva impegnato il 100 % del margine del conto
(16/9). Ora strategy.json ha la sezione risk (12 % dell'equity per basket, 40 %
in totale, disponibile con buffer del 25 %), la size è il minimo fra rischio e
margine e il ledger scrive quale vincolo ha deciso; il conto viene riletto
prima della gamba B e, se non copre, A viene richiusa; i segnali della stessa
barra vanno per |z| decrescente con rilettura del conto; sotto equity/margine
1,5 niente entrate, sotto 1,2 si chiude il basket peggiore. Il backtest applica
gli stessi limiti. Test (y), (z).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 11:01:58 +02:00
Alby96andClaude Fable 5.1 13d64d1830 5.0 Fase 1: registro persistente degli ordini, stati Pending, orfane adottate e chiuse
Un ordine dall'esito ignoto non viene più abbandonato: entra in
data/state/pending_orders.json prima della chiamata HTTP, l'esito si legge per
orderId (il server non registra il referenceId degli ordini v2) e in mancanza
si ricostruisce dalla posizione comparsa sul conto. Una gamba senza esito porta
il basket in PendingA/PendingB invece di rifiutarlo; alla risoluzione parte la
gamba B, ridimensionata sulle unità eseguite, oppure la gamba A viene richiusa.
Ogni posizione del conto è classificata basket / orfana-bot / esterna: le orfane
del bot vengono adottate e chiuse, le esterne contate e mai toccate. Il picco di
equity ignora i movimenti di cassa. Bonifica da headless, orders.jsonl, contatori
in dashboard, bandit che propone e non applica. ADR-0009, test (m)-(q).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 10:54:50 +02:00
Alby96andClaude Fable 5.1 f97f1e1fc3 Fase 0 della 5.0: piano, post-mortem degli ordini pendenti, domande D-26…D-37
Verifica della diagnosi sul codice e sul conto demo via API: il lookup per
referenceId fallisce perché il server registra un riferimento nullo per gli
ordini v2, e dal terzo ordine eToro ha ridotto ogni esecuzione a 2 000 USD di
margine. Il piano fissa l'ordine delle fasi e le decisioni vincolanti
(orderId come chiave, registro persistente degli ordini, margine come vincolo
di primo livello, picco al netto dei movimenti di cassa).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 10:24:54 +02:00
Alby96andClaude Fable 5.1 b8c3c75647 Registra l'esito della sessione demo di quattro ore
Perché: lo stato del lavoro deve dire cosa ha fatto il bot davvero. In quattro
ore di Demo autonomo 31 segnali sono stati rifiutati dal solo cancello di
correlazione (ρ_W mai sotto −0,6): è il primo dato da leggere nel ledger prima
di proporre un cambiamento di soglia.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:57:01 +02:00
183 changed files with 14259 additions and 6673 deletions
@@ -0,0 +1,16 @@
---
name: encelado-diagnose
description: Riconcilia ledger, registro degli ordini, heartbeat e posizioni del conto eToro e produce un rapporto CSV con separatore ; e colonna motivazione.
---
# Diagnosi di una sessione di Encelado
Cartella dati: `Documenti\Encelado\data` su Windows, `/data/data` nel container. Se l'utente indica un'altra cartella, usa quella.
1. **Ledger delle decisioni** `data/ledger/decisions.jsonl` (e i mensili `decisions_YYYYMM.jsonl`): conta gli `evento` (`segnale_ingresso`, `rifiuto`, `ingresso`, `pending`, `pending_risolto`, `leg_risk_unwind`, `uscita`, `orfana_adottata`, `orfana_chiusa`, `movimento_di_cassa`, `kill_switch_*`, `recupero_*`, `comando`, `correzione`). Per ogni `rifiuto` riporta `basket`, `ts`, `unitsA`, `unitsB`, `sizing_bound`, `motivazione`.
2. **Registro degli ordini** `data/ledger/orders.jsonl` e `data/state/pending_orders.json`: per ogni `client_ref` prendi l'ultima riga; elenca gli ordini con `esito = Pending` o `stato = Unknown` (sono le anomalie), quelli con `unita_eseguite ≠ unita_richieste` (il server ha ridotto l'ordine), gli slippage oltre 1 pip.
3. **Basket** `data/ledger/baskets.csv`: numero, win rate, P&L netto, `exit_reason` (attenzione a `orphan_closed`, `bonifica_orfana`, `leg_risk_unwind`, `margin_guard`, `kill_switch`).
4. **Heartbeat** `data/state/heartbeat.json` e **stato** `data/state/baskets_state.json`: ultimo battito, run, basket aperti e in attesa, `haltResidue`, `cumulativeCashFlow`, `peakNetEquity`.
5. **Conto eToro** (solo se le chiavi sono disponibili nell'ambiente `ETORO_API_KEY`/`ETORO_USER_KEY` o via collegamento MCP, e solo in lettura): posizioni aperte (`GET api/v1/trading/info/demo/pnl`), storico (`GET api/v1/trading/info/trade/demo/history?minDate=…`). Classifica ogni posizione come `basket` (id nello stato), `orfana-bot` (id nel registro, o strumento + verso + orario entro 90 s da una riga del ledger), `esterna`.
6. Scrivi `reports/diagnosi_<data>.csv` con separatore `;`, intestazione e colonna finale `motivazione`: una riga per anomalia (`tipo;riferimento;quando;dettaglio;motivazione`) e in fondo le righe di riepilogo. Nessuna riga del ledger va modificata: le correzioni sono righe nuove.
7. Riassumi all'utente: cosa non torna, cosa è già stato gestito dal bot, cosa richiede una mano (chiusure su eToro, reset).
@@ -0,0 +1,13 @@
---
name: encelado-docs
description: Chiude una sessione di lavoro su Encelado: aggiorna STATE, CHANGELOG, ADR e QUESTIONS, poi propone il messaggio di commit.
---
# Chiusura della sessione: documenti e commit
1. `docs/STATE.md`: data, fase in corso, cosa è stato fatto in questa sessione (con i numeri: test, file, commit), prossimi passi numerati, problemi aperti, domande in attesa. È la memoria fra una sessione e l'altra: chi legge solo questo deve poter ripartire.
2. `CHANGELOG.md`: una voce in alto con la data e il titolo della sessione; punti brevi su cosa cambia e perché, non l'elenco dei file.
3. ADR: una scelta non ovvia = un file `docs/adr/ADR-NNNN-<slug>.md` con Contesto, Decisione, Alternative scartate, Conseguenze. Aggiorna la tabella in `CLAUDE.md` se compare un documento nuovo.
4. `docs/QUESTIONS.md`: ogni dubbio è una riga numerata (D-xx) con il default applicato; le risposte arrivate si segnano con la data.
5. Se sono cambiati schema del ledger, rotte dell'API, regole di rischio o file in `Documenti\Encelado`: `docs/LEDGER_SCHEMA.md`, `docs/DATA_SOURCES.md`, `docs/RISK_RULES.md`, `docs/RUNBOOK.md`, `docs/KNOWN_ISSUES.md` (togli ciò che si chiude, aggiungi ciò che resta).
6. Verifica (`/encelado-verify`), poi proponi il messaggio di commit: titolo di una riga che dice cosa cambia, corpo che dice perché; un commit per sessione, un commit per fase se la sessione ne copre più d'una. Il push lo decide l'utente.
@@ -0,0 +1,16 @@
---
name: encelado-release
description: Rilascio di Encelado: verifica, zip portabile, immagine Docker, tag, push dell'immagine sul registro di Gitea e release con il template Unraid, in quest'ordine e solo dopo il commit e il push del ramo.
---
# Rilascio di Encelado
Presupposti: albero pulito, ultima sessione committata e pushata, `/encelado-verify` verde, Docker in esecuzione, `build/gitea.json` presente (mai committato: vedi `build/gitea.example.json`; il token deve avere anche `package: read and write`), versione decisa dall'utente (semver; la 5.0.0 è la prima con web UI e Docker).
1. `dotnet msbuild build/Release.proj -t:Verifica`.
2. `dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=<x.y.z>` (note in `ENCELADO_NOTE`): pubblica la cartella portabile, costruisce l'immagine `192.168.30.23:3000/alby96/encelado:<x.y.z>` e `:latest` (i test girano anche dentro la build), crea il tag, lo spinge e lo verifica sul remoto, fa il push dell'immagine sul registro di Gitea, crea la release con lo zip portabile e il template Unraid allegati. La versione viene dal tag, non da un file.
3. Template Unraid: aggiorna `deploy/unraid/encelado.xml` se sono cambiate variabili, porte o volumi; l'`Icon` è `assets/encelado.png` raw su Gitea (D-29). Il file finisce comunque allegato alla release con la versione nel nome.
4. Su Unraid: *Docker ▸ Controlla aggiornamenti*; il container riparte con SIGTERM pulito e recupero dopo inattività.
5. `CHANGELOG.md` e `docs/STATE.md` riportano la versione rilasciata; il tag e la release hanno lo stesso testo del CHANGELOG.
Senza Docker: `-p:SaltaDocker=true` produce solo lo zip (non è un rilascio completo, e va detto). Non modificare `build/Release.proj` se non richiesto: la catena è condivisa con Mimante e AutoBidder.
@@ -0,0 +1,14 @@
---
name: encelado-start
description: Avvia una sessione di lavoro su Encelado: legge lo stato, compila, riassume fase e problemi aperti, chiede cosa fare.
---
# Avvio di una sessione su Encelado
1. Leggi, in quest'ordine: `CLAUDE.md`, `docs/STATE.md`, `docs/PIANO_5.0.md` (se la 5.0 non è conclusa), `docs/QUESTIONS.md` (le domande in attesa), `docs/KNOWN_ISSUES.md`.
2. Controlla l'albero di lavoro: `git status --short` e `git log --oneline -5`. Un albero sporco va capito prima di toccare qualcosa.
3. Compila: `dotnet build Encelado.slnx`. Se non compila, la prima cosa da fare è farlo compilare, e va detto subito.
4. Riassumi in dieci righe: fase in corso, cosa è stato fatto nell'ultima sessione, prossimi passi, domande senza risposta, problemi aperti, esito della compilazione.
5. Chiedi all'utente cosa fare in questa sessione. Non iniziare una fase del piano senza che sia chiaro quale.
Regole che valgono sempre: niente pacchetti NuGet nell'applicazione, UTC ovunque, ledger append-only, mai un ordine reale senza flag e frase `CONFERMO LIVE`, un commit a fine sessione dopo la verifica.
@@ -0,0 +1,21 @@
---
name: encelado-ui
description: Regole per toccare l'interfaccia web di Encelado: i token Material 3 e le linee guida di docs/UI_GUIDELINES.md, nessuna libreria, stesse informazioni della dashboard, test dell'HTML e dello stream.
---
# Lavorare sull'interfaccia di Encelado
L'interfaccia è servita dal bot (`src/Encelado.Server/Web/wwwroot/`: `index.html`, `login.html`, `app.css`, `app.js`, `icon.svg`, `manifest.webmanifest`, incorporati come `EmbeddedResource`). Le API stanno in `Web/WebHost.cs`, lo snapshot JSON in `Engine/SnapshotJson.cs`, i dati finti in `Engine/SampleSnapshot.cs`. Leggi `docs/UI_GUIDELINES.md` prima di scrivere una riga. In sintesi:
- HTML (scritto come XHTML: tag chiusi, attributi con valore) + CSS + JavaScript vanilla; nessun framework, nessun font remoto, nessun `<script src=http…>`, nessun `@import`.
- La UI non decide niente: legge lo snapshot (`/api/stream`, SSE, uno al secondo; `?once=1` per una sola lettura) e manda comandi (`POST /api/commands/<nome>`). Un campo nuovo nello snapshot passa da `BotSnapshot``SnapshotJson``SampleSnapshot``app.js` → test.
- Ruoli di colore Material 3 (`--primary`, `--surface-container-*`, `--outline`, `--error`; `--up`/`--down` solo per P&L e stati), tema scuro di default e chiaro selezionabile (`ui.theme`), contrasto ≥ 4,5:1, cifre tabulari.
- Componenti propri: navigation rail (80/256 px, drawer sotto 600 px), top app bar, cards, data table compatta (cards sotto 600 px), chips, buttons, dialoghi nativi `<dialog>` per conferme (kill-switch con «chiudi anche le esterne», reset con motivazione, `CONFERMO LIVE`), snackbar, linear progress, tooltip con formula e fonte su ogni numero.
- Ogni importo convertito nella valuta di visualizzazione porta nel tooltip il valore in USD e il tasso usato; il ledger resta in USD.
- Accessibilità: tastiera completa, `aria-label`, focus visibile, `prefers-reduced-motion`, `prefers-color-scheme`.
Prima di dichiarare finita una modifica:
1. `dotnet test tests/Encelado.Tests --filter "FullyQualifiedName~EmbeddedUi|FullyQualifiedName~WebHost"` verde (HTML ben formato e senza riferimenti esterni, snapshot JSON, token, stream, storico).
2. `dotnet run --project src/Encelado.Server -- --sample --port 8085` e controllo a occhio nel browser (dashboard, storico, log, impostazioni, larghezza da telefono).
3. Se una pagina è cambiata, rifai gli screenshot in `docs/img/` (comando in `docs/UI_GUIDELINES.md`) e chiedi la revisione visiva all'utente.
@@ -0,0 +1,18 @@
---
name: encelado-verify
description: La verifica completa di Encelado prima di un commit: compilazione, test (inclusi HTML incorporato e stream), controllo dei file di configurazione e catena di rilascio.
---
# Verifica di Encelado
Esegui nell'ordine e riporta l'esito di ciascun passo con i numeri (errori, avvisi, test superati/non superati), senza abbellire:
1. `dotnet build Encelado.slnx` — deve chiudere con 0 errori e 0 avvisi (`TreatWarningsAsErrors` è attivo: un avviso è un errore). Se un server locale tiene bloccata `Encelado.Server.dll`, fermalo prima (`stop` sulla sua console o Ctrl+C).
2. `dotnet test tests/Encelado.Tests --no-build` — tutti verdi. Un test rosso si legge e si corregge; non si esclude.
3. Coerenza dei file di fabbrica: `config/strategy.json` deve essere identico a `BasketStrategyConfig.DefaultJson` e `config/encelado.json` a `ConfigDefaults.Json` (ci sono test che lo verificano: se falliscono, rigenera i file dalle costanti, non il contrario).
4. Interfaccia web: `EmbeddedUiTests` (HTML ben formato, nessun riferimento esterno, snapshot JSON) e `WebHostTests` (token, cookie, stream con uno snapshot al secondo, storico CSV) sono nella suite del passo 2; se hai toccato una pagina, anche il controllo a occhio con `--sample` (`/encelado-ui`).
5. `dotnet msbuild build/Release.proj -t:Verifica` — la catena di rilascio compila e testa nella cartella di verifica.
6. Se hai toccato `Dockerfile`, `deploy/` o la catena: `dotnet msbuild build/Release.proj -t:Docker` (o `docker build .`) deve produrre l'immagine; `docker run --rm <immagine> --health` non è un test valido a freddo (il server non gira): usa `docker compose up` e `docker inspect --format '{{.State.Health.Status}}' encelado`.
7. Riepilogo: una tabella `passo;esito;dettaglio`. Se un passo fallisce, fermati lì e proponi la correzione.
Non toccare `build/` se non richiesto: è condiviso con Mimante e AutoBidder.
+21
View File
@@ -0,0 +1,21 @@
# Ciò che non serve dentro il contesto di build. .git resta fuori: il commit
# arriva dal build-arg GIT_COMMIT (vedi build/Release.proj e la Dockerfile).
.git
.gitignore
.vscode
.claude
**/bin
**/obj
**/TestResults
bin
obj
results
reports
docs/img
deploy/local
build/gitea.json
*.md
!docs/**/*.md
Modifiche.txt
*.local.json
.env
+36
View File
@@ -0,0 +1,36 @@
# Verifica a ogni push e pull request: compilazione con gli avvisi come errori e la
# suite dei test. Un secondo lavoro costruisce l'immagine (senza pubblicarla) per
# accorgersi subito di una Dockerfile rotta: richiede un runner con Docker.
#
# Runner: act_runner con l'etichetta ubuntu-latest (vedi docs/DOCKER.md, «Gitea Actions»).
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
verifica:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Compilazione (avvisi = errori)
run: dotnet build Encelado.slnx -c Release -nologo -warnaserror
- name: Test
run: dotnet test tests/Encelado.Tests -c Release --no-build --nologo -v q
immagine:
runs-on: ubuntu-latest
needs: verifica
steps:
- uses: actions/checkout@v4
- name: Costruzione dell'immagine (i test girano anche dentro la build)
run: |
docker build --pull \
--build-arg VERSION=ci \
--build-arg GIT_COMMIT="${GITHUB_SHA::12}" \
-t encelado:ci .
+78
View File
@@ -0,0 +1,78 @@
# Rilascio a ogni tag vX.Y.Z: l'immagine sul registro dei container di Gitea
# (<host>/alby96/encelado:X.Y.Z e :latest) e la release con il template Unraid allegato.
#
# È la stessa cosa che fa build/Release.proj -t:Rilascia dal PC; qui gira sul runner
# quando il tag arriva sul server. Idempotente: un'immagine già presente viene
# sovrascritta con lo stesso contenuto, una release già creata dalla catena viene
# lasciata com'è e le manca solo l'allegato, che viene aggiunto se non c'è.
#
# Serve il segreto REGISTRY_TOKEN (Impostazioni ▸ Actions ▸ Segreti del repository):
# un token dell'utente con i permessi package:write e repository:write.
# Solo linux/amd64: la build esegue la suite dei test, e sotto QEMU (arm64) sarebbe
# lentissima; il server Unraid è amd64.
name: release
on:
push:
tags: ["v*"]
jobs:
immagine:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Versione e registro dal tag e dall'indirizzo del server
run: |
echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
echo "REGISTRY=$(echo "${{ gitea.server_url }}" | sed -E 's#^https?://##; s#/$##')" >> "$GITHUB_ENV"
echo "OWNER=$(echo "${{ gitea.repository_owner }}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/setup-buildx-action@v3
with:
# Il registro di Gitea è in HTTP: buildx deve poterlo trattare come insicuro.
driver-opts: network=host
buildkitd-config-inline: |
[registry."${{ env.REGISTRY }}"]
http = true
insecure = true
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
build-args: |
VERSION=${{ env.VERSION }}
GIT_COMMIT=${{ gitea.sha }}
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/encelado:${{ env.VERSION }}
${{ env.REGISTRY }}/${{ env.OWNER }}/encelado:latest
- name: Release con il template Unraid allegato
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: |
set -eu
TAG="${GITHUB_REF_NAME}"
ASSET="encelado-unraid-${VERSION}.xml"
cp deploy/unraid/encelado.xml "$ASSET"
ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1 || true)
if [ -z "$ID" ]; then
BODY=$(printf '{"tag_name":"%s","name":"Encelado %s","body":"Versione %s.\\n\\nImmagine: `%s/%s/encelado:%s` (anche `:latest`). Template Unraid allegato.","draft":false,"prerelease":false}' "$TAG" "$VERSION" "$VERSION" "$REGISTRY" "$OWNER" "$VERSION")
ID=$(curl -sf -H "Authorization: token $TOKEN" -H "Content-Type: application/json" --data-binary "$BODY" "$API/releases" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)
echo "release $TAG creata (id $ID)"
else
echo "release $TAG già presente (id $ID): aggiungo solo l'allegato mancante"
fi
if ! curl -sf -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" | grep -q "\"name\":\"$ASSET\""; then
curl -sf -H "Authorization: token $TOKEN" -F "attachment=@$ASSET" "$API/releases/$ID/assets?name=$ASSET" > /dev/null
echo "allegato $ASSET caricato"
fi
+3
View File
@@ -19,3 +19,6 @@ build/gitea.json
# Output dei test (coverlet)
TestResults/
# Volumi di docker-compose per le prove in locale (configurazione e dati del container)
deploy/local/
+4 -4
View File
@@ -4,9 +4,9 @@
"ms-dotnettools.csharp",
"ms-dotnettools.csdevkit",
// Colora installer\Encelado.iss e ne conosce direttive e costanti. Serve solo a
// leggere e scrivere quel file: l'installer si costruisce con il task
// "installer", che non dipende da nessuna estensione.
"idleberg.innosetup"
// Colora la Dockerfile e la docker-compose.yml e mostra container e immagini
// locali. L'immagine si costruisce con il task "immagine docker", che non
// dipende da nessuna estensione.
"ms-azuretools.vscode-docker"
]
}
+51 -7
View File
@@ -1,17 +1,61 @@
{
// One way to launch, on purpose. Encelado is a desktop application: F5 here starts
// the same window you get by double-clicking Encelado.exe. Everything else — login,
// start/stop, backtest, settings — lives inside that window.
// F5 avvia il server (motore + interfaccia web) come processo locale, fuori dal
// container, con configurazione e dati nella sandbox deploy/local (ignorata da git,
// stesse cartelle che il compose monta come /config e /data). Il browser si apre da
// solo appena Kestrel è in ascolto; senza token il server ascolta solo su localhost e
// non chiede il login. "Encelado (campione)" serve l'interfaccia con dati finti, senza
// chiavi né mercato: è quello che si usa per lavorare sulle pagine.
// Le chiavi eToro: variabili ETORO_API_KEY/ETORO_USER_KEY nell'ambiente di VS Code,
// oppure Impostazioni ▸ Chiavi eToro con ENCELADO_KEY_PASSPHRASE impostata qui sotto.
"version": "0.2.0",
"configurations": [
{
"name": "Encelado",
"name": "Encelado (server)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows/Encelado.exe",
"cwd": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows",
"console": "internalConsole",
"program": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll",
"args": ["--no-autostart"],
"cwd": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0",
"console": "integratedTerminal",
"stopAtEntry": false,
"env": {
"ENCELADO_WEB_PORT": "8080",
"ENCELADO_CONFIG_DIR": "${workspaceFolder}/deploy/local/config",
"ENCELADO_DATA_DIR": "${workspaceFolder}/deploy/local/data",
"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
}
]
+49 -13
View File
@@ -3,7 +3,8 @@
// MSBuild versionato col codice, e qui restano soltanto i nomi e le domande.
// MSBuild non può chiedere niente a nessuno — i prompt stanno in "inputs".
//
// È la stessa impostazione di Mimante/AutoBidder.
// È la stessa impostazione di Mimante/AutoBidder, con l'immagine Docker al
// posto dell'installatore.
"version": "2.0.0",
"tasks": [
{
@@ -39,7 +40,7 @@
},
{
"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",
"command": "dotnet",
"args": [
@@ -55,14 +56,14 @@
"problemMatcher": []
},
{
"label": "crea installatore",
"detail": "Verifica, pubblica ed esegue Inno Setup: bin/installer/Encelado-<versione>-setup.exe. Crea il tag a pacchetto pronto. Non tocca Gitea.",
"label": "immagine docker",
"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",
"command": "dotnet",
"args": [
"msbuild",
"${workspaceFolder}/build/Release.proj",
"-t:Pacchetto",
"-t:Docker",
"-p:Versione=${input:versione}",
"-nologo",
"-v:m"
@@ -71,8 +72,8 @@
"problemMatcher": "$msCompile"
},
{
"label": "crea installatore (senza rieseguire i test)",
"detail": "Solo pubblicazione e Inno Setup. Da usare quando i test sono appena passati.",
"label": "pacchetto",
"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",
"command": "dotnet",
"args": [
@@ -80,7 +81,6 @@
"${workspaceFolder}/build/Release.proj",
"-t:Pacchetto",
"-p:Versione=${input:versione}",
"-p:SaltaVerifica=true",
"-nologo",
"-v:m"
],
@@ -89,7 +89,7 @@
},
{
"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",
"command": "dotnet",
"args": [
@@ -110,13 +110,49 @@
},
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": "$msCompile"
},
{
"label": "avvia in locale (script)",
"detail": "scripts/run-dev.ps1: compila e avvia il server nella sandbox deploy/local, senza container. Ctrl+C per fermare.",
"type": "shell",
"command": "powershell -ExecutionPolicy Bypass -File scripts/run-dev.ps1",
"options": { "cwd": "${workspaceFolder}" },
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": []
},
{
"label": "avvia in docker",
"detail": "scripts/run-docker.ps1: costruisce l'immagine e avvia il container dalla docker-compose.yml (deploy/local come /config e /data), poi segue il log.",
"type": "shell",
"command": "powershell -ExecutionPolicy Bypass -File scripts/run-docker.ps1",
"options": { "cwd": "${workspaceFolder}" },
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": []
},
{
"label": "ferma docker",
"detail": "docker compose down: SIGTERM al bot, stop pulito, container rimosso (cartelle in deploy/local conservate).",
"type": "shell",
"command": "docker compose down",
"options": { "cwd": "${workspaceFolder}" },
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": []
},
{
"label": "screenshot",
"detail": "scripts/screenshots.ps1: server in modalità campione e Edge headless, rigenera docs/img/*.png.",
"type": "shell",
"command": "powershell -ExecutionPolicy Bypass -File scripts/screenshots.ps1",
"options": { "cwd": "${workspaceFolder}" },
"presentation": { "reveal": "always", "panel": "dedicated" },
"problemMatcher": []
}
],
"inputs": [
{
"id": "versione",
"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": ""
},
{
@@ -128,14 +164,14 @@
{
"id": "dati",
"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"
},
{
"id": "comando",
"type": "pickString",
"description": "Cosa misurare",
"options": ["baskets", "falsify", "ticks"],
"description": "Comando del backtest",
"options": ["baskets", "falsify", "ticks", "learn"],
"default": "baskets"
}
]
+67
View File
@@ -2,6 +2,73 @@
Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto.
## 2026-09-23 — 5.0, Fasi 6-9: solo container, interfaccia web, Docker e Unraid, pulizia
- **Ritirata la finestra WPF** (D-28, ADR-0007): `src/Encelado.Bot` diventa la libreria `src/Encelado.Engine`; l'eseguibile è `src/Encelado.Server` (Kestrel, nessun NuGet) che ospita il motore e serve l'interfaccia. Chiavi eToro da variabili d'ambiente o dal file cifrato `etoro.keys.enc` (AES-256-GCM, `ENCELADO_KEY_PASSPHRASE`) al posto di DPAPI. Cartelle `/config` e `/data` nel container, `Documenti\Encelado` fuori.
- **Interfaccia web Material 3** senza librerie (`Web/wwwroot`): navigation rail, barra con orologi UTC e fuso, valuta di visualizzazione (otto valute, tasso dalle quotazioni, tooltip in USD), AVVIA/FERMA e KILL-SWITCH con dialoghi (esterne, motivazione, `CONFERMO LIVE`); dashboard con sei KPI e la tabella dei basket (cards da telefono); **Storico ordini** (ordini, posizioni classificate `basket`/`orfana-bot`/`esterna`/`movimento di cassa`, profitti per periodo con curva dell'equity, CSV); Log; Impostazioni con configurazione validata, chiavi, ripristino in cinque passi, Ricerca (stato dell'apprendimento e ciclo a richiesta), Diagnostica (percorsi, quote, bonifica), Informazioni (versione, data di build, commit). SSE `/api/stream` a uno snapshot al secondo; token `ENCELADO_WEB_TOKEN` in cookie HttpOnly; senza token solo localhost. Tema scuro e chiaro. Regole in `docs/UI_GUIDELINES.md`, screenshot in `docs/img/`.
- **Docker e Unraid** (ADR-0008): `Dockerfile` multi-stage con i test nello stadio di build, `entrypoint.sh` con TZ e PUID/PGID, `docker-compose.yml`, `HEALTHCHECK` via `--health`; template `deploy/unraid/encelado.xml` con l'icona servita da Gitea (D-29); catena di rilascio senza Inno Setup: `Pubblica` framework-dependent, `Docker`, `Pacchetto` (zip + template + tag), `Rilascia` con push dell'immagine sul registro di Gitea. VS Code: F5 sul server (browser aperto da solo), modalità campione `--sample`, attività Docker.
- **Strumento**: comando `learn` (ciclo di apprendimento in ombra su un ledger esportato).
- **Pulizia**: rimossi WPF, DPAPI, i test di rendering e di binding, ~45 membri mai usati nel Core e nell'Engine (statistiche di ricerca, `Psi`, `EmptyContextProvider`, `EnvironmentKeyStore`, display helper dello snapshot, `INotifyPropertyChanged`), `build/Encelado.iss`. `PROMPT.md``docs/PROMPT_5.0.md`.
- **Documenti**: ADR-0007, ADR-0008, `docs/DOCKER.md`, `docs/UI_GUIDELINES.md`, `README.md`, `CLAUDE.md`, ARCHITECTURE, RUNBOOK, KNOWN_ISSUES, GLOSSARY, DATA_SOURCES, ML_AND_LEARNING, QUESTIONS (D-28, D-29, D-36, D-37), POSTMORTEM (gli accrediti del demo erano uno per ordine ridotto), skill, `build/README.md`.
- **Avvio in locale**: sandbox `deploy/local/` con la stessa disposizione del container (`ENCELADO_DATA_DIR` vale anche fuori dal container), F5 in VS Code sulla sandbox, script `scripts/run-dev`, `run-docker`, `screenshots` (`.ps1` e `.sh`), attività di VS Code per avviare, fermare e fotografare. Arresto pulito su SIGTERM (`PosixSignalRegistration`) verificato con `docker stop`; `/api/health` in `--sample` non finge un motore acceso.
- Test nuovi: `EmbeddedUiTests`, `WebHostTests` (token, cookie, stream, storico), `HistoryBuilderTests`, `KeyStoreTests`; 210 verdi, anche su Linux dentro `docker build`. Versione di sviluppo 5.0.0.
## 2026-09-23 — 5.0, apprendimento in ombra (ADR-0006) e skill di progetto
- Sezione `learning` in `strategy.json` (`enabled`, `weeklyCycle`, `challenger`, tutti `false` di fabbrica): il ciclo settimanale e il challenger non girano nel bot, il cancello ML non può attivarsi, la logistica resta in ombra con `p_ML` nel ledger, il bandit propone e basta. Criterio di riattivazione in `docs/ML_AND_LEARNING.md`.
- Skill di progetto in `.claude/skills/`: `encelado-start`, `encelado-verify`, `encelado-diagnose`, `encelado-docs`, `encelado-release`, `encelado-ui`.
- `CLAUDE.md` aggiornato (esecuzione 5.0, `--bonifica`, variabili Telegram, skill); glossario e problemi noti aggiornati.
- Catena di verifica (`build/Release.proj -t:Verifica`) verde.
## 2026-09-23 — 5.0, Fase 5: notifiche e comandi Telegram
- `TelegramNotifier` nel Core (solo `HttpClient`): coda non bloccante, un messaggio al secondo, tre tentativi con backoff e `retry_after`, spezzatura a 4096 caratteri, long polling `getUpdates` da un solo task, comandi accettati solo dalla chat autorizzata.
- Sezione `notifications.telegram` in `encelado.json` e in Impostazioni → Notifiche; token da `TELEGRAM_BOT_TOKEN`, chat da `TELEGRAM_CHAT_ID`.
- Stato ogni ora, riepilogo giornaliero, eventi (avvio/arresto, basket aperto/chiuso, gamba in attesa, ordine risolto, unwind, orfana, kill-switch, equity stop, perdita giornaliera, margin guard, recupero, reset, preset, API in errore, scarto orologio).
- Comandi `/stato`, `/posizioni`, `/storico Nd`, `/pausa`, `/riprendi`, `/chiudi`, `/kill CONFERMO`, `/reset`; comandi `Pause`, `Resume`, `History` nel motore; ogni comando eseguito (finestra, console, Telegram) scrive una riga `comando` nel ledger.
- Test (s), (t), (u) e altri: 219 verdi.
## 2026-09-23 — 5.0, Fase 4: recupero dopo inattività, heartbeat, lock di istanza
- `data/state/heartbeat.json` ogni 30 s (all'avvio e all'arresto con nota); all'avvio e a ogni ciclo l'inattività oltre `recovery.thresholdMinutes` (10) fa partire il recupero.
- Recupero: entrate bloccate, ordini senza esito risolti, riconciliazione, riscaldamento con le barre perse e `barsHeld` aggiornato, ogni basket aperto rivalutato con le regole di uscita ordinarie (chiudi/tieni), orfane chiuse, esterne riportate, `reports/recupero_<run_id>.csv`, righe `recupero_avviato`/`recupero_concluso`, notifica, entrate riaperte dopo `recovery.warmupMinutes` (15). A mercato chiuso aspetta le quotazioni.
- Sezione `recovery` in `strategy.json`; il reset usa lo stesso riscaldamento.
- `data/state/instance.lock` tenuto in esclusiva: un secondo bot sulla stessa cartella dati non parte (problema noto dalla 4.0.0, chiuso).
- `RecoveryPlanner`, `HeartbeatFile`, `InstanceLock` nel Core; test (r) e altri sei: 211 verdi.
## 2026-09-23 — 5.0, Fase 3: kill-switch che chiude davvero, ripristino in cinque passi
- `FlattenProcedure` nel Core: annullamento degli ordini senza esito (con attesa della risoluzione), chiusura delle posizioni, verifica di piattezza sul conto; stato `Halted` o `Halted-Residuo`.
- Il kill-switch (pulsante, `kill`, file `STOP`) chiude basket, gambe in attesa e orfane; le posizioni esterne solo su richiesta esplicita o con `risk.closeForeignOnKill`; niente è dichiarato chiuso senza la rilettura del conto; i residui finiscono nel banner, nello stato salvato e nel ledger (`kill_switch_concluso`).
- Comando `CloseResidue` (`residuo` da console, «chiudi ora» dalla finestra) per riprovare sui residui.
- Reset in cinque passi (stato, file `STOP`, motivazione, riconciliazione + riscaldamento + picco, ripartenza con 15 minuti di entrate bloccate); rifiutato con `reset_rifiutato` finché una posizione del bot resta sul conto.
- `INotifier` e `NullNotifier`: il motore notifica kill-switch e reset; il canale Telegram arriva con la Fase 5.
- Test (v), (w), (x): 204 verdi.
## 2026-09-23 — 5.0, Fase 2: il margine come vincolo di primo livello
- Sezione `risk` in `strategy.json` (`maxMarginUsePct` 40, `maxMarginPerBasketPct` 12, `marginBufferPct` 25, `closeForeignOnKill` false, `marginCallBlockRatio` 1,5, `marginCallCloseRatio` 1,2), letta, validata e nell'hash della configurazione.
- Sizing = min(rischio, margine): `VolParitySizing` riceve il margine massimo e le leve delle gambe, dice quale vincolo ha deciso (`sizing_bound`) e quanto margine impegna (`marginUsd`), entrambi nel ledger; sotto l'esposizione minima l'ingresso è rifiutato con `min_exposure`.
- Il decisore calcola il margine disponibile per un basket (per basket, totale, disponibile / buffer) e blocca con `margin` o `margin_guard`.
- L'esecutore rilegge il conto prima della gamba B e, se il disponibile non copre `marginB × 1,25`, non la manda e richiude A.
- I segnali della stessa barra vengono eseguiti per |z| decrescente, rileggendo il conto e ridecidendo prima di ciascuno.
- Margin guard alla riconciliazione: sotto 1,5 entrate bloccate, sotto 1,2 chiusura del basket con il P&L peggiore.
- Il backtest passa al decisore disponibile, margine usato e leve del simulatore: applica gli stessi limiti.
- Test (y), (z) e altri quattro: 196 verdi.
## 2026-09-23 — 5.0, Fasi 0-1: post-mortem degli ordini pendenti, registro degli ordini, orfane adottate
- **Diagnosi verificata** sul codice e sul conto demo via API: il lookup per `referenceId` fallisce perché il server registra un riferimento nullo per gli ordini v2; 21 gambe singole del bot fra il 16 e il 21/9, chiuse a mano il 21/9; i primi due ordini hanno impegnato tutta l'equity come margine e da lì il server ha ridotto ogni ordine a 2 000 USD di margine. `docs/PIANO_5.0.md`, `docs/POSTMORTEM_ordini_pendenti.md`, domande D-26…D-37.
- **Registro persistente degli ordini** (`OrderTracker`, `data/state/pending_orders.json`, `orders.jsonl`): scritto prima di ogni invio, risolto per `orderId`, per riferimento e per posizione comparsa; ricaricato all'avvio e risolto prima di ogni decisione. Nessun esito inventato: `Unknown` resta pendente (ADR-0009).
- **Stati `PendingA`/`PendingB`**: una gamba senza esito non è più un rifiuto; alla risoluzione parte la gamba B (ridimensionata sulle unità eseguite di A) oppure la gamba A viene richiusa se il segnale è decaduto.
- **Classificazione delle posizioni** (`basket` / `orfana-bot` / `esterna`) a ogni riconciliazione; le orfane del bot vengono adottate e chiuse; contatori «in attesa · orfane · esterne» e P&L aperto **del conto** in dashboard; avviso «posizioni non riconciliate».
- **Movimenti di cassa** riconosciuti e scritti nel ledger; picco di equity e drawdown al netto (`EquityTracker`).
- **Bonifica** (`--headless --bonifica`, comando `bonifica`): elenco delle orfane con conferma per posizione, rapporto in `reports/bonifica_YYYYMMDD.csv`.
- `IBroker.LookupOrderByIdAsync` e `CancelOrderAsync`; `EtoroBroker.OpenAsync` legge l'esito per `orderId` e riconosce l'esecuzione dalla posizione; parser dell'esito v2 e v1.
- Il bandit **propone e non applica** più il preset (D-30).
- `BasketEngine` spezzato in sei file parziali.
- Test nuovi (m)-(q) e altri 13: 190 test verdi.
## 2026-09-16 (pomeriggio) — 4.0.0: solo Correlation Baskets su eToro, bot autonomo, interfaccia nuova
- **Rimossi** i motori precedenti: Binance, Alpaca, cTrader/proba, SQLite, GBDT, RL, TA-Lib, indicatori, backtest a coppie, pagine e test relativi (ADR-0004). Nessun pacchetto NuGet nell'applicazione.
+37 -14
View File
@@ -4,35 +4,51 @@
## Scopo
Bot di trading in C# (.NET 10, WPF) su **eToro** con la strategia "Correlation Baskets": cinque basket di due coppie forex correlate, ingresso quando il cross sintetico diverge (z-score), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale, ledger completo, feed gratuiti di calendario e notizie, livelli di apprendimento 0-3 costruiti da zero. È l'unica strategia del repository: i motori precedenti (Binance, cTrader/proba, ricerca) sono stati rimossi il 2026-09-16 (ADR-0004) e vivono solo nella storia git. Il bot opera da solo in ogni modalità (ADR-0005): `Paper`, `Demo` (default), `Live`.
Bot di trading in C# (.NET 10) su **eToro** con la strategia "Correlation Baskets": cinque basket di due coppie forex correlate, ingresso quando il cross sintetico diverge (z-score), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale, ledger completo, feed gratuiti di calendario e notizie, livelli di apprendimento 0-3 costruiti da zero. È l'unica strategia del repository: i motori precedenti (Binance, cTrader/proba, ricerca) sono stati rimossi il 2026-09-16 (ADR-0004) e vivono solo nella storia git. Il bot opera da solo in ogni modalità (ADR-0005): `Paper`, `Demo` (default), `Live`. Dalla 5.0 gira **solo in un container** (ADR-0007, ADR-0008): un processo `Encelado.Server` che ospita il motore e serve l'interfaccia web; la finestra WPF non esiste più.
## Mappa dei documenti
| File | Contenuto |
|---|---|
| `docs/STATE.md` | stato corrente, fase, ultima sessione, prossimi passi, problemi aperti |
| `docs/ARCHITECTURE.md` | progetti, flusso dati, macchine a stati, interfacce |
| `docs/ARCHITECTURE.md` | progetti, flusso dati, macchine a stati, interfacce, API web |
| `docs/STRATEGY.md` | logica dei basket, cross sintetici, formule, preset, aspettative oneste, numeri |
| `docs/ML_AND_LEARNING.md` | livelli 0-3, feature, label, addestramento, attivazione, esclusioni |
| `docs/ML_AND_LEARNING.md` | livelli 0-3, feature, label, addestramento, attivazione, esclusioni, comando `learn` |
| `docs/DATA_SOURCES.md` | ogni fonte (URL, formato, limiti), schema dei file in `data/` |
| `docs/LEDGER_SCHEMA.md` | schema di `decisions.jsonl`, `baskets.csv`, `trials.csv`, `calibration.csv`, `preregistrazione.csv`, `proposals.csv` |
| `docs/LEDGER_SCHEMA.md` | schema di `decisions.jsonl`, `orders.jsonl`, `baskets.csv`, `trials.csv`, `calibration.csv`, `preregistrazione.csv`, `proposals.csv` |
| `docs/RISK_RULES.md` | regole di sicurezza con i default e chi può cambiarle |
| `docs/RUNBOOK.md` | avvio, arresto, kill-switch, reset, riconciliazione, chiavi, errori API, checklist |
| `docs/DOCKER.md`, `deploy/unraid/README.md` | immagine, volumi, variabili, healthcheck, aggiornamento; template Unraid |
| `docs/UI_GUIDELINES.md` | l'interfaccia web: token Material 3, componenti, pagine, screenshot |
| `docs/QUESTIONS.md` | domande poste per fase, risposte o default applicati, con data |
| `docs/PIANO_5.0.md`, `docs/PROMPT_5.0.md`, `docs/POSTMORTEM_ordini_pendenti.md` | il piano della 5.0 (fasi, stime, decisioni vincolanti), la specifica originale e il post-mortem delle gambe orfane del 16-21/9/2026 |
| `docs/GLOSSARY.md`, `docs/KNOWN_ISSUES.md`, `CHANGELOG.md`, `docs/adr/` | glossario, problemi noti, cronologia, decisioni architetturali |
| `build/README.md` | catena di verifica, pacchetto e rilascio |
| `build/README.md` | catena di verifica, pacchetto (immagine Docker) e rilascio; `.gitea/workflows/` per le Actions |
## Progetti
```
src/Encelado.Core logica pura, zero I/O, AOT/trim-compatibile
src/Encelado.Etoro client HTTP di eToro Public API
src/Encelado.Engine motore, ledger, feed, configurazione, impostazioni, storico, Telegram (ex Encelado.Bot senza UI)
src/Encelado.Server l'eseguibile: Kestrel, API JSON scritta a mano, SSE, UI incorporata (Web/wwwroot)
tools/Encelado.Backtest ticks, baskets, falsify, learn
tests/Encelado.Tests xunit
deploy/docker, deploy/unraid entrypoint del container, template Unraid
```
## Convenzioni
- **C#**, `Nullable` e `TreatWarningsAsErrors` attivi. Identificatori e commenti tecnici in inglese; documentazione, report e colonna `motivazione` in italiano.
- **Nessun pacchetto NuGet.** Solo BCL e WPF nei progetti dell'applicazione; xunit nei test.
- **Nessun pacchetto NuGet.** Solo BCL e i framework reference dell'SDK (`Microsoft.AspNetCore.App` nel Server) nei progetti dell'applicazione; xunit nei test. Nessuna libreria JavaScript o CSS: l'interfaccia è HTML, CSS e JS vanilla incorporati.
- **Tabelle**: CSV con separatore `;`, header, ultima colonna `motivazione`; JSONL append-only per ledger e notizie; JSON per modelli e stato. Scritture atomiche (`.tmp` + `File.Move`), rotazione mensile. **Nessuna riga del ledger viene mai modificata**: le correzioni sono righe nuove con `evento = correzione`.
- **Tempo**: UTC ovunque; conversione solo in UI. `CultureInfo.InvariantCulture` per ogni parsing e formattazione su file.
- **Concorrenza**: un solo thread di decisione; I/O asincrono; `Channel<T>` fra ingestion, strategia, esecuzione e UI.
- **Riproducibilità**: seed fisso 42 per ogni componente stocastica; ogni run scrive `run_id`, hash della configurazione e versione del codice nel ledger.
- Cartelle a runtime sotto `Documenti\Encelado\`: `data/`, `knowledge/`, `reports/`, `results/`, `logs/`. Credenziali solo in `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o variabili d'ambiente `ETORO_API_KEY`, `ETORO_USER_KEY`.
- **Interfaccia**: una barra in alto (schede Dashboard / Log / Impostazioni, stato, ambiente, ora nel fuso scelto, AVVIA), pagine sotto. Nella dashboard solo le informazioni principali; i dettagli nei tooltip e nel log. Gli orari a schermo passano da `UiClock` (`ui.timeZone`); il log porta l'offset, il ledger è UTC.
- **Verifica visiva**: `ENCELADO_RENDER_DIR=<cartella> dotnet test tests/Encelado.Tests --filter UiRenderTests` scrive `dashboard.png`, `log.png`, `settings.png`, `window.png`.
- **Cartelle a runtime**: nel container `/config` (encelado.json, strategy.json, instruments.json, `etoro.keys.enc`, file `STOP`) e `/data` (`data/`, `knowledge/`, `reports/`, `results/`, `logs/`); fuori dal container (solo sviluppo) la sandbox `deploy/local/` (`ENCELADO_CONFIG_DIR` + `ENCELADO_DATA_DIR`, usata da F5 e dagli script) o, senza variabili, `Documenti\Encelado\`. Credenziali solo nelle variabili `ETORO_API_KEY`/`ETORO_USER_KEY` o nel file cifrato (AES-256-GCM, passphrase `ENCELADO_KEY_PASSPHRASE`); token Telegram solo in `TELEGRAM_BOT_TOKEN` (chat in `TELEGRAM_CHAT_ID`); token dell'interfaccia in `ENCELADO_WEB_TOKEN`.
- **Esecuzione (5.0)**: ogni ordine entra nel registro `data/state/pending_orders.json` prima dell'HTTP e l'esito si legge per `orderId` (il server non registra il `referenceId`); una gamba senza esito porta il basket in `PendingA`/`PendingB`, mai in un rifiuto; ogni posizione del conto è `basket`, `orfana-bot` (adottata e chiusa) o `esterna` (mai toccata); la size è il minimo fra rischio e margine (`strategy.json``risk`); il kill-switch verifica la piattezza sul conto e dichiara `Halted-Residuo` se resta qualcosa; heartbeat e recupero dopo inattività (`recovery`); un solo bot per cartella dati (`instance.lock`).
- **Interfaccia**: navigation rail a sinistra (Dashboard, Storico ordini, Log, Impostazioni), barra in alto (titolo, ora UTC e nel fuso, valuta, AVVIA/FERMA, KILL-SWITCH), pagine sotto. Nella dashboard solo le informazioni principali; i dettagli nei tooltip e nel log. La UI non decide niente: legge lo snapshot via SSE e manda comandi via `POST /api/commands/<nome>`. Gli orari a schermo sono nel fuso scelto (`ui.timeZone`, `TZ`); il log porta l'offset, il ledger è UTC. Regole complete in `docs/UI_GUIDELINES.md`.
- **Verifica visiva**: `scripts\screenshots.ps1` (server `--sample` + Edge headless, `docs/UI_GUIDELINES.md`); i test `EmbeddedUiTests` e `WebHostTests` coprono HTML, JSON, token e stream.
## Comandi
@@ -40,28 +56,35 @@ Bot di trading in C# (.NET 10, WPF) su **eToro** con la strategia "Correlation B
dotnet build Encelado.slnx # compilazione
dotnet test tests/Encelado.Tests --no-restore # test (xunit)
dotnet msbuild build/Release.proj -t:Verifica # compilazione + test nella cartella di verifica
dotnet run --project src/Encelado.Bot -- --headless [--minutes 240] # bot senza finestra (VPS, test lunghi)
dotnet run --project src/Encelado.Server -- [--no-autostart] [--sample] [--port 8080] [--minutes 240] [--bonifica] [--confirm-live "CONFERMO LIVE"] # server locale; comandi da tastiera: status, close, kill, residuo, preset, reset, bonifica, stop
scripts\run-dev.ps1 [-Sample] [-Autostart] # server locale nella sandbox deploy/local (= F5 in VS Code); .sh per Git Bash
scripts\run-docker.ps1 [-Down] # il container dalla compose, stessa sandbox, token "sviluppo"
scripts\screenshots.ps1 # docs/img dal server campione
dotnet run --project tools/Encelado.Backtest -- ticks --data "A:\Download\Trading" --out "%USERPROFILE%\Documents\Encelado\data\market"
dotnet run --project tools/Encelado.Backtest -- baskets --data "%USERPROFILE%\Documents\Encelado\data\market" --out results
dotnet run --project tools/Encelado.Backtest -- falsify --data "%USERPROFILE%\Documents\Encelado\data\market" --out reports [--costs api]
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=4.0.0 # installatore + zip + tag + release su Gitea (dopo il commit e il push del ramo)
dotnet run --project tools/Encelado.Backtest -- learn --data "%USERPROFILE%\Documents\Encelado\data" [--knowledge ] [--strategy config/strategy.json]
dotnet msbuild build/Release.proj -t:Docker # immagine 192.168.30.23:3000/alby96/encelado:<v> (test dentro la build)
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=5.0.0 # zip + immagine + tag + push dell'immagine + release su Gitea (dopo il commit e il push del ramo)
docker compose up --build # il container in locale (deploy/local/, segreti in .env)
```
## Regole
1. **Chiedi se hai un dubbio.** Le domande vanno in `docs/QUESTIONS.md`, numerate, con il default che applicheresti; in assenza di risposta applica il default più prudente e annotalo.
2. **Mai un ordine reale senza flag e conferma.** `run.executionMode` predefinito `Demo`; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` all'avvio. Nessuna approvazione per singolo ordine (D-20): il bot opera da solo.
2. **Mai un ordine reale senza flag e conferma.** `run.executionMode` predefinito `Demo`; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` (dialogo dell'interfaccia, `--confirm-live`, o `ENCELADO_CONFIRM_LIVE`). Nessuna approvazione per singolo ordine (D-20): il bot opera da solo.
3. **Mai una riga del ledger modificata.**
4. **Mai un risultato abbellito.** Se la strategia non regge i costi di eToro, il report lo dice con i numeri. "Nessuna configurazione profittevole" è un esito ammesso.
5. **Non toccare la catena di rilascio** (`build/`) se non richiesto; è condivisa con Mimante/AutoBidder.
6. **Un commit a fine sessione**, dopo che la verifica passa, con un messaggio che dice cosa cambia e perché. Il push lo decide l'utente.
7. Aggiorna `docs/STATE.md` e `CHANGELOG.md` a fine sessione; un ADR per ogni scelta non ovvia.
8. Le skill di progetto in `.claude/skills/` (`encelado-start`, `encelado-verify`, `encelado-diagnose`, `encelado-docs`, `encelado-release`, `encelado-ui`) dicono come si comincia, si verifica, si diagnostica, si chiude e si rilascia: usale.
## Cose da non fare
- Non scrivere chiavi in chat, nel log, nel repo o in `Documenti`.
- Non scrivere chiavi in chat, nel log, nel repo, in `Documenti` o nel template Unraid.
- Non usare spread fissi nel cost gate: sempre lo spread reale letto dall'API in quel momento più il markup dell'endpoint dei costi.
- Non ricostruire feature a posteriori: il dataset di addestramento è il ledger scritto al momento della decisione.
- Non cambiare parametri live in automatico: le proposte passano da `knowledge/proposals.csv` e dal forward test.
- Non spostare l'installazione in Program Files (vedi `docs/adr/`): l'app scrive accanto alla configurazione.
- Non reintrodurre un'interfaccia desktop né librerie JavaScript: l'interfaccia è servita dal bot ed è vanilla (ADR-0007).
- Non usare heredoc lunghi o con backslash nel Bash tool: usare `Write`/`Edit` (vedi memoria `strumenti-heredoc-backslash`).
+6 -6
View File
@@ -13,12 +13,12 @@
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<Product>Encelado</Product>
<Company>Encelado</Company>
<!-- Numero delle compilazioni di sviluppo: è quello che compare nella finestra
mentre si lavora. La versione RILASCIATA viene dal tag git — vedi
build/Release.proj — e questo serve solo da seme quando non esiste ancora
nessun tag. Tenerlo allineato all'ultimo rilascio evita di leggere in
finestra un numero che non corrisponde a niente. -->
<Version>4.0.0</Version>
<!-- Numero delle compilazioni di sviluppo: è quello che compare in
Impostazioni ▸ Informazioni mentre si lavora. La versione RILASCIATA viene
dal tag git — vedi build/Release.proj — e questo serve solo da seme quando
non esiste ancora nessun tag. Tenerlo allineato all'ultimo rilascio evita
di leggere a schermo un numero che non corrisponde a niente. -->
<Version>5.0.0</Version>
</PropertyGroup>
<!--
+90
View File
@@ -0,0 +1,90 @@
# syntax=docker/dockerfile:1.7
#
# Encelado — immagine del bot (motore + interfaccia web su Kestrel).
#
# Tre stadi: build (restore + compilazione), test (la suite xunit gira dentro la
# build: un'immagine con i test rossi non esiste), publish (framework-dependent,
# non trimmed: il server ospita ASP.NET Core minimal e non è AOT). Il runtime è
# l'immagine ufficiale aspnet, con gosu per scendere ai PUID/PGID di Unraid.
#
# docker build -t 192.168.30.23:3000/alby96/encelado:5.0.0 --build-arg GIT_COMMIT=$(git rev-parse --short=12 HEAD) .
# docker run -p 8080:8080 -v ./config:/config -v ./data:/data -e TZ=Europe/Rome 192.168.30.23:3000/alby96/encelado:5.0.0
#
# Vedi docs/DOCKER.md per volumi, variabili, healthcheck e aggiornamento.
ARG DOTNET_VERSION=10.0
# ---------------------------------------------------------------- build
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
ARG GIT_COMMIT=n/d
WORKDIR /src
# Prima i file di progetto, così il restore resta in cache finché non cambiano.
COPY Directory.Build.props Encelado.slnx ./
COPY src/Encelado.Core/Encelado.Core.csproj src/Encelado.Core/
COPY src/Encelado.Etoro/Encelado.Etoro.csproj src/Encelado.Etoro/
COPY src/Encelado.Engine/Encelado.Engine.csproj src/Encelado.Engine/
COPY src/Encelado.Server/Encelado.Server.csproj src/Encelado.Server/
COPY tools/Encelado.Backtest/Encelado.Backtest.csproj tools/Encelado.Backtest/
COPY tests/Encelado.Tests/Encelado.Tests.csproj tests/Encelado.Tests/
RUN dotnet restore Encelado.slnx
COPY . .
RUN dotnet build Encelado.slnx -c Release --no-restore -p:GitCommit=${GIT_COMMIT}
# ---------------------------------------------------------------- test
FROM build AS test
RUN dotnet test tests/Encelado.Tests -c Release --no-build --nologo -v q
# ---------------------------------------------------------------- publish
FROM build AS publish
ARG GIT_COMMIT=n/d
# Dipende dallo stadio test solo per ordine: la publish parte se i test sono verdi.
COPY --from=test /src/tests/Encelado.Tests/bin/Release/net10.0/Encelado.Tests.dll /tmp/tests-ok
RUN dotnet publish src/Encelado.Server/Encelado.Server.csproj -c Release --no-build --no-restore -p:GitCommit=${GIT_COMMIT} -o /app
# ---------------------------------------------------------------- runtime
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
ARG VERSION=dev
ARG GIT_COMMIT=n/d
LABEL org.opencontainers.image.title="Encelado" \
org.opencontainers.image.description="Correlation Baskets su eToro: motore, ledger e interfaccia web" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${GIT_COMMIT}" \
org.opencontainers.image.authors="Alberto Balbo" \
org.opencontainers.image.source="http://192.168.30.23:3000/Alby96/Encelado"
# gosu per cambiare utente dopo aver sistemato i permessi; tzdata per TZ; curl
# non serve: il healthcheck è il server stesso con --health.
RUN apt-get update \
&& apt-get install -y --no-install-recommends gosu tzdata ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV ENCELADO_IN_CONTAINER=1 \
ENCELADO_WEB_PORT=8080 \
ENCELADO_AUTOSTART=1 \
DOTNET_gcServer=1 \
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
ASPNETCORE_URLS= \
ASPNETCORE_HTTP_PORTS= \
TZ=UTC \
PUID=99 \
PGID=100
WORKDIR /app
COPY --from=publish /app ./
COPY deploy/docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir -p /config /data
VOLUME ["/config", "/data"]
EXPOSE 8080
# Il server risponde da solo alla sonda: GET /api/health sulla porta configurata.
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD ["dotnet", "/app/Encelado.Server.dll", "--health"]
# SIGTERM arriva al processo dotnet (exec nell'entrypoint): stop pulito, ledger
# chiuso, posizioni lasciate sul conto con gli stop nativi (closeOnShutdown).
STOPSIGNAL SIGTERM
ENTRYPOINT ["/entrypoint.sh"]
CMD ["dotnet", "/app/Encelado.Server.dll"]
+2 -1
View File
@@ -1,6 +1,7 @@
<Solution>
<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.Etoro/Encelado.Etoro.csproj" />
</Folder>
+72
View File
@@ -0,0 +1,72 @@
# Encelado
Bot di trading **Correlation Baskets** su eToro (Public API): cinque basket di due coppie forex correlate, ingresso quando il cross sintetico diverge (z-score), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale, ledger completo, calendario e notizie gratuiti, apprendimento in ombra. Dalla 5.0 gira **solo in un container** e si guarda dal browser.
> Il backtest è negativo: la strategia non regge i costi di eToro (`docs/STRATEGY.md`). Il bot è uno strumento di forward test in Demo, non un sistema da mettere sul reale.
![Dashboard](docs/img/dashboard.png)
## Avvio rapido (Docker)
```powershell
docker pull 192.168.30.23:3000/alby96/encelado:latest
docker run -d --name encelado --restart unless-stopped -p 8080:8080 `
-v /mnt/user/appdata/encelado/config:/config -v /mnt/user/appdata/encelado/data:/data `
-e ENCELADO_WEB_TOKEN=<una-stringa-lunga> -e TZ=Europe/Rome `
-e ETORO_API_KEY=<x-api-key> -e ETORO_USER_KEY=<x-user-key> `
192.168.30.23:3000/alby96/encelado:latest
```
Poi `http://<ip>:8080/`, token una volta, **AVVIA**. Su Unraid: template in `deploy/unraid/` (icona, porte, volumi e variabili già descritti). Tutto il resto in `docs/DOCKER.md`.
Senza token il server ascolta solo su localhost del container. La modalità `Live` richiede `run.allowLive = true`, `ETORO_ENVIRONMENT=real` e la frase `CONFERMO LIVE` in `ENCELADO_CONFIRM_LIVE`.
## Le pagine
| | |
|---|---|
| ![Storico](docs/img/storico.png) | ![Log](docs/img/log.png) |
| **Storico ordini**: ordini (unità richieste ed eseguite, slippage, esito), posizioni classificate `basket` / `orfana-bot` / `esterna` / `movimento di cassa`, profitti per periodo con la curva dell'equity, esportazione CSV. | **Log** con filtro, ricerca e «Segui». |
| ![Impostazioni](docs/img/impostazioni.png) | ![Telefono](docs/img/mobile.png) |
| **Impostazioni**: configurazione validata, chiavi eToro cifrate, ripristino in cinque passi, Ricerca (modello in ombra, ciclo di apprendimento), Diagnostica (percorsi, quote, bonifica), Informazioni (versione, build, commit). | Sotto i 600 px il rail diventa un menu e i basket diventano cards. |
## Provarlo in locale
Tre modi, tutti sulla stessa sandbox `deploy/local/` (ignorata da git: `config/` è il `/config` del container, `data/` il `/data`), così configurazione e chiavi salvate valgono ovunque:
| Modo | Come | Cosa ottieni |
|---|---|---|
| **F5 in VS Code** | configurazione `Encelado (server)` | server locale su `http://localhost:8080/`, browser aperto da solo, motore fermo finché non premi **AVVIA**; `Encelado (campione)` mostra le pagine con dati finti senza chiavi |
| **Script** | `scripts\run-dev.ps1` (`-Sample`, `-Autostart`, `-Port`) o `sh scripts/run-dev.sh` | lo stesso processo da terminale, con i comandi da tastiera (`status`, `kill`, `stop`…) |
| **Container** | `scripts\run-docker.ps1` (o `sh scripts/run-docker.sh`, o `docker compose up -d --build`) | l'immagine vera su `http://localhost:8080/`, token `sviluppo`; `-Down` per fermare con SIGTERM |
Le chiavi eToro: variabili `ETORO_API_KEY`/`ETORO_USER_KEY` nell'ambiente (per il compose in un file `.env` accanto alla `docker-compose.yml`), oppure **Impostazioni ▸ Chiavi eToro** nella pagina con `ENCELADO_KEY_PASSPHRASE` impostata (vengono verificate e salvate cifrate in `deploy/local/config/etoro.keys.enc`). Senza chiavi il motore non parte e la pagina lo dice; lo storico mostra solo il ledger.
```powershell
dotnet build Encelado.slnx # compilazione
dotnet test tests/Encelado.Tests --no-restore # test (xunit)
dotnet msbuild build/Release.proj -t:Verifica # compilazione + test nella cartella di verifica
dotnet msbuild build/Release.proj -t:Docker # l'immagine, con i test dentro la build
scripts\screenshots.ps1 # rigenera docs/img dal server campione
```
Attività di VS Code: `verifica`, `backtest`, `avvia in locale (script)`, `avvia in docker`, `ferma docker`, `screenshot`, `immagine docker`, `pacchetto`, `rilascia su Gitea`. Su Gitea, `.gitea/workflows/ci.yml` verifica ogni push e `release.yml` pubblica l'immagine a ogni tag (serve un runner: `docs/DOCKER.md`).
```
src/Encelado.Core logica pura: basket, cross sintetici, decisore, cost gate, sizing, esecutore, registro ordini, kill-switch, apprendimento, notifiche
src/Encelado.Etoro client HTTP di eToro Public API
src/Encelado.Engine motore, ledger, feed, configurazione, impostazioni, storico, Telegram
src/Encelado.Server l'eseguibile: Kestrel, API JSON, SSE, interfaccia incorporata (Material 3, vanilla)
tools/Encelado.Backtest ticks, baskets, falsify, learn
tests/Encelado.Tests xunit
deploy/ docker (entrypoint), unraid (template), local (ignorato: sandbox di F5 e del compose)
scripts/ run-dev, run-docker, screenshots (.ps1 e .sh)
build/ catena di verifica, pacchetto e rilascio (MSBuild)
docs/ STATE, ARCHITECTURE, STRATEGY, RUNBOOK, DOCKER, UI_GUIDELINES, ADR…
```
Leggi `CLAUDE.md` per le convenzioni e `docs/STATE.md` per lo stato del lavoro.
## Autore e licenza
Alberto Balbo. Uso personale; nessuna licenza di ridistribuzione. Nessuna garanzia: un bot che opera su un conto reale può perdere denaro.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 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;
+57 -51
View File
@@ -1,23 +1,23 @@
# Catena di verifica, pacchetto e rilascio
Tutto quello che serve a controllare, impacchettare e pubblicare Encelado sta in questa
cartella. La radice del progetto non contiene script.
cartella. La radice del progetto contiene solo la `Dockerfile` e la `docker-compose.yml`.
È la stessa catena di [Mimante/AutoBidder](http://192.168.30.23:3000/Alby96/Mimante),
adattata a una soluzione con più progetti. Le differenze sono tre, tutte segnate sul
posto in `Release.proj`:
adattata a una soluzione con più progetti e a un pacchetto che è un'immagine Docker
(dalla 5.0, ADR-0008). Le differenze sono segnate sul posto in `Release.proj`:
| | AutoBidder | Encelado |
|---|---|---|
| Dove sta la versione | `AutoBidder.csproj` | `Directory.Build.props`, ereditato da tutti i progetti |
| Cosa produce `dotnet publish` | un eseguibile unico | una cartella: l'app legge `encelado.json` accanto a sé |
| Copia portabile allegata | il solo `.exe` | uno zip della cartella |
| Cosa rigioca `Backtest` | i dossier delle aste | barre M15 bid/ask dei basket |
| Cosa produce `dotnet publish` | un eseguibile unico | una cartella framework-dependent: la stessa che il `Dockerfile` mette in `/app` |
| Il pacchetto | installatore Inno Setup | immagine Docker `192.168.30.23:3000/alby96/encelado:<versione>` (+ `:latest`) |
| Allegati della release | `.exe` e installatore | zip portabile e template Unraid |
| Cosa rigioca `Backtest` | i dossier delle aste | barre M15 bid/ask dei basket (+ `learn` sul ledger) |
| File | Cos'è |
|---|---|
| `Release.proj` | La catena. Un solo file MSBuild, nessuno script. |
| `Encelado.iss` | Lo script di Inno Setup. Non si compila a mano: lo lancia `Release.proj`. |
| `gitea.example.json` | Modello per `gitea.json` (che è escluso dal controllo di versione). |
## Da VS Code
@@ -27,43 +27,53 @@ posto in `Release.proj`:
| Attività | Cosa fa |
|---|---|
| `verifica` | Compila e lancia i test. |
| `backtest` | Ricerca sui basket: `ticks`, `baskets`, `falsify`. |
| `crea installatore` | Chiede la versione, verifica, pubblica, esegue Inno Setup. |
| `crea installatore (senza rieseguire i test)` | Solo pubblicazione e installatore. |
| `rilascia su Gitea` | Tutto quanto sopra, più tag e release con i file allegati. |
| `backtest` | Ricerca sui basket: `ticks`, `baskets`, `falsify`, `learn`. |
| `immagine docker` | Costruisce l'immagine (i test girano dentro la build). Non pubblica. |
| `pacchetto` | Verifica, cartella portabile, immagine, tag a pacchetto pronto. Non tocca Gitea. |
| `rilascia su Gitea` | Tutto quanto sopra, più push dell'immagine sul registro di Gitea e release con gli allegati. |
| `docker compose up` | Il container in locale con le cartelle di `deploy/local/`. |
## Da riga di comando
```powershell
dotnet msbuild build/Release.proj -t:Verifica
dotnet msbuild build/Release.proj -t:Docker
dotnet msbuild build/Release.proj -t:Pacchetto
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=3.3.0 -p:Note="Cosa cambia"
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=5.0.0 -p:Note="Cosa cambia"
# La ricerca sui basket: barre M15 in data/market (da `ticks`), tabelle in results/ e reports/
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data\market"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=falsify -p:Extra="--costs api"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="A:\Download\Trading" -p:Comando=ticks
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data" -p:Comando=learn
```
| Proprietà | Predefinito | A cosa serve |
|---|---|---|
| `Versione` | vuoto | Vuoto = incrementa la minor (3.2.0 → 3.3.0). Altrimenti la scrive, se ha la forma `X.Y.Z`. |
| `Note` | vuoto | Note di rilascio. |
| `SaltaVerifica` | `false` | Non rieseguire i test. |
| `Versione` | vuoto | Vuoto = incrementa la minor (5.0.0 → 5.1.0). Altrimenti la scrive, se ha la forma `X.Y.Z`. |
| `Note` | vuoto | Note di rilascio (meglio via `ENCELADO_NOTE`, vedi sotto). |
| `Immagine` | `192.168.30.23:3000/alby96/encelado` | Nome completo dell'immagine. |
| `SaltaVerifica` | `false` | Non rieseguire i test (girano comunque dentro `docker build`). |
| `SaltaDocker` | `false` | Solo lo zip portabile, niente immagine né push. |
| `Sovrascrivi` | `false` | Sostituisci una release Gitea con lo stesso tag. |
| `Bozza` | `false` | Crea la release come bozza. |
| `ConsentiModifiche` | `false` | Tagga anche con l'albero sporco. Serve saperlo. |
| `Dati` | — | La cartella dei dati (`candles_<SYMBOL>_M15.csv` per `baskets`/`falsify`, tick MT5 per `ticks`). Obbligatoria per `Backtest`. |
| `Comando` | `baskets` | `ticks`, `baskets`, `falsify`. |
| `Dati` | — | La cartella dei dati. Obbligatoria per `Backtest`. |
| `Comando` | `baskets` | `ticks`, `baskets`, `falsify`, `learn`. |
| `Extra` | vuoto | Altre opzioni passate allo strumento così come sono, es. `--costs api --quick`. |
## La ricerca e il meta-modello
## Cosa succede in `Rilascia`
Il meta-modello del bot (regressione logistica in ombra, MLP challenger, bandit) si
addestra da solo dal ledger nel ciclo settimanale e non passa da qui: vedi
`docs/ML_AND_LEARNING.md`. Lo strumento di ricerca produce solo le tabelle del
backtest (`results/trials.csv`, `results/riepilogo_baskets.csv`,
`reports/falsificazione.csv`), che `docs/STRATEGY.md` commenta.
1. `ConfigGitea`: legge url, owner, repo e token (ambiente o `gitea.json`); senza, si ferma prima di compilare.
2. `Verifica`: `dotnet test` nella cartella di verifica (`%TEMP%\Encelado.Verifica`).
3. `Pubblica`: `dotnet publish` framework-dependent di `Encelado.Server` in `bin/publish/portable`, con la versione da riga di comando; controlli su `Encelado.Server.dll`, `config/encelado.json` e sull'assenza di sorgenti.
4. `Docker`: `docker build --pull` con `VERSION` e `GIT_COMMIT` come build-arg; l'immagine ha i tag `:<versione>` e `:latest`. La `Dockerfile` esegue i test nel suo stadio `test`.
5. `Pacchetto`: zip portabile `bin/installer/Encelado_<v>_portabile.zip`, copia del template `deploy/unraid/encelado.xml` con la versione nel nome, **tag** `v<versione>` (solo ora, a pacchetto pronto, e solo con l'albero pulito).
6. `Rilascia`: `git push --tags` e verifica che il tag sia sul remoto; `docker login` sul registro di Gitea con il token (via stdin), `docker push` dei due tag, `docker logout`; release su Gitea con lo zip e il template allegati.
## Gitea Actions
`.gitea/workflows/ci.yml` (build + test + `docker build` a ogni push) e `release.yml` (immagine sul registro e release a ogni tag `v*`) fanno sul server quello che `Verifica` e `Rilascia` fanno dal PC. Richiedono un runner registrato e il segreto `REGISTRY_TOKEN`: vedi `docs/DOCKER.md`, «Gitea Actions». La catena resta il percorso di riferimento finché il runner non c'è.
## Chi chiede la versione
@@ -72,15 +82,15 @@ l'attività di VS Code (`inputs` in `.vscode/tasks.json`) e passa la risposta in
`-p:Versione=`. Lasciando il campo vuoto si prende la minor successiva, che è il caso
normale di fine sessione.
**La versione arriva dal tag e non viene scritta da nessuna parte.** `dotnet publish` la
riceve come proprietà da riga di comando, che è globale e vince su quella dichiarata in
`Directory.Build.props`. Tag, eseguibile, installatore e release portano quindi lo stesso
numero per costruzione.
**La versione arriva dal tag e non viene scritta da nessuna parte.** `dotnet publish` e
`docker build` la ricevono come proprietà da riga di comando, che è globale e vince su
quella dichiarata in `Directory.Build.props`. Tag, assembly, immagine e release portano
quindi lo stesso numero per costruzione. Il commit e la data di build finiscono in
`AssemblyMetadata` (Impostazioni ▸ Informazioni, `/api/info`).
Il tag si crea **in fondo**, quando l'installatore esiste davvero. Il contrario sembra più
naturale — decidi il numero, poi costruisci — ma lascia dietro un tag quando la verifica
fallisce, e il tentativo dopo riparte da lì: il numero sale senza che sia mai esistito un
pacchetto con quella versione.
Il tag si crea **in fondo**, quando lo zip e l'immagine esistono davvero. Il contrario
sembra più naturale — decidi il numero, poi costruisci — ma lascia dietro un tag quando la
verifica fallisce, e il tentativo dopo riparte da lì.
## Gitea
@@ -90,34 +100,30 @@ macchina condivisa può rilasciare senza scrivere un token su disco:
- `GITEA_URL`, `GITEA_OWNER`, `GITEA_REPO`, `GITEA_TOKEN`
- oppure `build/gitea.json`, copiato da `gitea.example.json`
Il token si crea in Gitea da *Impostazioni ▸ Applicazioni ▸ Genera nuovo token*, con il
permesso `repository: read and write`.
Il token si crea in Gitea da *Impostazioni ▸ Applicazioni ▸ Genera nuovo token*, con i
permessi `repository: read and write` **e** `package: read and write` (serve per il
registro dei container). Il registro è lo stesso host di Gitea senza schema
(`192.168.30.23:3000`): in HTTP va dichiarato `insecure-registry` a Docker Desktop
(*Settings ▸ Docker Engine*).
Il token non passa mai dalla riga di comando: sta in un file di configurazione di curl,
cancellato subito dopo il rilascio. Gli `Exec` hanno `EchoOff` perché un registro di
compilazione è la classica cosa che si incolla in una chat.
Nella release vengono caricati **sia l'installatore sia la copia portabile**: chi non
vuole installare niente deve continuare a poter scaricare l'applicazione e basta.
Il token non passa mai dalla riga di comando: sta in un file di configurazione di curl e
in un file letto da `docker login --password-stdin`, entrambi cancellati subito dopo. Gli
`Exec` hanno `EchoOff` perché un registro di compilazione è la classica cosa che si
incolla in una chat.
## Una trappola già pagata
`dotnet test` e `dotnet publish` lanciati **da dentro** MSBuild ereditano l'ambiente del
processo padre. La compilazione WPF crea un progetto temporaneo (`_wpftmp.csproj`) e con
`MSBUILD_EXE_PATH` puntata al build in corso non genera più le classi parziali dello XAML:
si ottengono decine di errori su membri che esistono benissimo.
processo padre: `MSBUILD_EXE_PATH` e `MSBuildLoadMicrosoftTargetsReadOnly` puntate al
build in corso confondono il figlio. Gli `Exec` azzerano **solo quelle due**: la ricetta
che gira in rete azzera anche `MSBuildExtensionsPath` e `MSBuildSDKsPath`, e così il
figlio perde la posizione dell'SDK.
Per questo gli `Exec` azzerano `MSBUILD_EXE_PATH` e `MSBuildLoadMicrosoftTargetsReadOnly`.
**Solo quelle due**: la ricetta che gira in rete azzera anche `MSBuildExtensionsPath` e
`MSBuildSDKsPath`, e così il figlio perde la posizione dell'SDK — *«l'SDK Microsoft.NET.Sdk
specificato non è stato trovato»*. Serve isolare il motore, non nascondergli dove abita.
I test girano in una cartella a parte (`%TEMP%\Encelado.Verifica`) perché l'applicazione
può essere aperta mentre si lavora e tiene bloccato `Encelado.exe`: senza, la compilazione
si ferma su MSB3027.
I test girano in una cartella a parte (`--artifacts-path`) perché un server lasciato
acceso da VS Code tiene bloccata `Encelado.Server.dll` nella `bin/` di lavoro.
## Prerequisiti
- .NET SDK 10
- [Inno Setup 6](https://jrsoftware.org/isinfo.php) — `winget install -e --id JRSoftware.InnoSetup`
- Docker Desktop (o un demone Docker raggiungibile) per `Docker`, `Pacchetto`, `Rilascia`; `-p:SaltaDocker=true` per farne a meno
- `curl` e `git`, entrambi di serie in Windows 11
+145 -136
View File
@@ -7,16 +7,18 @@
di VS Code (Terminale ▸ Esegui attività…) oppure a mano:
dotnet msbuild build/Release.proj -t:Verifica
dotnet msbuild build/Release.proj -t:Backtest
dotnet msbuild build/Release.proj -t:Backtest -p:Dati=...
dotnet msbuild build/Release.proj -t:Docker
dotnet msbuild build/Release.proj -t:Pacchetto
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=3.3.0
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=5.0.0
È la stessa catena di Mimante/AutoBidder, adattata a una soluzione con più
progetti. Le differenze rispetto a quel file sono tre, tutte segnate sul
progetti. Le differenze rispetto a quel file sono quattro, tutte segnate sul
posto: la versione vive in Directory.Build.props e non nel .csproj, la
pubblicazione produce una cartella e non un singolo eseguibile, e il target
Backtest rigioca coppie di serie storiche di prezzi invece dei dossier
delle aste.
pubblicazione produce una cartella e non un singolo eseguibile, il pacchetto
è un'immagine Docker e non un installatore Windows (dalla 5.0, ADR-0007 e
ADR-0008: il bot gira solo nel container), e il target Backtest rigioca
coppie di serie storiche di prezzi invece dei dossier delle aste.
── Perché MSBuild e non uno script ──────────────────────────────────────
La catena vive accanto al codice che rilascia ed è versionata con lui: fra
@@ -27,56 +29,52 @@
── Da dove viene la versione ────────────────────────────────────────────
Dal tag git, e da nient'altro. Directory.Build.props non viene mai riscritto:
il numero arriva a `dotnet publish` come proprietà da riga di comando, quindi
tag, eseguibile, installatore e release portano lo stesso numero per
costruzione, non per disciplina.
il numero arriva a `dotnet publish` e a `docker build` come proprietà da riga
di comando, quindi tag, assembly, immagine e release portano lo stesso numero
per costruzione, non per disciplina.
Il modo previsto è taggare e poi rilasciare:
git tag v3.3.0
git tag v5.0.0
dotnet msbuild build/Release.proj -t:Rilascia
Se HEAD non ha un tag di versione la catena lo crea da sé — con il numero
passato in `-p:Versione=`, oppure la minor successiva all'ultimo tag — e lo
fa in fondo, quando l'installatore esiste davvero. Un giro andato male non
fa in fondo, quando l'immagine esiste davvero. Un giro andato male non
lascia dietro un tag per una versione che non è mai stata costruita.
Il numero in Directory.Build.props resta quello delle compilazioni di
sviluppo. Continua ad avere senso alzarlo a ogni modifica — è quello che
compare nel titolo della finestra durante il lavoro — ma non decide più cosa
viene rilasciato: serve solo come seme al primissimo rilascio, quando non
esiste ancora nessun tag da cui ripartire.
sviluppo (Impostazioni ▸ Informazioni mentre si lavora) e serve solo come
seme al primissimo rilascio, quando non esiste ancora nessun tag.
-->
<Project DefaultTargets="Pacchetto" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Radice>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..'))</Radice>
<Csproj>$(Radice)\src\Encelado.Bot\Encelado.Bot.csproj</Csproj>
<Csproj>$(Radice)\src\Encelado.Server\Encelado.Server.csproj</Csproj>
<TestProj>$(Radice)\tests\Encelado.Tests\Encelado.Tests.csproj</TestProj>
<BacktestProj>$(Radice)\tools\Encelado.Backtest\Encelado.Backtest.csproj</BacktestProj>
<Iss>$(MSBuildThisFileDirectory)Encelado.iss</Iss>
<Dockerfile>$(Radice)\Dockerfile</Dockerfile>
<TemplateUnraid>$(Radice)\deploy\unraid\encelado.xml</TemplateUnraid>
<!-- La versione non sta nel .csproj come in AutoBidder: sta in
Directory.Build.props, da cui la ereditano tutti e quattro i progetti.
Directory.Build.props, da cui la ereditano tutti i progetti.
È il file che VersioneDaTag legge quando non esiste ancora nessun tag. -->
<Props>$(Radice)\Directory.Build.props</Props>
<CartellaPubblicazione>$(Radice)\bin\publish\win-x64</CartellaPubblicazione>
<CartellaPubblicazione>$(Radice)\bin\publish\portable</CartellaPubblicazione>
<CartellaPacchetti>$(Radice)\bin\installer</CartellaPacchetti>
<!-- ── L'immagine ─────────────────────────────────────────────────────
Il registro è quello di Gitea (D-29): stesso host di build/gitea.json,
owner in minuscolo perché i nomi delle immagini OCI lo richiedono. Si
può cambiare con -p:Immagine=host/utente/nome. -->
<Immagine Condition="'$(Immagine)' == ''">192.168.30.23:3000/alby96/encelado</Immagine>
<!-- ── Perché una cartella a parte per verifica e backtest ─────────────
Due ragioni, e servono entrambe.
La prima: l'applicazione può essere aperta mentre si lavora, e tiene
bloccato Encelado.exe — la compilazione si fermerebbe su MSB3027.
La seconda: l'opzione artifacts-path sposta anche gli INTERMEDI, non
solo il risultato. La sola -o li lascia nella obj/ condivisa, e la
compilazione WPF — che genera un progetto temporaneo `_wpftmp.csproj` a
ogni giro — ogni tanto ci trovava stato altrui e smetteva di produrre
le classi parziali dello XAML. Il sintomo era una raffica di
"AuctionMonitorControl non contiene una definizione di ...", a giri
alterni, senza che il codice fosse cambiato. -->
L'opzione artifacts-path sposta anche gli INTERMEDI, non solo il
risultato: la verifica non tocca le obj/ del lavoro in corso e non
viene disturbata da un server lasciato acceso da VS Code. -->
<CartellaProve>$([System.IO.Path]::GetTempPath())Encelado.Verifica</CartellaProve>
<!-- Serve solo quando HEAD non è ancora taggato: è il numero del tag da
@@ -98,21 +96,15 @@
-->
<Note Condition="'$(Note)' == ''">$(ENCELADO_NOTE)</Note>
<!-- ── Perché serve azzerare queste variabili ──────────────────────────
`dotnet test` e `dotnet publish` lanciati da dentro MSBuild ereditano
l'ambiente del processo padre. La compilazione WPF crea un progetto
temporaneo (_wpftmp.csproj) e con quelle variabili puntate al build in
corso non genera più le classi parziali dello XAML: si ottengono decine
di "AuctionMonitorControl non contiene una definizione di ..." che non
hanno niente a che vedere col codice.
Si azzerano SOLO queste due. Togliere anche MSBuildExtensionsPath o
MSBuildSDKsPath — la ricetta che gira in rete — fa perdere al figlio la
posizione dell'SDK: "l'SDK Microsoft.NET.Sdk specificato non è stato
trovato". Serve isolare il motore, non nascondergli dove abita. -->
<!-- `dotnet test` e `dotnet publish` lanciati da dentro MSBuild ereditano
l'ambiente del processo padre; queste due variabili puntate al build
in corso confondono il figlio. Si azzerano SOLO queste due: togliere
anche MSBuildExtensionsPath o MSBuildSDKsPath fa perdere al figlio la
posizione dell'SDK. -->
<AmbientePulito>MSBUILD_EXE_PATH=;MSBuildLoadMicrosoftTargetsReadOnly=</AmbientePulito>
<SaltaVerifica Condition="'$(SaltaVerifica)' == ''">false</SaltaVerifica>
<SaltaDocker Condition="'$(SaltaDocker)' == ''">false</SaltaDocker>
<Sovrascrivi Condition="'$(Sovrascrivi)' == ''">false</Sovrascrivi>
<Bozza Condition="'$(Bozza)' == ''">false</Bozza>
@@ -243,33 +235,6 @@
</Task>
</UsingTask>
<UsingTask TaskName="TrovaInnoSetup" TaskFactory="RoslynCodeTaskFactory"
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<Percorso ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Code Type="Fragment" Language="cs">
<![CDATA[
var candidati = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Inno Setup 6\ISCC.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Inno Setup 6\ISCC.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), @"Inno Setup 6\ISCC.exe"),
};
foreach (var c in candidati)
if (File.Exists(c)) { Percorso = c; break; }
if (string.IsNullOrEmpty(Percorso))
Log.LogError("Inno Setup 6 non trovato. Installalo con: winget install -e --id JRSoftware.InnoSetup");
]]>
</Code>
</Task>
</UsingTask>
<!--
Gitea si raggiunge con curl, di serie in Windows 10 e 11.
@@ -280,8 +245,9 @@
al prossimo aggiornamento di .NET no. curl non ha questo problema.
Il token NON passa mai dalla riga di comando: sta in un file di configurazione
di curl, che viene cancellato subito dopo. Gli Exec hanno EchoOff perché un
registro di compilazione è la classica cosa che si incolla in una chat.
di curl (e in un file letto da `docker login ‐‐password-stdin`), cancellati
subito dopo. Gli Exec hanno EchoOff perché un registro di compilazione è la
classica cosa che si incolla in una chat.
-->
<UsingTask TaskName="LeggiConfigGitea" TaskFactory="RoslynCodeTaskFactory"
@@ -292,6 +258,7 @@
<Owner ParameterType="System.String" Output="true" />
<Repo ParameterType="System.String" Output="true" />
<Token ParameterType="System.String" Output="true" />
<Registro ParameterType="System.String" Output="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
@@ -325,13 +292,16 @@
" copia build/gitea.example.json in build/gitea.json e riempilo,\n" +
" oppure imposta GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_TOKEN.\n" +
"Non e' stato costruito niente: si controlla prima di compilare, non dopo.\n" +
"Per il solo installatore, senza Gitea, usa il target Pacchetto.");
"Per il solo pacchetto, senza Gitea, usa il target Pacchetto.");
// Senza questo il target prosegue lo stesso: git tag, poi curl con
// l'indirizzo vuoto, e infine un "codice 3" che non dice niente a
// nessuno. Un errore va fermato dove si capisce ancora cos'era.
return false;
}
// Il registro dei container di Gitea e' lo stesso host, senza schema.
Registro = Regex.Replace(Url, "^https?://", "");
]]>
</Code>
</Task>
@@ -343,6 +313,7 @@
<Destinazione ParameterType="System.String" Required="true" />
<Tag ParameterType="System.String" Required="true" />
<Versione ParameterType="System.String" Required="true" />
<Immagine ParameterType="System.String" />
<Note ParameterType="System.String" />
<Bozza ParameterType="System.Boolean" />
</ParameterGroup>
@@ -358,6 +329,8 @@
.Replace("\r", "").Replace("\n", "\\n").Replace("\t", " ");
var note = string.IsNullOrWhiteSpace(Note) ? "Versione " + Versione + "." : Note;
if (!string.IsNullOrWhiteSpace(Immagine))
note += "\n\nImmagine: `" + Immagine + ":" + Versione + "` (anche `:latest`). Template Unraid allegato.";
File.WriteAllText(Destinazione,
"{\"tag_name\":\"" + esc(Tag) + "\"," +
@@ -406,8 +379,6 @@
<Target Name="Verifica" Condition="'$(SaltaVerifica)' != 'true'">
<Message Importance="High" Text="== Verifica (compilazione + test) ==" />
<!-- In una cartella a parte: l'applicazione puo' essere aperta e tenere
bloccato Encelado.exe. -->
<Exec Command="dotnet test &quot;$(TestProj)&quot; --nologo -v q --artifacts-path &quot;$(CartellaProve)&quot;"
WorkingDirectory="$(Radice)"
EnvironmentVariables="$(AmbientePulito)" />
@@ -420,12 +391,14 @@
<!--
Lo strumento in tools/Encelado.Backtest: `ticks` converte i tick MT5 in barre
M15 bid/ask, `baskets` rigioca la strategia (baseline, griglia, PSR/DSR, PBO,
walk-forward), `falsify` esegue i test di falsificazione. Ogni tabella è un CSV
con ; e colonna motivazione. Vedi docs/STRATEGY.md per i risultati.
walk-forward), `falsify` esegue i test di falsificazione, `learn` rigioca il
ciclo di apprendimento in ombra su un ledger. Ogni tabella è un CSV con ; e
colonna motivazione. Vedi docs/STRATEGY.md per i risultati.
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data\market"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=falsify -p:Extra="‐‐costs api"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="A:\Download\Trading" -p:Comando=ticks
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data" -p:Comando=learn
-->
<Target Name="Backtest">
<PropertyGroup>
@@ -434,7 +407,7 @@
</PropertyGroup>
<Error Condition="'$(Dati)' == ''"
Text="Serve la cartella dei dati: -p:Dati=&quot;%USERPROFILE%\Documents\Encelado\data\market&quot; (barre candles_SYMBOL_M15.csv) per baskets e falsify, oppure la cartella dei tick MT5 per ticks.%0AComandi disponibili in -p:Comando= : ticks, baskets, falsify (altre opzioni in -p:Extra=, es. --costs api --quick)." />
Text="Serve la cartella dei dati: -p:Dati=&quot;%USERPROFILE%\Documents\Encelado\data\market&quot; (barre candles_SYMBOL_M15.csv) per baskets e falsify, la cartella dei tick MT5 per ticks, la cartella data del bot per learn.%0AComandi disponibili in -p:Comando= : ticks, baskets, falsify, learn (altre opzioni in -p:Extra=, es. --costs api --quick)." />
<Error Condition="!Exists('$(Dati)')" Text="Cartella dati non trovata: $(Dati)" />
@@ -449,27 +422,24 @@
Command="&quot;$(BacktestExe)&quot; $(Comando) --data &quot;$(Dati)&quot; $(Extra)" />
</Target>
<!-- ═════════════════════ Eseguibile ═════════════════════ -->
<!-- ═════════════════════ Pubblicazione ═════════════════════ -->
<!--
I quattro numeri di versione arrivano da qui, non da Directory.Build.props:
le proprietà da riga di comando sono globali e vincono su quelle scritte nei
progetti. Vengono passati tutti e quattro anche se il file ne dichiara uno
solo — la finestra legge Assembly.GetName().Version, e vederne divergere uno
significa un numero a schermo che mente.
solo — Informazioni legge Assembly.GetName().Version, e vederne divergere
uno significa un numero a schermo che mente.
── Perché una cartella e non un singolo eseguibile ────────────────────────
AutoBidder pubblica con PublishSingleFile e allega quel file alla release.
Qui non si può: Encelado legge `encelado.json` accanto al proprio eseguibile
e ci scrive log, diario e CSV di analisi. Un singolo file estratto in una
cartella temporanea a ogni avvio metterebbe la configurazione dell'utente e
i suoi log in un percorso che cambia da un avvio all'altro.
Una cartella non si allega a una release, quindi al suo posto viene allegato
uno zip: vedi il target Rilascia.
── Cosa si pubblica ──────────────────────────────────────────────────────
Una cartella framework-dependent, senza sistema operativo: è la stessa
cosa che il Dockerfile mette in /app, e chi vuole girare senza container
(sconsigliato: ADR-0007) la lancia con `dotnet Encelado.Server.dll` sopra
il runtime ASP.NET Core 10. Una cartella non si allega a una release,
quindi al suo posto viene allegato uno zip: vedi il target Pacchetto.
-->
<Target Name="Pubblica" DependsOnTargets="DeterminaVersione">
<Message Importance="High" Text="== Pubblicazione dell'eseguibile ($(V)) ==" />
<Message Importance="High" Text="== Pubblicazione della cartella portabile ($(V)) ==" />
<!-- Cartella pulita a ogni giro. Senza questo i resti di una pubblicazione
precedente — una DLL rinominata, un runtime cambiato — finiscono nel
@@ -479,29 +449,24 @@
<Exec WorkingDirectory="$(Radice)"
EnvironmentVariables="$(AmbientePulito)"
Command="dotnet publish &quot;$(Csproj)&quot; -c Release -r win-x64 --nologo -v q --self-contained true -p:PublishReadyToRun=true -p:PublishTrimmed=false -p:DebugType=none -p:Version=$(V) -p:AssemblyVersion=$(V).0 -p:FileVersion=$(V).0 -p:InformationalVersion=$(V) -o &quot;$(CartellaPubblicazione)&quot;" />
Command="dotnet publish &quot;$(Csproj)&quot; -c Release --nologo -v q --self-contained false -p:DebugType=none -p:Version=$(V) -p:AssemblyVersion=$(V).0 -p:FileVersion=$(V).0 -p:InformationalVersion=$(V) -o &quot;$(CartellaPubblicazione)&quot;" />
<Error Condition="!Exists('$(CartellaPubblicazione)\Encelado.exe')"
Text="Pubblicazione fallita: Encelado.exe non trovato." />
<Error Condition="!Exists('$(CartellaPubblicazione)\Encelado.Server.dll')"
Text="Pubblicazione fallita: Encelado.Server.dll non trovato." />
<!-- Senza configurazione l'applicazione non parte, e l'installatore la
copierebbe senza accorgersi che manca. -->
<Error Condition="!Exists('$(CartellaPubblicazione)\encelado.json')"
Text="Pubblicazione incompleta: encelado.json non è finito accanto all'eseguibile." />
<!-- Senza configurazione di fabbrica il primo avvio non può seminare
encelado.json e strategy.json nella cartella di configurazione. -->
<Error Condition="!Exists('$(CartellaPubblicazione)\config\encelado.json')"
Text="Pubblicazione incompleta: config\encelado.json non è finito accanto all'eseguibile." />
<!--
Nella release non devono finire i sorgenti: si pubblica il programma, non
il progetto. La cartella pubblicata diventa lo zip portabile, quindi basta
controllare qui.
Non è teorico: un <None CopyToOutputDirectory> aggiunto per comodità, o un
pacchetto che porta i propri .cs, li farebbe scivolare dentro senza che
nessuno se ne accorga fino a quando qualcuno non apre lo zip.
-->
<ItemGroup>
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.cs" />
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.csproj" />
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.xaml" />
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.pdb" />
</ItemGroup>
@@ -509,6 +474,43 @@
Text="Nella cartella pubblicata ci sono file che non sono programma: @(SorgenteIntruso->'%(Filename)%(Extension)', ', ').%0ANon devono finire nella release." />
</Target>
<!-- ═════════════════════ Immagine Docker ═════════════════════ -->
<!--
L'immagine è il pacchetto vero (ADR-0008). La Dockerfile alla radice compila,
esegue i test e pubblica dentro lo stadio di build: un'immagine con i test
rossi non esiste. Il commit arriva come build-arg perché .git resta fuori
dal contesto (.dockerignore).
dotnet msbuild build/Release.proj -t:Docker # :<versione> e :latest
dotnet msbuild build/Release.proj -t:Docker -p:SaltaDocker=true # (no-op, per la catena senza Docker)
-->
<Target Name="Docker" DependsOnTargets="DeterminaVersione" Condition="'$(SaltaDocker)' != 'true'">
<Message Importance="High" Text="== Immagine Docker $(Immagine):$(V) ==" />
<Error Condition="!Exists('$(Dockerfile)')" Text="Dockerfile non trovata: $(Dockerfile)" />
<Exec Command="docker version --format {{.Server.Version}}" ContinueOnError="true"
ConsoleToMSBuild="true" StandardOutputImportance="low" StandardErrorImportance="low">
<Output TaskParameter="ExitCode" PropertyName="DockerEsito" />
</Exec>
<Error Condition="'$(DockerEsito)' != '0'"
Text="Docker non risponde: avvia Docker Desktop (o il demone) e rilancia. Per saltare l'immagine: -p:SaltaDocker=true." />
<Exec Command="git rev-parse --short=12 HEAD" WorkingDirectory="$(Radice)" ContinueOnError="true"
ConsoleToMSBuild="true" StandardOutputImportance="low" StandardErrorImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="Commit" />
</Exec>
<PropertyGroup>
<Commit Condition="'$(Commit)' == ''">n/d</Commit>
</PropertyGroup>
<Exec WorkingDirectory="$(Radice)"
Command="docker build --pull -t &quot;$(Immagine):$(V)&quot; -t &quot;$(Immagine):latest&quot; --build-arg VERSION=$(V) --build-arg GIT_COMMIT=$(Commit) -f &quot;$(Dockerfile)&quot; &quot;$(Radice)&quot;" />
<Message Importance="High" Text=" immagine $(Immagine):$(V) (commit $(Commit))" />
</Target>
<!-- ═════════════════════ Pacchetto ═════════════════════ -->
<!--
@@ -518,45 +520,37 @@
lascia dietro un tag quando qualcosa va storto, e il tentativo successivo
riparte da li'. Un giro andato male e il numero e' salito lo stesso, senza
che sia mai esistito un pacchetto con quella versione. Taggando in fondo,
ogni tag corrisponde a un installatore che esiste davvero.
ogni tag corrisponde a un'immagine e a uno zip che esistono davvero.
-->
<Target Name="Pacchetto" DependsOnTargets="Verifica;DeterminaVersione;Pubblica">
<Message Importance="High" Text="== Creazione dell'installatore ==" />
<TrovaInnoSetup>
<Output TaskParameter="Percorso" PropertyName="Iscc" />
</TrovaInnoSetup>
<Target Name="Pacchetto" DependsOnTargets="Verifica;DeterminaVersione;Pubblica;Docker">
<Message Importance="High" Text="== Creazione del pacchetto ==" />
<MakeDir Directories="$(CartellaPacchetti)" />
<Exec WorkingDirectory="$(MSBuildThisFileDirectory)"
Command="&quot;$(Iscc)&quot; /Qp &quot;/DAppVersion=$(V)&quot; &quot;/DSourceDir=$(CartellaPubblicazione)&quot; &quot;/DOutputDir=$(CartellaPacchetti)&quot; &quot;$(Iss)&quot;" />
<PropertyGroup>
<Setup>$(CartellaPacchetti)\Encelado_$(V).exe</Setup>
<Portabile>$(CartellaPacchetti)\Encelado_$(V)_portabile.zip</Portabile>
<TemplateAllegato>$(CartellaPacchetti)\encelado-unraid-$(V).xml</TemplateAllegato>
</PropertyGroup>
<Error Condition="!Exists('$(Setup)')" Text="Installatore non trovato: $(Setup)" />
<!--
La copia portabile per chi non vuole installare niente.
In AutoBidder è il solo .exe, perché lì la pubblicazione è un file unico.
Qui l'applicazione ha bisogno di encelado.json accanto a sé, quindi è uno
zip della cartella. Si crea qui e non nel rilascio: entrambi i pacchetti
devono esistere anche costruendo senza pubblicare su Gitea.
La copia portabile per chi non vuole il container (framework-dependent:
serve il runtime ASP.NET Core 10). Si crea qui e non nel rilascio: deve
esistere anche costruendo senza pubblicare su Gitea.
-->
<Delete Files="$(Portabile)" ContinueOnError="true" />
<ZipDirectory SourceDirectory="$(CartellaPubblicazione)" DestinationFile="$(Portabile)" />
<!-- Il template Unraid con la versione nel nome, così la release lo porta con sé. -->
<Copy SourceFiles="$(TemplateUnraid)" DestinationFiles="$(TemplateAllegato)" Condition="Exists('$(TemplateUnraid)')" />
<!-- Adesso: il pacchetto c'e', il tag puo' esistere. -->
<CallTarget Targets="CreaTag" />
<Message Importance="High" Text=" " />
<Message Importance="High" Text="Pacchetto pronto:" />
<Message Importance="High" Text=" $(Setup)" />
<Message Importance="High" Text=" $(Portabile)" />
<Message Importance="High" Text=" $(TemplateAllegato)" />
<Message Importance="High" Text=" $(Immagine):$(V)" Condition="'$(SaltaDocker)' != 'true'" />
</Target>
<!-- ═════════════════════ Versione e tag ═════════════════════ -->
@@ -601,14 +595,15 @@
<!-- ═════════════════════ Rilascio ═════════════════════ -->
<!-- Prima di tutto il resto: un token mancante non deve costare due minuti di
compilazione per poi fermarsi all'ultimo passo. -->
<!-- Prima di tutto il resto: un token mancante non deve costare i minuti
dell'immagine per poi fermarsi all'ultimo passo. -->
<Target Name="ConfigGitea">
<LeggiConfigGitea Percorso="$(MSBuildThisFileDirectory)gitea.json">
<Output TaskParameter="Url" PropertyName="GUrl" />
<Output TaskParameter="Owner" PropertyName="GOwner" />
<Output TaskParameter="Repo" PropertyName="GRepo" />
<Output TaskParameter="Token" PropertyName="GToken" />
<Output TaskParameter="Registro" PropertyName="GRegistro" />
</LeggiConfigGitea>
</Target>
@@ -619,9 +614,11 @@
<Api>$(GUrl)/api/v1/repos/$(GOwner)/$(GRepo)</Api>
<Tmp>$([System.IO.Path]::GetTempPath())Encelado.Rilascio</Tmp>
<CurlCfg>$(Tmp)\curl.cfg</CurlCfg>
<TokenFile>$(Tmp)\registry.token</TokenFile>
<CorpoJson>$(Tmp)\release.json</CorpoJson>
<RispostaJson>$(Tmp)\risposta.json</RispostaJson>
<Setup>$(CartellaPacchetti)\Encelado_$(V).exe</Setup>
<Portabile>$(CartellaPacchetti)\Encelado_$(V)_portabile.zip</Portabile>
<TemplateAllegato>$(CartellaPacchetti)\encelado-unraid-$(V).xml</TemplateAllegato>
</PropertyGroup>
<MakeDir Directories="$(Tmp)" />
@@ -629,6 +626,7 @@
<!-- Il token vive qui e solo qui, per il tempo del rilascio. -->
<WriteLinesToFile File="$(CurlCfg)" Overwrite="true"
Lines="header = &quot;Authorization: token $(GToken)&quot;" />
<WriteLinesToFile File="$(TokenFile)" Overwrite="true" Lines="$(GToken)" />
<!--
Il tag esiste gia' in locale: l'ha creato Pacchetto, o c'era prima. Qui va
@@ -658,6 +656,20 @@
<Message Importance="High" Text=" tag $(Tag) sul remoto" />
<!--
L'immagine sul registro dei container di Gitea (stesso host, stesso
token: il registro accetta utente + token come password). Il push viene
prima della release: se fallisce, non esiste una release che indica
un'immagine che nessuno può scaricare.
-->
<Exec Condition="'$(SaltaDocker)' != 'true'" EchoOff="true" StandardOutputImportance="low"
Command="docker login &quot;$(GRegistro)&quot; -u &quot;$(GOwner)&quot; --password-stdin &lt; &quot;$(TokenFile)&quot;" />
<Exec Condition="'$(SaltaDocker)' != 'true'" Command="docker push &quot;$(Immagine):$(V)&quot;" />
<Exec Condition="'$(SaltaDocker)' != 'true'" Command="docker push &quot;$(Immagine):latest&quot;" />
<Exec Condition="'$(SaltaDocker)' != 'true'" ContinueOnError="true" StandardOutputImportance="low"
Command="docker logout &quot;$(GRegistro)&quot;" />
<Message Condition="'$(SaltaDocker)' != 'true'" Importance="High" Text=" immagine $(Immagine):$(V) e :latest sul registro" />
<!-- Release gia' presente? -->
<Exec EchoOff="true" ContinueOnError="true" StandardOutputImportance="low"
Command="curl -s -K &quot;$(CurlCfg)&quot; -o &quot;$(RispostaJson)&quot; &quot;$(Api)/releases/tags/$(Tag)&quot;" />
@@ -676,7 +688,7 @@
<!-- Creazione -->
<PreparaCorpoRelease Destinazione="$(CorpoJson)" Tag="$(Tag)" Versione="$(V)"
Note="$(Note)" Bozza="$(Bozza)" />
Immagine="$(Immagine)" Note="$(Note)" Bozza="$(Bozza)" />
<Exec EchoOff="true" StandardOutputImportance="low"
Command="curl -s -K &quot;$(CurlCfg)&quot; -X POST -H &quot;Content-Type: application/json&quot; --data-binary &quot;@$(CorpoJson)&quot; -o &quot;$(RispostaJson)&quot; &quot;$(Api)/releases&quot;" />
@@ -692,17 +704,13 @@
<Message Importance="High" Text=" release creata" />
<!--
Allegati: l'installatore e la copia portabile, entrambi già costruiti da
Allegati: la copia portabile e il template Unraid, entrambi già pronti da
Pacchetto. Nient'altro — in particolare nessun sorgente: si pubblica il
programma, non il progetto.
Gli archivi "Source code" che Gitea mostra da sé sulla pagina della release
non arrivano da qui: li genera il server dal tag, e si tolgono solo dalla
sua configurazione (DISABLE_DOWNLOAD_SOURCE_ARCHIVES in app.ini).
programma, non il progetto. L'immagine non si allega: sta nel registro.
-->
<ItemGroup>
<Allegato Include="$(Setup)" />
<Allegato Include="$(CartellaPacchetti)\Encelado_$(V)_portabile.zip" />
<Allegato Include="$(Portabile)" />
<Allegato Include="$(TemplateAllegato)" />
</ItemGroup>
<Exec Condition="Exists('%(Allegato.FullPath)')" EchoOff="true" StandardOutputImportance="low"
@@ -712,11 +720,12 @@
Condition="Exists('%(Allegato.FullPath)')" />
<!-- Il token non deve sopravvivere al rilascio. -->
<Delete Files="$(CurlCfg);$(CorpoJson);$(RispostaJson)" ContinueOnError="true" />
<Delete Files="$(CurlCfg);$(TokenFile);$(CorpoJson);$(RispostaJson)" ContinueOnError="true" />
<Message Importance="High" Text=" " />
<Message Importance="High" Text="Rilascio completato:" />
<Message Importance="High" Text=" $(GUrl)/$(GOwner)/$(GRepo)/releases/tag/$(Tag)" />
<Message Importance="High" Text=" docker pull $(Immagine):$(V)" Condition="'$(SaltaDocker)' != 'true'" />
</Target>
</Project>
+20 -2
View File
@@ -31,8 +31,14 @@
},
"ui": {
"_timeZone": "Fuso orario con cui la finestra mostra gli orari. 'computer' = quello di Windows; 'UTC'; oppure un id di Windows (es. 'W. Europe Standard Time') o IANA (es. 'Europe/Rome'). Il file di log porta l'offset, il ledger è in UTC: cambiare questo valore non tocca nessun file.",
"timeZone": "computer"
"_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",
"_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": {
@@ -48,5 +54,17 @@
"_righe": "statusLines = righe della striscia di attività nella dashboard; bufferedLines = righe tenute in memoria dalla pagina Log (il file su disco resta completo).",
"statusLines": 200,
"bufferedLines": 5000
},
"notifications": {
"_telegram": "Notifiche e comandi su Telegram (5.0). Il token del bot NON si scrive qui: variabile d'ambiente TELEGRAM_BOT_TOKEN (e TELEGRAM_CHAT_ID, che ha la precedenza su chatId). hourlyStatus = stato allo scoccare di ogni ora UTC; eventAlerts = aperture, chiusure, ordini risolti, kill-switch, recupero, errori; dailySummaryUtcHour = ora UTC del riepilogo giornaliero (negativo = spento); commands = risposte ai comandi /stato, /posizioni, /storico, /pausa, /riprendi, /chiudi, /kill CONFERMO, /reset dalla sola chat autorizzata.",
"telegram": {
"enabled": false,
"chatId": "",
"hourlyStatus": true,
"eventAlerts": true,
"dailySummaryUtcHour": 21,
"commands": true
}
}
}
+25 -1
View File
@@ -63,12 +63,36 @@
"volAverageDays": 30,
"mlMinProbability": 0.55,
"_sicurezza": "equityStopPct: perdita dal picco di equity oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.",
"_sicurezza": "equityStopPct: perdita dal picco di equity (al netto dei movimenti di cassa) oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.",
"equityStopPct": 9,
"dailyLossPct": 3,
"legTimeoutSec": 5,
"clockSkewMaxSeconds": 5,
"_risk": "Margine (5.0, §10). La size di un basket è il minimo fra la size a rischio e quella a margine. maxMarginUsePct: margine totale impegnato dopo l'apertura, in % dell'equity. maxMarginPerBasketPct: margine delle due gambe di un basket, in % dell'equity. marginBufferPct: il disponibile deve superare il margine richiesto di questa percentuale prima di inviare A e, ricontrollato, prima di inviare B (altrimenti A viene richiusa). closeForeignOnKill: il kill-switch chiude anche le posizioni non aperte dal bot. marginCallBlockRatio / marginCallCloseRatio: sotto equity/margine usato = 1,5 niente entrate; sotto 1,2 si chiude il basket con il P&L peggiore.",
"risk": {
"maxMarginUsePct": 40,
"maxMarginPerBasketPct": 12,
"marginBufferPct": 25,
"closeForeignOnKill": false,
"marginCallBlockRatio": 1.5,
"marginCallCloseRatio": 1.2
},
"_recovery": "Recupero dopo inattività (5.0, §6). Il bot scrive data/state/heartbeat.json ogni heartbeatSeconds; se all'avvio o fra due cicli passano più di thresholdMinutes (riavvio, sospensione del PC, aggiornamento, container fermo) blocca le entrate, risolve gli ordini senza esito, riconcilia, riscalda le serie con le barre perse e rivaluta ogni basket aperto come a una chiusura di barra ordinaria (chiudi o tieni; le orfane si chiudono, le esterne si riportano); scrive reports/recupero_<run_id>.csv e riapre le entrate dopo warmupMinutes di quotazioni. A mercato chiuso aspetta la riapertura.",
"recovery": {
"thresholdMinutes": 10,
"warmupMinutes": 15,
"heartbeatSeconds": 30
},
"_learning": "Apprendimento (ADR-0006). Di fabbrica tutto spento: restano il ledger, le tabelle di calibrazione, la previsione di volatilità e la logistica in ombra (p_ML nel ledger, mai un cancello). enabled = true riaccende il ciclo settimanale (weeklyCycle) e il challenger MLP (challenger) solo dopo il criterio di riattivazione di docs/ML_AND_LEARNING.md: almeno 300 basket chiusi in Demo e P&L netto forward >= 0 sulla pre-registrazione. Il bandit propone soltanto, non applica mai il preset.",
"learning": {
"enabled": false,
"weeklyCycle": false,
"challenger": false
},
"_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.",
"baskets": [
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Encelado — entrypoint del container.
#
# 1. Fuso orario da TZ (tzdata è nell'immagine).
# 2. Utente e gruppo da PUID/PGID (default 99/100, i «nobody:users» di Unraid):
# /config e /data vengono riassegnati a quell'utente, così i file scritti dal
# bot sono leggibili dalla share appdata senza giochi di permessi.
# 3. exec del processo finale con gosu: PID 1 è dotnet, SIGTERM arriva a lui.
#
# Con PUID=0 il bot resta root: sconsigliato, ma ammesso per il debug.
set -eu
PUID="${PUID:-99}"
PGID="${PGID:-100}"
if [ -n "${TZ:-}" ] && [ -f "/usr/share/zoneinfo/${TZ}" ]; then
ln -sf "/usr/share/zoneinfo/${TZ}" /etc/localtime
echo "${TZ}" > /etc/timezone
fi
if [ "${PUID}" != "0" ]; then
if ! getent group "${PGID}" >/dev/null 2>&1; then
groupadd -g "${PGID}" encelado
fi
if ! getent passwd "${PUID}" >/dev/null 2>&1; then
useradd -r -o -u "${PUID}" -g "${PGID}" -M -d /data -s /usr/sbin/nologin encelado
fi
mkdir -p /config /data
chown -R "${PUID}:${PGID}" /config /data
echo "encelado: avvio come uid ${PUID} gid ${PGID}, fuso ${TZ:-UTC}"
exec gosu "${PUID}:${PGID}" "$@"
fi
echo "encelado: avvio come root (PUID=0), fuso ${TZ:-UTC}"
exec "$@"
+71
View File
@@ -0,0 +1,71 @@
# Encelado su Unraid
Il template `encelado.xml` installa il container dall'immagine sul registro di Gitea
(`192.168.30.23:3000/alby96/encelado:latest`). Dettagli su volumi, variabili, healthcheck e
aggiornamento in `docs/DOCKER.md`; guida operativa in `docs/RUNBOOK.md`.
## 1. Il registro di Gitea è in HTTP: dillo a Docker di Unraid
Docker rifiuta un registro senza TLS finché non è dichiarato «insecure». Su Unraid, dal
terminale (o da *Tools ▸ Terminal*):
```sh
echo 'DOCKER_OPTS="--insecure-registry 192.168.30.23:3000"' >> /boot/config/docker.cfg
/etc/rc.d/rc.docker restart # oppure Impostazioni ▸ Docker: disabilita e riabilita
docker info | grep -A2 "Insecure Registries" # deve elencare 192.168.30.23:3000
```
Il repository Encelado è pubblico, quindi anche il pacchetto lo è: `docker pull` non ha
bisogno di login. Se un giorno diventa privato: `docker login 192.168.30.23:3000` con
utente Gitea e un token con permesso `package:read`.
## 2. Installa dal template
*Docker ▸ Add Container*, in fondo alla pagina **Template repositories** non serve: incolla
l'URL del template nel campo **Template** (in alto, «Select a template» ▸ voce vuota, poi il
campo URL), oppure scarica il file e mettilo in `/boot/config/plugins/dockerMan/templates-user/`:
```
http://192.168.30.23:3000/Alby96/Encelado/raw/branch/main/deploy/unraid/encelado.xml
```
Lo stesso file è allegato a ogni release su Gitea con la versione nel nome.
## 3. Compila i campi
| Campo | Cosa mettere |
|---|---|
| **Porta web** | `8080` (o un'altra libera: la WebUI del container la segue) |
| **/config**, **/data** | lascia i default `/mnt/user/appdata/encelado/config` e `/data` |
| **ENCELADO_WEB_TOKEN** | **obbligatorio**: una stringa lunga a piacere. Senza, il server ascolta solo dentro il container e dalla rete non si vede niente |
| **ETORO_API_KEY**, **ETORO_USER_KEY** | le chiavi del conto **demo** (dal portale sviluppatori eToro; demo e reale hanno chiavi diverse). Oppure lasciale vuote e inseriscile poi dall'interfaccia, mettendo una **ENCELADO_KEY_PASSPHRASE** |
| **ETORO_ENVIRONMENT** | `demo` |
| **ENCELADO_EXECUTION_MODE** | `Demo` |
| **TZ** | `Europe/Rome` |
| **TELEGRAM_BOT_TOKEN**, **TELEGRAM_CHAT_ID** | facoltativi: notifiche ogni ora, eventi, riepilogo serale e comandi dalla chat |
| PUID / PGID | `99` / `100` (nobody:users, i default di Unraid) |
`ENCELADO_CONFIRM_LIVE` resta vuoto: serve solo per il conto reale, che oggi non è praticabile.
## 4. Primo avvio
1. **Apply**. Il container semina `encelado.json` e `strategy.json` in `/mnt/user/appdata/encelado/config`.
2. Apri `http://<ip-unraid>:8080/`, inserisci il token (resta in un cookie per trenta giorni).
3. Se non hai messo le chiavi nelle variabili: **Impostazioni ▸ Chiavi eToro ▸ Verifica e salva** (vengono provate contro eToro e salvate cifrate in `/config/etoro.keys.enc`).
4. Con `ENCELADO_AUTOSTART` al default (`1`) il motore è già partito; altrimenti **AVVIA**. Il chip in basso a sinistra dice `DEMO`.
5. Per un test lungo guarda: contatore «in attesa · orfane · esterne» (orfane deve restare 0), **Storico ▸ Ordini** (unità richieste ed eseguite uguali, esito `Filled`), il log. Il kill-switch è il pulsante in alto a destra, o un file `STOP` in `/mnt/user/appdata/encelado/config`.
Il log è in `docker logs encelado` e in `/mnt/user/appdata/encelado/data/logs/encelado.log`; il ledger in `.../data/data/ledger/`.
## 5. Aggiornamento
*Docker ▸ Check for Updates*: il tag `latest` segue l'ultima release. Il container si ferma
con SIGTERM (45 s di grazia): i basket aperti restano sul conto con gli stop nativi e
vengono ripresi dallo stato salvato e dalla riconciliazione al riavvio (`docs/RUNBOOK.md`,
«Recupero dopo inattività»). Per tornare a una versione precisa cambia il tag nel campo
**Repository** (`…/encelado:5.0.0`).
## Icona
`assets/encelado.png` nel repository, servita da Gitea come file raw (`Icon` nel
template). Non serve pubblicarla altrove (D-29).
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0"?>
<Container version="2">
<Name>Encelado</Name>
<Repository>192.168.30.23:3000/alby96/encelado:latest</Repository>
<Registry>http://192.168.30.23:3000/Alby96/-/packages/container/encelado</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>http://192.168.30.23:3000/Alby96/Encelado/issues</Support>
<Project>http://192.168.30.23:3000/Alby96/Encelado</Project>
<Overview>Encelado: bot di trading "Correlation Baskets" su eToro (Public API). Motore, ledger e interfaccia web Material 3 in un solo container. Configurazione in /config (encelado.json, strategy.json, chiavi cifrate), dati in /data (ledger, stato, barre, conoscenza, rapporti, log). Le chiavi eToro si mettono nelle variabili ETORO_API_KEY e ETORO_USER_KEY oppure si salvano dall'interfaccia (cifrate con ENCELADO_KEY_PASSPHRASE). Modalita' Live solo con ENCELADO_EXECUTION_MODE=Live, ETORO_ENVIRONMENT=real e la frase CONFERMO LIVE in ENCELADO_CONFIRM_LIVE.</Overview>
<Category>Tools: Productivity:</Category>
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
<TemplateURL>http://192.168.30.23:3000/Alby96/Encelado/raw/branch/main/deploy/unraid/encelado.xml</TemplateURL>
<Icon>http://192.168.30.23:3000/Alby96/Encelado/raw/branch/main/assets/encelado.png</Icon>
<ExtraParams>--restart=unless-stopped --stop-timeout 45</ExtraParams>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Requires/>
<Config Name="Porta web" Target="8080" Default="8080" Mode="tcp" Description="Porta dell'interfaccia web e dell'API (http://IP:porta/)." Type="Port" Display="always" Required="true" Mask="false">8080</Config>
<Config Name="Configurazione (/config)" Target="/config" Default="/mnt/user/appdata/encelado/config" Mode="rw" Description="encelado.json, strategy.json, instruments.json e il file cifrato delle chiavi. Seminati al primo avvio dai valori di fabbrica." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/encelado/config</Config>
<Config Name="Dati (/data)" Target="/data" Default="/mnt/user/appdata/encelado/data" Mode="rw" Description="Ledger (data/ledger), stato (data/state), barre, cache dei feed, conoscenza, rapporti e log. Non cancellare: il ledger e' la memoria del bot." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/encelado/data</Config>
<Config Name="ETORO_API_KEY" Target="ETORO_API_KEY" Default="" Mode="" Description="Chiave x-api-key di eToro Public API (portale sviluppatori). In alternativa si salvano dall'interfaccia, cifrate con ENCELADO_KEY_PASSPHRASE." Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="ETORO_USER_KEY" Target="ETORO_USER_KEY" Default="" Mode="" Description="Chiave x-user-key di eToro Public API. Demo e reale hanno chiavi diverse." Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="ETORO_ENVIRONMENT" Target="ETORO_ENVIRONMENT" Default="demo" Mode="" Description="Ambiente eToro: demo (denaro virtuale) oppure real (conto reale; serve anche ENCELADO_EXECUTION_MODE=Live e la frase di conferma)." Type="Variable" Display="always" Required="true" Mask="false">demo</Config>
<Config Name="ENCELADO_EXECUTION_MODE" Target="ENCELADO_EXECUTION_MODE" Default="Demo" Mode="" Description="Paper (simulatore locale sopra le quotazioni reali), Demo (conto demo eToro, predefinito) o Live (conto reale: richiede run.allowLive=true in encelado.json e la frase in ENCELADO_CONFIRM_LIVE)." Type="Variable" Display="always" Required="true" Mask="false">Demo</Config>
<Config Name="ENCELADO_CONFIRM_LIVE" Target="ENCELADO_CONFIRM_LIVE" Default="" Mode="" Description="Solo per la modalita' Live: la frase esatta CONFERMO LIVE. Con qualunque altro valore il bot non parte sul conto reale." Type="Variable" Display="advanced" Required="false" Mask="false"></Config>
<Config Name="ENCELADO_WEB_TOKEN" Target="ENCELADO_WEB_TOKEN" Default="" Mode="" Description="Token di accesso all'interfaccia web (una stringa lunga a piacere). SENZA token il server ascolta solo su localhost del container e l'interfaccia non e' raggiungibile dalla rete: mettilo." Type="Variable" Display="always" Required="true" Mask="true"></Config>
<Config Name="ENCELADO_WEB_PORT" Target="ENCELADO_WEB_PORT" Default="8080" Mode="" Description="Porta interna di Kestrel. Cambiala solo se cambi anche la mappatura della porta qui sopra." Type="Variable" Display="advanced" Required="false" Mask="false">8080</Config>
<Config Name="ENCELADO_DISPLAY_CURRENCY" Target="ENCELADO_DISPLAY_CURRENCY" Default="USD" Mode="" Description="Valuta con cui l'interfaccia mostra gli importi (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD). Il ledger resta in USD; il tasso viene dalle quotazioni eToro." Type="Variable" Display="always" Required="false" Mask="false">USD</Config>
<Config Name="ENCELADO_KEY_PASSPHRASE" Target="ENCELADO_KEY_PASSPHRASE" Default="" Mode="" Description="Passphrase con cui l'interfaccia cifra le chiavi eToro salvate in /config/etoro.keys.enc (AES-256-GCM). Non serve se le chiavi arrivano dalle variabili qui sopra." Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="TELEGRAM_BOT_TOKEN" Target="TELEGRAM_BOT_TOKEN" Default="" Mode="" Description="Token del bot Telegram (da @BotFather) per notifiche e comandi. Vuoto = Telegram spento." Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="TELEGRAM_CHAT_ID" Target="TELEGRAM_CHAT_ID" Default="" Mode="" Description="Chat id autorizzata a ricevere le notifiche e a mandare i comandi (/stato, /posizioni, /pausa, /kill CONFERMO...). Ogni altro mittente viene ignorato." Type="Variable" Display="always" Required="false" Mask="false"></Config>
<Config Name="TZ" Target="TZ" Default="Europe/Rome" Mode="" Description="Fuso orario degli orari a schermo e nel log (nome IANA). Il ledger resta in UTC." Type="Variable" Display="always" Required="false" Mask="false">Europe/Rome</Config>
<Config Name="PUID" Target="PUID" Default="99" Mode="" Description="Utente con cui gira il bot e a cui vengono assegnati /config e /data (99 = nobody su Unraid)." Type="Variable" Display="advanced" Required="false" Mask="false">99</Config>
<Config Name="PGID" Target="PGID" Default="100" Mode="" Description="Gruppo del bot (100 = users su Unraid)." Type="Variable" Display="advanced" Required="false" Mask="false">100</Config>
</Container>
+50
View File
@@ -0,0 +1,50 @@
# Encelado in locale con Docker Compose: immagine costruita dalla Dockerfile alla
# radice, cartelle di configurazione e dati sotto deploy/local (ignorate da git).
#
# docker compose up --build # costruisce e avvia, log a video (Ctrl+C = stop pulito)
# docker compose up -d --build # in background → http://localhost:8080/ (token "sviluppo")
# docker compose logs -f encelado # segue il log
# docker compose down # ferma (SIGTERM: stop pulito del motore)
# Oppure: scripts/run-docker.ps1 (o .sh), che fa le stesse cose e stampa l'indirizzo.
#
# Le chiavi eToro e il token Telegram NON stanno qui: mettili in un file .env
# accanto a questo (ignorato da git) con ETORO_API_KEY=…, ETORO_USER_KEY=…,
# TELEGRAM_BOT_TOKEN=…, TELEGRAM_CHAT_ID=…, ENCELADO_WEB_TOKEN=…
# Per Unraid usa invece il template in deploy/unraid/encelado.xml.
services:
encelado:
build:
context: .
args:
GIT_COMMIT: ${GIT_COMMIT:-n/d}
VERSION: ${VERSION:-dev}
image: 192.168.30.23:3000/alby96/encelado:${VERSION:-dev}
container_name: encelado
restart: unless-stopped
ports:
- "${ENCELADO_WEB_PORT:-8080}:8080"
volumes:
- ./deploy/local/config:/config
- ./deploy/local/data:/data
environment:
TZ: ${TZ:-Europe/Rome}
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
# demo | real — l'ambiente eToro; real richiede anche ENCELADO_EXECUTION_MODE=Live
ETORO_ENVIRONMENT: ${ETORO_ENVIRONMENT:-demo}
# Paper | Demo | Live
ENCELADO_EXECUTION_MODE: ${ENCELADO_EXECUTION_MODE:-Demo}
# Solo per Live: la frase esatta «CONFERMO LIVE», altrimenti il bot non parte
ENCELADO_CONFIRM_LIVE: ${ENCELADO_CONFIRM_LIVE:-}
ENCELADO_DISPLAY_CURRENCY: ${ENCELADO_DISPLAY_CURRENCY:-USD}
# Senza token l'interfaccia è raggiungibile solo da localhost del container:
# per usarla dal browser il token serve. "sviluppo" è il default delle prove
# in locale; in .env mettine uno vero.
ENCELADO_WEB_TOKEN: ${ENCELADO_WEB_TOKEN:-sviluppo}
ENCELADO_KEY_PASSPHRASE: ${ENCELADO_KEY_PASSPHRASE:-}
ETORO_API_KEY: ${ETORO_API_KEY:-}
ETORO_USER_KEY: ${ETORO_USER_KEY:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
stop_grace_period: 45s
+86 -106
View File
@@ -1,102 +1,50 @@
# Architettura di Encelado
Aggiornato: 2026-09-16 (Fase 0 della modifica "Correlation Baskets" su eToro).
Aggiornato: 2026-09-23 (5.0: Engine + Server, interfaccia web, container). La storia della ricognizione del 2026-09-16 (l'albero che non compilava, i motori Binance/cTrader, SQLite) è nella versione precedente di questo file nella storia git e in ADR-0004.
## 1. Che cosa c'era prima della modifica (ricognizione)
### 1.1 Albero dei progetti
## 1. Progetti
```
Encelado.slnx
├── src/Encelado.Core libreria portabile (net10.0), zero NuGet, AOT/trim-compatibile
│ ├── Backtest/ replay su coppie cointegrate (era Binance), CsvBarSource, CrossSectional
│ ├── Indicators/ SMA, EMA, RSI, MACD, ATR, RollingStdDev, Bollinger, Donchian, RollingWindow<T>
├── Journal/ IJournalSink e record dei journal (DecisionRow, TradeRow, ProbaDecisionRow…)
├── Market/ Bar, Quote, Tick, Side, TimeFrame
│ ├── Ml/ GBDT nativo, meta-labeling, triple barrier, PurgedCv/CPCV, Pbo (CSCV), Classification (AUC, Brier, log-loss, calibrazione), DriftMonitor (PSI/KS)
│ ├── Research/ pipeline ProbaBot: AssetFrame, eventi CUSUM, bracci di feature B/C, EventBacktest, Trials, Gates
│ ├── Risk/ RiskEngine e RiskLimits (kill-switch giornaliero, esposizione, spread)
│ ├── Rl/ Mlp a due strati (Adam), DqnAgent, PairEnvironment
│ ├── Statistics/ Ols, DickeyFuller, Cointegration (+HalfLife), Johansen, Kalman, Pca, Performance (Sharpe, PSR, DSR, Kelly), Normal
── Strategies/ StatArbStrategy (coppie), StrategyParameters
├── src/Encelado.Storage SQLite (Microsoft.Data.Sqlite) — l'unica dipendenza NuGet a runtime; journal in doppia scrittura, dataset, modelli, campioni
├── src/Encelado.CTrader adattatore cTrader Open API (NuGet cTrader.OpenAPI.Net). NON referenziato dal Bot: non è mai stato collegato
├── src/Encelado.Bot WPF (net10.0-windows), WinExe "Encelado.exe", zero NuGet
│ ├── Configuration/ BotConfig, ConfigLoader (JsonDocument a mano, chiavi sconosciute segnalate), ConfigDefaults (JSON di fabbrica incorporato), ConfigWriter (modifica per percorso puntato, scrittura atomica), CredentialStore (DPAPI in %LOCALAPPDATA%\Encelado), CredentialResolver
├── Engine/ BotSupervisor (ciclo di vita, snapshot), ProbaEngine (motore cTrader, incompleto), AccountState, BotSnapshot, TradeJournal, DecisionLog
│ ├── Diagnostics/ CsvTable (tabelle ';' con header e spostamento in .old), Metrics
│ ├── Logging/ Log statico non bloccante su Channel<T>, file ';' con rotazione, Sink per la UI
│ ├── Ui/ MainViewModel (INotifyPropertyChanged a mano), Theme.xaml (tema scuro proprio), pagine Status/Log/Settings, LoginWindow (OAuth cTrader), SettingsCatalogue, SettingField, Converters
│ └── MainWindow.xaml(.cs) shell con navigazione laterale, timer 1 s che applica lo snapshot
├── tests/Encelado.Tests xunit 2.9 (framework GIÀ presente: si usa quello, niente mini-runner)
├── tools/Encelado.Backtest strumento console di ricerca ("backtest <comando>"), unico progetto con TA-Lib
└── build/ Release.proj (verifica, pacchetto, rilascio su Gitea), Encelado.iss (Inno Setup)
├── src/Encelado.Core libreria portabile (net10.0), zero NuGet, AOT/trim-compatibile, zero I/O
│ ├── Broker/ IBroker, modelli (Instrument, QuoteSnapshot, AccountSnapshot, BrokerPosition, ClosedTrade, OrderRequest, OrderOutcome), PaperBroker, RateLimiter
│ ├── Baskets/ SyntheticCross, PipMath, BasketMath, SymbolSeries, BasketDecider, CostGate, VolParitySizing, BasketExecutor, BasketPosition (con PendingA/PendingB),
PendingEntry, BasketStrategyConfig (strategy.json, preset, risk, recovery, learning), OrderTracker (ADR-0009), PositionClassifier, EquityTracker,
CashFlowDetector, FlattenProcedure, Heartbeat/HeartbeatFile, InstanceLock, RecoveryPlanner
│ ├── Baskets/History/ OrderRecord (orders.jsonl), BasketOutcomeRow (baskets.csv), PositionRecord, PeriodStats, CashMovementRecord, EquityPoint, HistoryBuilder (la pagina Storico, pura)
│ ├── Baskets/Data/ BidAskBar + CSV, TickToBars (tick MT5 → M15)
│ ├── Baskets/Learning/ CalibrationTables, OnlineLogistic, SmallMlp, ThompsonBandit, VolForecast, LearningFeatures, ModelEvaluator
│ ├── Baskets/Backtest/ BasketBacktest, BacktestBroker, BasketTrials
│ ├── News/ CalendarParser, RssParser, SentimentLexicon, SentimentEngine
── Notifications/ INotifier, NullNotifier, TelegramNotifier (solo HttpClient)
│ └── Statistics/ Ols, Normal, Performance (Sharpe, PSR, DSR, drawdown), Classification, Pbo
├── src/Encelado.Etoro EtoroOptions, EtoroHttp (x-api-key/x-user-key/x-request-id, limitatore per classe di quota, 429, scarto orologio), EtoroBroker : IBroker, Json
├── src/Encelado.Engine libreria (net10.0): il motore e tutto ciò che tocca il disco o la rete oltre al broker
│ ├── Baskets/ BasketEngine in otto file parziali (.cs ciclo e decisioni, .Pending, .Reconcile, .Kill, .Recovery, .State, .Commands, .Snapshot), Ledger, Feeds,
ContextProvider, LearningState, HistoryService (fonti della pagina Storico, con cache), TelegramReports, TelegramCommands, HeadlessRunner (console: stato, comandi, bonifica)
│ ├── Configuration/ BotConfig, ConfigLoader (JsonDocument, variabili d'ambiente), ConfigDefaults (JSON di fabbrica incorporato), ConfigWriter, AppPaths (/config, /data, Documenti), KeyStore (IKeyStore, EncryptedFileKeyStore, KeyStores)
│ ├── Engine/ IEngine, BotSupervisor (ciclo di vita, snapshot, eventi di log), BotSnapshot, SnapshotJson (lo snapshot in JSON, a mano), SampleSnapshot (dati finti per --sample e test)
│ ├── Logging/ Log statico non bloccante su Channel<T>, file ';' con rotazione, console
│ └── Settings/ SettingsCatalogue e SettingField (il form di Impostazioni), SettingsService (descrizione e applicazione via API), UiClock (fuso orario a schermo)
├── src/Encelado.Server l'eseguibile (Microsoft.NET.Sdk.Web, framework reference Microsoft.AspNetCore.App, nessun NuGet)
│ ├── Program.cs argomenti e ambiente, AppPaths, log, notifier, supervisore, Telegram, LogBuffer, HistoryService, WebHost, avvio automatico, SIGTERM, --health, --sample
│ └── Web/ WebHost (Kestrel, autenticazione, rotte statiche e API), Json (Utf8JsonWriter), LogBuffer (finestra del log per la pagina), LogBridge (log di ASP.NET → Log),
│ wwwroot/ (index.html, login.html, app.css, app.js, icon.svg, manifest.webmanifest — incorporati)
├── tools/Encelado.Backtest ticks, baskets, falsify, learn (referenzia Core ed Engine)
├── tests/Encelado.Tests xunit (net10.0, gira su Windows e nello stadio di build dell'immagine)
├── .gitea/workflows/ ci.yml (build, test, docker build a ogni push), release.yml (immagine sul registro e release a ogni tag)
├── deploy/docker/ entrypoint.sh (TZ, PUID/PGID, gosu, exec)
├── deploy/unraid/ encelado.xml (template Container v2), README.md
├── Dockerfile, docker-compose.yml, .dockerignore
└── build/ Release.proj (Verifica, Backtest, Pubblica, Docker, Pacchetto, Rilascia), README.md
```
- **Target framework**: `net10.0` (Bot e test `net10.0-windows`), `Nullable` e `TreatWarningsAsErrors` attivi per tutti i progetti via `Directory.Build.props`. SDK installato: 10.0.301.
- **Pattern**: nessun contenitore DI; oggetti costruiti a mano nel supervisore; async/await con `ConfigureAwait(false)` nel motore; `Channel<T>` per il log; `Lock` per lo stato condiviso; snapshot immutabili verso la UI; ogni tabella è CSV `;` con colonna finale `motivazione`; log strutturato `timestamp;level;source;subject;event;message;exception;stack`.
- **Client broker esistente**: nessun client eToro. Esisteva un adattatore Binance (cancellato, non committato) e un adattatore cTrader (mai collegato al Bot). Il motore `ProbaEngine` usa i tipi cTrader direttamente.
- **Storage**: SQLite in `%ProgramData%\Encelado\encelado.db` (barre, dataset, modelli, journal) più CSV nella cartella dei log. Configurazione in `Documenti\Encelado\encelado.json`; credenziali cifrate DPAPI in `%LOCALAPPDATA%\Encelado`.
- **UI**: WPF, tema scuro proprio (`Ui/Theme.xaml`: palette, `Card`, `Chip`, `Kpi`, `Label`, `Value`, `Sub`, `Head`, pulsanti `Primary`/`Danger`, `PowerButton`, `ModeBadge`), font tabulare `Cascadia Mono`. Nessuna libreria MVVM: `MainViewModel` implementa `INotifyPropertyChanged` a mano.
- **Test**: xunit con test di binding WPF (`UiBindingTests` ascolta la trace source dei binding e fallisce su ogni binding irrisolto), test di configurazione, statistica, ML, rischio.
- **Build ed esecuzione**: `dotnet build Encelado.slnx`; verifica completa `dotnet msbuild build/Release.proj -t:Verifica`; l'app legge `Documenti\Encelado\encelado.json` (creato dal JSON di fabbrica al primo avvio); la versione rilasciata viene dal tag git (`build/Release.proj`).
- **Target framework**: `net10.0` ovunque, `Nullable` e `TreatWarningsAsErrors` attivi via `Directory.Build.props`; `InvariantGlobalization` nei progetti dell'applicazione (i test lo spengono per verificare i fusi IANA e Windows). SDK 10.0.301.
- **Pattern**: nessun contenitore DI; oggetti costruiti a mano in `Program.cs` e nel supervisore; async/await con `ConfigureAwait(false)` nel motore; `Channel<T>` per il log e le notifiche; `Lock` per lo stato condiviso; snapshot immutabili verso il server; ogni tabella è CSV `;` con colonna finale `motivazione`; log strutturato `timestamp;level;source;subject;event;message;exception;stack`.
- **Storage**: solo file (ADR-0002). Nel container `/config` (configurazione, chiavi cifrate) e `/data` (`data/`, `knowledge/`, `reports/`, `results/`, `logs/`); fuori dal container `Documenti\Encelado` o `ENCELADO_CONFIG_DIR`.
- **Build ed esecuzione**: `dotnet build Encelado.slnx`; `dotnet run --project src/Encelado.Server`; immagine con `dotnet msbuild build/Release.proj -t:Docker`; la versione rilasciata viene dal tag git, commit e data di build da `AssemblyMetadata`.
### 1.2 Stato dell'albero di lavoro trovato il 2026-09-16
L'albero **non compilava**: la sessione precedente (rework verso cTrader, 2026-09-09) era rimasta a metà e non committata.
| Problema | Dove |
|---|---|
| `Encelado.Bot` non referenzia `Encelado.CTrader`, quindi `ProbaEngine`, `BotConfig`, `CredentialResolver`, `LoginWindow`, `AccountState` non risolvono i tipi cTrader | `src/Encelado.Bot/Encelado.Bot.csproj` |
| `MainWindow.xaml.cs` referenzia `PositionsPage` (cancellata), `_config.Binance`, `ClosePairAsync`, `EnabledPairs` (era Binance) | `src/Encelado.Bot/MainWindow.xaml.cs` |
| `CsvTable.cs` usa `Side` senza `using Encelado.Core.Market` | `src/Encelado.Bot/Diagnostics/CsvTable.cs` |
| `TestSnapshots.cs` costruisce lo snapshot dell'era Binance (`PairRow`, `EquityCurve`, `OrderRow`…) | `tests/Encelado.Tests/TestSnapshots.cs` |
| `Documenti\Encelado\encelado.json` dell'utente è nel formato Binance (sezioni `binance`, `pairs`) | file dell'utente, non nel repo |
Decisione presa (vedi `docs/QUESTIONS.md`, D-09): il motore cTrader resta nel repository come modulo selezionabile (`engine.strategy = "proba"`) e viene rimesso in compilazione; il motore nuovo (`"baskets"`) è il predefinito.
### 1.3 Punti di estensione usati dalla modifica
| Cosa | Dove si aggancia |
|---|---|
| Nuova strategia | `BotSupervisor` costruisce il motore in base a `engine.strategy`; il motore espone `IEngine` (`RunAsync`, `Snapshot`, comandi) |
| Flusso dati di mercato | il motore basket interroga `IBroker.GetQuotesAsync` a polling (2-5 s) e costruisce le barre M15 in locale; le candele ufficiali servono per il riscaldamento e la riconciliazione |
| Esecuzione ordini | `IBroker.OpenAsync/CloseAsync/UpdateStopsAsync`, tre implementazioni (`EtoroBroker`, `PaperBroker`, `BacktestBroker`) |
| Log delle operazioni | `Log` (file `;`), più il ledger nuovo (`data/ledger/decisions.jsonl`, `baskets.csv`) |
| UI | pagine nuove (`BasketsPage`) selezionate dalla shell in base al motore; `Theme.xaml` riusato |
| Configurazione | `encelado.json` (sezioni `engine`, `etoro`, `logging`) + `strategy.json` (parametri e preset dei basket) letti con `JsonDocument`, modificati con `ConfigWriter` |
| Test | xunit esistente; nuove suite in `tests/Encelado.Tests/Baskets*.cs` |
## 2. Architettura della modifica (obiettivo)
### 2.1 Progetti (stato del 2026-09-16 sera, dopo la rimozione dei motori precedenti — ADR-0004)
```
src/Encelado.Core/Broker/ IBroker, modelli (Instrument, QuoteSnapshot, AccountSnapshot, BrokerPosition, OrderRequest, OrderOutcome), PaperBroker (simulatore sopra un feed reale), RateLimiter
src/Encelado.Core/Baskets/ matematica e logica pura, senza I/O:
SyntheticCross (derivazione automatica del cross e dei segni), PipMath, BasketMath (rendimenti log, ATR, EWMA vol, ρ_W/ρ_20, z-score, semiperiodo OLS, forza di trend),
SymbolSeries (barre + quote + qualità dati), BasketDecider (entrate/uscite/averaging di §5), CostGate, VolParitySizing, BasketExecutor (protocollo leg-risk),
BasketPosition (macchina a stati), BasketStrategyConfig (strategy.json, preset), ExecutionMode (Paper | Demo | Live)
src/Encelado.Core/Baskets/Data/ BidAskBar + CSV, TickToBars (tick MT5 → M15)
src/Encelado.Core/Baskets/Learning/ livelli 0-3: CalibrationTables, OnlineLogistic (SGD+L2, standardizzazione rolling), SmallMlp (16 ReLU, Adam, early stopping, gradient check),
ThompsonBandit (Beta per preset × terzile di vol), VolForecast (EWMA vs HAR-RV, PSI), LearningFeatures (28 feature del ledger), ModelEvaluator (walk-forward, fold purgati, bootstrap, attivazione)
src/Encelado.Core/Baskets/Backtest/ BasketBacktest (event-driven su barre M15 bid/ask), BacktestBroker, BasketTrials (griglia, PSR/DSR, PBO, walk-forward 6m/1m)
src/Encelado.Core/News/ parser puri: CalendarParser (JSON/XML FairEconomy), RssParser (XmlReader), SentimentLexicon, SentimentEngine (finestre 1h/4h/24h con decadimento)
src/Encelado.Core/Ml/, Statistics/ la statistica condivisa rimasta: Classification (AUC, Brier, log-loss, calibrazione), Pbo (CSCV), Performance (Sharpe, PSR, DSR, drawdown, momenti), Ols, Normal
src/Encelado.Etoro/ EtoroOptions, EtoroHttp (HttpClient, x-api-key/x-user-key/x-request-id, limitatore per classe di quota, 429 con Retry-After, scarto orologio dall'header Date), EtoroBroker : IBroker
src/Encelado.Bot/Baskets/ BasketEngine (ciclo di decisione a thread singolo, polling quote, barre locali, esecuzione diretta, equity stop, kill-switch, file STOP, riconciliazione),
Ledger (decisions.jsonl append-only, baskets.csv, rotazione mensile, scritture atomiche), Feeds (calendario + RSS con cache su disco, robots.txt, backoff),
LearningState (modello in ombra, bandit, ciclo settimanale, knowledge/), HeadlessRunner (--headless)
src/Encelado.Bot/Configuration/ BotConfig (etoro, run, ui, logging), ConfigLoader (JsonDocument, avvisi sulle sezioni di versioni precedenti), ConfigDefaults, ConfigWriter, EtoroKeyStore (DPAPI)
src/Encelado.Bot/Engine/ IEngine, BotSupervisor (ciclo di vita, snapshot, feed di attività), BotSnapshot
src/Encelado.Bot/Ui/ Theme.xaml, MainWindow (barra in alto con le tre schede), Pages/DashboardPage (i cinque numeri, la tabella dei basket, il contesto, l'attività), LogPage, SettingsPage (SettingsCatalogue, fuso orario),
EtoroLoginWindow, PromptWindow (CONFERMO LIVE, motivazione del reset), UiClock (fuso orario della finestra), MainViewModel
tools/Encelado.Backtest `ticks` (tick MT5 → barre M15 bid/ask), `baskets` (baseline, griglia, trials, PBO, walk-forward), `falsify` (i cinque test di falsificazione); `--costs etoro|api`
```
Progetti rimossi il 2026-09-16 (ADR-0004): `Encelado.CTrader`, `Encelado.Storage`, `Core/Backtest`, `Indicators`, `Journal`, `Market`, `Portfolio`, `Research`, `Risk`, `Rl`, `Strategies`, il grosso di `Ml` e `Statistics`, `ProbaEngine`, le pagine `StatusPage`/`LoginWindow`, `ApprovalQueue`.
### 2.2 Flusso dati (live)
## 2. Flusso dati (live)
```mermaid
flowchart LR
@@ -107,22 +55,29 @@ flowchart LR
C[Calendario + RSS] --> F[Feature contesto]
F --> S
M[Meta-modello in ombra<br/>vol forecast] --> S
S -->|decisione| X[Executor<br/>leg-risk protocol]
S -->|decisione| X[Executor<br/>leg-risk + OrderTracker]
X --> E
S --> L[(Ledger jsonl/csv)]
X --> L
S --> U[Snapshot → UI / headless]
L --> K[Ciclo settimanale: L0-L3]
S --> N[Snapshot]
N --> W[Server: SSE /api/stream<br/>console, Telegram]
W --> U[Browser]
U -->|POST /api/commands| W --> S
L --> K[Ciclo di apprendimento<br/>backtest learn / Ricerca]
K --> M
```
Le decisioni avvengono su un solo thread; l'I/O è asincrono; l'unico gate umano per ordine è sparito (ADR-0005): restano avvio del reale, kill-switch, reset e cambio di preset.
Le decisioni avvengono su un solo thread; l'I/O è asincrono; l'unico gate umano per ordine è sparito (ADR-0005): restano avvio del reale, kill-switch, reset e cambio di preset, tutti dall'interfaccia web, dalla console o da Telegram.
### 2.3 Macchina a stati del basket
## 3. Macchina a stati del basket
```
Idle ──(segnale + cancelli)──► Entering ──(A e B eseguite)──► Open ──(add)──► Adding ──► Open
│ (B rifiutata/timeout → chiudi A, leg_risk_unwind, basket disattivato 1 h)
│ (B rifiutata → chiudi A, leg_risk_unwind, basket disattivato 1 h)
│ │ │ (A senza esito oltre legTimeoutSec) ──► PendingA ──(A eseguita, segnale valido)──► Entering
│ │ │ │ (A rifiutata → Idle; A eseguita e segnale decaduto → chiudi A → Idle)
│ │ │ (A eseguita, B senza esito) ──► PendingB ──(B eseguita)──► Open
│ └───────────────────────────────────────────────────────────────── (B rifiutata → chiudi A → Idle)
│ ▼
└────────── Closed ◄──── Exiting ◄──(TP | z_out | stop | time-stop | manuale | forzata)── Open
│ (una gamba non chiude dopo 3 tentativi)
@@ -130,19 +85,44 @@ Idle ──(segnale + cancelli)──► Entering ──(A e B eseguite)──
Error (blocco nuove entrate finché non risolto)
```
### 2.4 Interfacce
Stati del motore (non del basket): `Halted` dopo un kill-switch o un equity stop con il conto piatto per le posizioni del bot; **`Halted-Residuo`** quando la verifica di piattezza trova ancora posizioni del bot sul conto (banner rosso con l'elenco, reset rifiutato finché restano). Negli stati `PendingA`/`PendingB` il basket non viene valutato e non manda ordini; il registro degli ordini (`OrderTracker`) chiede l'esito al server e, quando manca, lo ricostruisce dalla posizione comparsa sul conto. Gli ingressi in sospeso sopravvivono a un riavvio (`pendingEntries` in `baskets_state.json`) e vengono risolti all'avvio prima di qualsiasi decisione.
- `IBroker`: `Environment`, `GetInstrumentsAsync`, `GetQuotesAsync(ids)`, `GetCandlesAsync(id, interval, count)`, `GetAccountAsync`, `GetPositionsAsync`, `OpenAsync(OrderRequest)`, `LookupOrderAsync`, `CloseAsync(positionId, instrumentId)`, `UpdateStopsAsync(positionId, sl, tp)`, `GetCostAsync(OrderRequest)`, `GetClosedTradesAsync`, `ClockSkew`.
- `IContextProvider` (Bot): calendario, notizie e sentiment per basket (`FeedContextProvider`; `EmptyContextProvider` nei test).
## 4. Interfacce
- `IBroker`: `Environment`, `GetInstrumentsAsync`, `GetQuotesAsync(ids)`, `GetCandlesAsync(id, interval, count)`, `GetAccountAsync`, `GetPositionsAsync`, `OpenAsync(OrderRequest)` (esito per `orderId`, poi per posizione comparsa; mai un esito inventato), `LookupOrderAsync(clientRef)`, `LookupOrderByIdAsync(orderId)`, `CancelOrderAsync(orderId)`, `CloseAsync(positionId, instrumentId)`, `UpdateStopsAsync(positionId, sl, tp)`, `GetCostAsync(OrderRequest)`, `GetClosedTradesAsync`, `ClockSkew`.
- `IContextProvider` (Engine): calendario, notizie e sentiment per basket (`FeedContextProvider`).
- `IModel`: `Predict(features)`, `Update(features, label)`, JSON, implementato da `OnlineLogistic` e `SmallMlp`.
- `IEngine` (Bot): `RunAsync`, `CloseAllAsync`, `ExecuteAsync(EngineCommand)` con `Close`, `KillSwitch`, `SetPreset`, `ResetEquityStop(motivazione)`, `Snapshot()`.
- `IUiActions` (Bot): ciò che le pagine possono chiedere alla finestra (chiudi basket, kill-switch, preset, reset, chiavi, file).
- `IEngine` (Engine): `RunAsync`, `CloseAllAsync`, `ExecuteAsync(EngineCommand)` con `Close`, `KillSwitch` (argomento `esterne`), `SetPreset`, `ResetEquityStop(motivazione)` (la procedura in cinque passi), `CloseResidue`, `Bonifica`, `History`, `Pause`, `Resume`, `Snapshot()`. Ogni comando eseguito è una riga `comando` nel ledger.
- `INotifier` (Core): `Notify(kind, title, text)` non bloccante e `Status`; il motore lo riceve dal supervisore, che lo riceve da `Program.cs` (`Notifications.Create`).
- `IKeyStore` (Engine): `Load(demo)`, `Save`, `Clear`, `CanSave`, implementato da `EncryptedFileKeyStore` (`etoro.keys.enc`, AES-256-GCM con passphrase); le variabili `ETORO_API_KEY`/`ETORO_USER_KEY` le applica il loader e `KeyStores.Resolve` prova prima quelle poi il file.
- `WebContext` (Server): ciò che il web host vede del processo — configurazione, supervisore, finestra del log, storico, percorso del file, `Start` e `Learn` come funzioni, `Snapshot()` (finto in `--sample`).
### 2.5 Vincoli e limiti scoperti in Fase 0
## 5. API web
- L'endpoint candele di eToro accetta solo `count ≤ 1000` senza data di partenza: dà al massimo ~10 giorni di M15. Lo storico per il backtest viene dai tick MT5 forniti dall'utente (`A:\Download\Trading`, 2018-12 → 2026-09, UTC), convertiti in M15 bid/ask dallo strumento `backtest ticks`.
- Quote di mercato (`/api/v2/market-data/rates`) in batch fino a 1000 strumenti per chiamata: un polling ogni 3 s costa 20 richieste/min sulla quota condivisa di 120/min.
Tutte le rotte tranne `/api/health`, `/login`, `/icon.svg`, `/manifest.webmanifest` chiedono il token (`ENCELADO_WEB_TOKEN`) nel cookie `encelado_token` (HttpOnly, 30 giorni) o in `Authorization: Bearer`. Senza token configurato il server ascolta solo su localhost e non chiede niente. JSON scritto a mano con `Utf8JsonWriter`, mai serializzazione per reflection.
| Rotta | Cosa |
|---|---|
| `GET /`, `/index.html`, `/app.css`, `/app.js`, `/icon.svg`, `/manifest.webmanifest`, `/login`; `POST /login` | la pagina e i suoi file, incorporati; il login mette il cookie e rimanda a `/` |
| `GET /api/health` | `{ok, running, heartbeatAgeSeconds, disk, state}`; usato dal `HEALTHCHECK` del container |
| `GET /api/snapshot` | lo snapshot completo (`SnapshotJson`) |
| `GET /api/stream` | Server-Sent Events, `event: snapshot` con lo stesso JSON, uno al secondo |
| `GET /api/log?level=&q=&limit=&after=` | la finestra del log (`LogBuffer`), a incrementi per `seq` |
| `POST /api/commands/{start\|stop\|close\|kill\|preset\|reset\|residue\|pause\|resume\|bonifica\|learn}` | corpo `{argument, reason, confirm, foreign}`; risposta `{ok, message, steps[], positions[]}` |
| `GET /api/history/orders\|positions\|periods[?format=csv&from=&to=&basket=&symbol=&outcome=&origin=]` | le tre viste dello Storico (`HistoryBuilder`); `format=csv` scarica il file `;` |
| `GET/POST /api/settings` | il form (`SettingsService.Describe`) e le modifiche per percorso (`Apply`, con validazione e scrittura atomica) |
| `POST/DELETE /api/settings/keys` | verifica le chiavi contro eToro (profilo e conto) e le salva nel file cifrato; rimozione |
| `POST /api/settings/restore` | configurazione ai valori di fabbrica con backup datato (solo a bot fermo) |
| `GET /api/info` | nome, autore, versione, data di build, commit, percorsi, .NET, sistema, uptime, quote, fuso |
| `GET /api/research` | stato dell'apprendimento (abilitazioni, modello in ombra, bandit, volatilità, file in `knowledge/`) |
Il ciclo di vita del processo (`Program.cs`): carica `AppPaths` (semina `encelado.json` e `strategy.json`), inizializza il log (file + console), crea notifier e supervisore, avvia Kestrel, avvia il motore se `ENCELADO_AUTOSTART` ≠ 0 (chiavi da `KeyStores.Resolve`, frase `CONFERMO LIVE` per il Live), legge i comandi da stdin, si ferma su Ctrl+C, SIGTERM, `stop` o `--minutes`. `--health` interroga `/api/health` ed esce 0/1; `--sample` serve dati finti senza motore; `--bonifica` avvia con la bonifica interattiva.
## 6. Vincoli e limiti dell'API eToro
- L'endpoint candele accetta solo `count ≤ 1000` senza data di partenza: dà al massimo ~10 giorni di M15. Lo storico per il backtest viene dai tick MT5 forniti dall'utente (`A:\Download\Trading`, 2018-12 → 2026-09, UTC), convertiti in M15 bid/ask dallo strumento `backtest ticks`.
- Quote di mercato (`/api/v2/market-data/rates`) in batch fino a 1000 strumenti per chiamata: un polling ogni 3 s costa 20 richieste/min sulla quota condivisa di 120/min. Le sette coppie di conversione della valuta di visualizzazione (EURUSD, GBPUSD, USDCHF, USDJPY, AUDUSD, USDCAD, NZDUSD) viaggiano nella stessa richiesta.
- Quota ordini: 20 richieste/min (demo e reale separate). Un basket costa 2 aperture + 2 chiusure.
- Le quote di `rates` sono senza markup; il costo effettivo (markup + spread di mercato + overnight) arriva da `POST /trading/info/{demo/}costs` (20/min dedicate). Il cost gate somma i due.
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (asincrono: esito con `orders:lookup` per `referenceId` = `x-request-id`); `sellShort` e leva > 1 richiedono `stopLossRate`. Chiusura: `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}`.
- Esposizione minima per posizione: 1000 USD (`minPositionExposure`); leva ammessa 1-30 (majors) e 1-20 (minors). Il conto reale dell'utente vale 193,18 USD: con i limiti di rischio della strategia il reale non è praticabile oggi (vedi QUESTIONS D-05).
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (asincrono: esito con `orders:lookup?orderId=`; il server **non** registra l'`x-request-id` come `referenceId`, verificato il 2026-09-23); `sellShort` e leva > 1 richiedono `stopLossRate`. Il server può **ridurre** un ordine invece di rifiutarlo (2 000 USD di margine a margine esaurito, 2026-09-16): le unità eseguite si leggono dalla risposta, mai date per scontate. Chiusura: `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}`. Cancellazione: `DELETE /api/v2/trading/execution/{demo/}orders/{id}`.
- Esposizione minima per posizione: 1000 USD (`minPositionExposure`); leva ammessa 1-30 (majors) e 1-20 (minors). Il conto reale dell'utente vale 224,90 USD: con i limiti di rischio della strategia il reale non è praticabile oggi (vedi QUESTIONS D-05).
+32 -4
View File
@@ -15,6 +15,20 @@ Aggiornato: 2026-09-16. Ogni fonte è stata verificata alla data indicata; se un
Qualità (`data/market/data_quality.csv`, generato da `backtest ticks`, e `reports/data_quality.csv` dal bot): buchi > 1 h nei giorni feriali, salti > 2 % fra barre, duplicati. Una barra sospetta sospende le decisioni sul basket coinvolto per quella barra.
### 1.1 Ordini e posizioni (rotte verificate il 2026-09-23 sull'OpenAPI v1.379.0 e con chiamate reali sul conto demo)
| Rotta | Uso | Note verificate |
|---|---|---|
| `POST api/v2/trading/execution/{demo/}orders` | invio dell'ordine (`action open`, `transaction buy/sellShort`, `orderType mkt`, `units`, `leverage`, `stopLossRate`) | risponde 200 con `orderId`; il server lavora l'ordine in modo asincrono. **Non registra l'`x-request-id` come riferimento**: la lettura per `orderId` di un ordine del bot mostra `referenceID = 00000000-0000-0000-0000-000000000000`. Quota 20/min condivisa con chiusure e cancellazioni. |
| `GET api/v2/trading/info/{demo/}orders:lookup?orderId=<id>` | esito dell'ordine, con le posizioni prodotte (`positionExecutions[].positionId`, `openingData.avgPrice`, `units`, `executionTime`, `fees`) | è la **chiave** usata dal bot. `requestedUnits`/`requestedAmount` possono differire dalle unità inviate: il 2026-09-16 il server ha ridotto gli ordini a 2 000 USD di margine (`frozenAmount 2000`, unità a sei decimali ricalcolate). Quota 60/min condivisa con `close-orders/{id}` e `orders/{id}`. |
| `GET api/v2/trading/info/{demo/}orders:lookup?referenceId=<x-request-id>` | ripiego quando la risposta al `POST` è andata persa | 404 per gli ordini v2 del bot (vedi sopra). |
| `GET api/v1/trading/info/{demo/}orders/{orderId}` | ripiego per `orderId` con la risposta v1 (`statusID`, `errorCode`, `positions[] {positionID, rate, units, occurred, isOpen}`) | verificato con l'ordine 381739181. |
| `DELETE api/v2/trading/execution/{demo/}orders/{orderId}` | cancellazione di un ordine non ancora eseguito (kill-switch) | 200 = richiesta accettata, non annullamento avvenuto: confermare con il lookup (7 o 9 = annullato, 6 = in corso). Idempotente su ordini già chiusi. |
| `GET api/v1/trading/info/{demo/}pnl` | conto e posizioni in una chiamata: `clientPortfolio.credit`, `bonusCredit`, `unrealizedPnL`, `positions[] {positionID, instrumentID, isBuy, units, openRate, openDateTime, amount (margine), leverage, unrealizedPnL.pnL, totalFees}` | `equity = credit + bonus + Σ amount + unrealized`; `available = credit + bonus`; `usedMargin = Σ amount`. Il conto demo **non compare** in `api/v1/balances` (solo i conti reali). |
| `GET api/v1/trading/info/trade/{demo/}history?minDate=…&page=…&pageSize=200` | posizioni chiuse: `positionId`, `orderId`, `openRate`, `closeRate`, `openTime`, `closeTime`, `netProfit`, `fees`, `investment` | `netProfit` **non** include `fees`. Fonte del realizzato della scheda Storico e della distinzione fra chiusure e movimenti di cassa. |
**Stati dell'ordine** (`status.id` / `statusID`): 1 Received, 2 Placed, 3 Filled, 4 Rejected, 5 PartiallyFilled, 6 PendingCancel, 7 Canceled, 8 Expired, 9 CanceledPartiallyFilled, 10 RejectedPartiallyFilled, 11 WaitingForMarket, 12 PendingTriggeredRate. Il bot tratta 3 e 5 come eseguito, 4, 7, 8, 9, 10 come rifiutato/annullato, 1, 2, 6, 11, 12 come in corso; in assenza di risposta lo stato è `Unknown` e l'ordine resta nel registro. Esiste anche `POST api/v3/trading/execution/{demo/}orders` (202, stessa semantica, `settlementType` obbligatorio): non usato, annotato per il futuro.
## 2. Calendario economico
| Fonte | URL | Formato | Aggiornamento | Note |
@@ -49,10 +63,20 @@ Sentiment senza librerie (`Encelado.Core/News/SentimentLexicon.cs`, `SentimentEn
Copie dei feed usate dai test: `tests/fixtures/` (scaricate il 2026-09-16).
## 4. Schema dei file in `Documenti\Encelado`
## 3.1 Telegram Bot API (dalla 5.0)
| Rotta | Uso | Note |
|---|---|---|
| `POST https://api.telegram.org/bot<token>/sendMessage` | notifiche e risposte, corpo `{chat_id, text, parse_mode: "HTML", disable_web_page_preview: true}` | limite 4096 caratteri per messaggio (il bot spezza sulle righe); un invio al secondo; su 429 il corpo porta `parameters.retry_after`; un 4xx (token, chat o markup sbagliati) non viene ritentato |
| `GET https://api.telegram.org/bot<token>/getUpdates?offset=<n>&timeout=25&allowed_updates=["message"]` | comandi in ingresso, long polling da un solo task | solo i messaggi con `chat.id` uguale a `TELEGRAM_CHAT_ID` vengono eseguiti; gli altri sono ignorati e annotati nel log; `offset` avanza all'ultimo `update_id` + 1 |
Il token non è mai in un file del repository: `TELEGRAM_BOT_TOKEN` nell'ambiente (o, a scelta dell'operatore, `botToken` in `encelado.local.json`).
## 4. Schema dei file (nel container `/config` e `/data`; fuori dal container `Documenti\Encelado`)
```
encelado.json, strategy.json, instruments.json
/config: encelado.json, strategy.json, instruments.json, etoro.keys.enc (chiavi cifrate), STOP (kill-switch da file)
/data:
data/market/candles_<SYMBOL>_M15.csv timeUtc;bidOpen;bidHigh;bidLow;bidClose;askOpen;askHigh;askLow;askClose;spreadMean;ticks;motivazione
data/market/data_quality.csv simbolo;tick_letti;tick_scartati;barre;prima_barra;ultima_barra;buchi_feriali_oltre_1h;barre_spike;spread_mediano_pip;motivazione
data/calendar/events.jsonl {title,country,date,impact,forecast,previous,actual}
@@ -60,10 +84,14 @@ data/news/news_YYYYMM.jsonl {hash,published,source,title,summary,li
data/cache/<fonte>.xml|json ultimo corpo buono di ogni feed
data/ledger/decisions.jsonl vedi docs/LEDGER_SCHEMA.md (rotazione mensile in decisions_YYYYMM.jsonl)
data/ledger/baskets.csv vedi docs/LEDGER_SCHEMA.md
data/state/baskets_state.json posizioni aperte, picco di equity, blocchi (per ripartire dopo un riavvio)
data/ledger/orders.jsonl una riga per ordine inviato e per cambio di stato (5.0)
data/state/baskets_state.json posizioni aperte, ingressi in attesa, picco di equity al netto dei movimenti di cassa, blocchi
data/state/pending_orders.json il registro degli ordini (5.0)
data/state/heartbeat.json ultimo battito (30 s), run, nota di arresto (5.0)
data/state/instance.lock un solo bot per cartella dati (5.0)
data/state/paper_state.json il conto del simulatore (solo Paper)
data/models/*.json modelli (livelli 1-3) e stato del bandit
knowledge/*.csv, *.md calibrazione, proposte, registri, insight settimanali
reports/*.csv qualità dati, falsificazione
reports/*.csv qualità dati, falsificazione, bonifica_YYYYMMDD, recupero_<run_id> (5.0)
logs/encelado.log log applicativo (;)
```
+98
View File
@@ -0,0 +1,98 @@
# Encelado in Docker
Aggiornato: 2026-09-23 (5.0). Dalla 5.0 il bot gira **solo** nel container (ADR-0007, ADR-0008): un processo `Encelado.Server` che ospita il motore e serve l'interfaccia web su Kestrel. Per Unraid c'è il template in `deploy/unraid/` (vedi il README lì).
## Immagine
`192.168.30.23:3000/alby96/encelado:<versione>` e `:latest`, sul registro dei container di Gitea. Si costruisce dalla `Dockerfile` alla radice:
```powershell
dotnet msbuild build/Release.proj -t:Docker # :<versione dal tag> e :latest, con i test dentro la build
docker build -t encelado:dev --build-arg GIT_COMMIT=$(git rev-parse --short=12 HEAD) . # a mano
```
Stadi: `build` (restore + compilazione Release), `test` (la suite xunit: se è rossa l'immagine non esiste), `publish` (framework-dependent, non trimmed), `runtime` (`mcr.microsoft.com/dotnet/aspnet:10.0` + `gosu` + `tzdata`). L'immagine porta le etichette OCI `version` e `revision`; commit e data di build sono in Impostazioni ▸ Informazioni.
## Volumi
| Dentro | Contenuto | Su Unraid |
|---|---|---|
| `/config` | `encelado.json`, `strategy.json`, `instruments.json`, `etoro.keys.enc` (chiavi cifrate), file `STOP` per il kill-switch | `/mnt/user/appdata/encelado/config` |
| `/data` | `data/` (ledger, stato, barre, cache, modelli), `knowledge/`, `reports/`, `results/`, `logs/` | `/mnt/user/appdata/encelado/data` |
Al primo avvio il container semina `encelado.json` e `strategy.json` dai valori di fabbrica. Nel container `run.dataPath`, `knowledgePath`, `reportsPath` e `logging.path` vengono rimappati sotto `/data` qualunque cosa dica il file: non c'è niente da cambiare a mano. I file appartengono a `PUID:PGID`.
## Variabili d'ambiente
| Variabile | Default | Effetto |
|---|---|---|
| `ENCELADO_WEB_TOKEN` | vuoto | Token dell'interfaccia. **Senza token il server ascolta solo su localhost del container**: dalla rete non si vede niente. Si inserisce una volta nel browser e resta in un cookie HttpOnly per 30 giorni; l'API accetta anche `Authorization: Bearer <token>`. |
| `ENCELADO_WEB_PORT` | `8080` | Porta di Kestrel. |
| `ETORO_API_KEY`, `ETORO_USER_KEY` | vuoto | Chiavi eToro Public API; hanno la precedenza sul file cifrato. |
| `ENCELADO_KEY_PASSPHRASE` | vuoto | Passphrase del file `etoro.keys.enc` (AES-256-GCM, PBKDF2 200 000 iterazioni) scritto da Impostazioni ▸ Chiavi eToro. Senza passphrase il salvataggio dall'interfaccia è disattivato. |
| `ETORO_ENVIRONMENT` | `demo` | `demo` o `real`. |
| `ENCELADO_EXECUTION_MODE` | `Demo` | `Paper`, `Demo`, `Live`. |
| `ENCELADO_CONFIRM_LIVE` | vuoto | La frase `CONFERMO LIVE`, obbligatoria per `Live` insieme a `run.allowLive = true`. |
| `ENCELADO_AUTOSTART` | `1` | `0` = il motore non parte da solo: si preme AVVIA nell'interfaccia. |
| `ENCELADO_DISPLAY_CURRENCY` | `USD` | Valuta di visualizzazione (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD). Il ledger resta in USD. |
| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | vuoto | Notifiche e comandi Telegram. |
| `TZ` | `UTC` | Fuso degli orari a schermo e nel log (nome IANA, es. `Europe/Rome`); il ledger è UTC. Vale anche come `ui.timeZone` se il file dice `computer`. |
| `PUID`, `PGID` | `99`, `100` | Utente e gruppo del processo e dei volumi (`nobody:users` di Unraid). |
| `ENCELADO_LOG_LEVEL`, `ENCELADO_CONFIG_DIR`, `ENCELADO_DATA_DIR` | — | Livello di log; cartelle alternative (di norma non servono nel container). |
## Avvio, arresto, aggiornamento
```powershell
docker compose up -d # sviluppo locale: cartelle in deploy/local/, segreti in .env
docker compose logs -f encelado
docker compose down # SIGTERM → arresto pulito (45 s di grazia)
docker pull 192.168.30.23:3000/alby96/encelado:latest && docker compose up -d # aggiornamento
```
Su `SIGTERM` il server ferma il motore come da `run.closeOnShutdown` (default `false`: i basket restano sul conto con gli stop nativi), chiude il ledger, scrive l'heartbeat con la nota di arresto ed esce con 0. Al riavvio riprende lo stato salvato, riconcilia il conto e, se è passato più di `recovery.thresholdMinutes`, esegue il recupero dopo inattività (`docs/RUNBOOK.md`).
## Healthcheck
`HEALTHCHECK` ogni 30 s: `dotnet /app/Encelado.Server.dll --health` chiama `GET /api/health` sulla porta configurata ed esce 0 solo se il server risponde, il disco dei dati è scrivibile e, a motore acceso, l'heartbeat è più fresco di 105 s. `docker inspect --format '{{.State.Health.Status}}' encelado` per leggerlo. Il campo JSON di `/api/health` dice quale condizione fallisce.
## Log
Sulla console (stdout, `docker logs`) **e** nel file `/data/logs/encelado.log` (CSV `;`, rotazione per dimensione). L'ora nel log è nel fuso `TZ` con l'offset esplicito; il ledger resta UTC. Il livello si cambia con `ENCELADO_LOG_LEVEL` o da Impostazioni.
## Rete
Il server ascolta su tutte le interfacce **solo** quando `ENCELADO_WEB_TOKEN` è impostato. Non c'è TLS: l'interfaccia è pensata per la rete locale (Unraid) o dietro un reverse proxy che lo aggiunge. Le rotte `/api/health`, `/login`, `/icon.svg` e `/manifest.webmanifest` sono libere; tutto il resto chiede il token.
## Registro privato in HTTP
Il Gitea dell'utente è raggiunto in HTTP: chi fa `docker pull` deve dichiararlo `insecure-registry` (Docker Desktop: *Settings ▸ Docker Engine*`"insecure-registries": ["192.168.30.23:3000"]`; Unraid: vedi `deploy/unraid/README.md`). Il push dalla catena di rilascio (`-t:Rilascia`) fa `docker login` con il token di `build/gitea.json` e `docker logout` subito dopo.
## Gitea Actions
Due workflow in `.gitea/workflows/`, sul modello di Rea:
| Workflow | Quando | Cosa fa |
|---|---|---|
| `ci.yml` | push su `main`, pull request | `dotnet build -warnaserror` e `dotnet test`; poi `docker build` dell'immagine (senza pubblicarla) per accorgersi di una Dockerfile rotta |
| `release.yml` | tag `v*` | costruisce e pubblica `<host>/alby96/encelado:<versione>` e `:latest` sul registro di Gitea (solo `linux/amd64`: la build esegue i test e sotto QEMU sarebbe lentissima), poi crea la release con il template Unraid allegato; se la release esiste già (creata dalla catena) aggiunge solo l'allegato mancante |
Servono due cose che al 2026-09-23 **mancano**:
1. **Un runner.** Sul server non ce n'è nessuno (`/api/v1/repos/Alby96/Encelado/actions/runners` è vuoto): i workflow restano in coda. Il modo più semplice è un container `gitea/act_runner` su Unraid con il socket di Docker montato (`/var/run/docker.sock`), registrato con il token di *Site Administration ▸ Actions ▸ Runners ▸ Create new runner* e con l'etichetta `ubuntu-latest:docker://catthehacker/ubuntu:act-latest`. Con il registro in HTTP il runner (e la sua immagine di lavoro) deve avere `192.168.30.23:3000` fra gli `insecure-registries`.
2. **Il segreto `REGISTRY_TOKEN`** nel repository (*Impostazioni ▸ Actions ▸ Segreti*): un token dell'utente con `package:write` e `repository:write` (lo stesso di `build/gitea.json` va bene).
Finché il runner non c'è, il rilascio si fa dal PC con `dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=X.Y.Z`, che produce lo stesso risultato (immagine sul registro, tag, release con allegati); i due percorsi sono idempotenti fra loro.
## Prove in locale: la sandbox `deploy/local`
`deploy/local/config` e `deploy/local/data` (ignorate da git) sono il `/config` e il `/data` delle prove sul PC, in tutti e tre i modi di avvio:
| Modo | Comando | Note |
|---|---|---|
| F5 in VS Code | `Encelado (server)` | processo locale, `ENCELADO_CONFIG_DIR`/`ENCELADO_DATA_DIR` puntati alla sandbox, browser aperto da solo, motore fermo finché non premi AVVIA (senza token: solo localhost, nessun login) |
| script | `scripts\run-dev.ps1` / `sh scripts/run-dev.sh` (`--sample`, `--autostart`) | come F5 ma da terminale, con i comandi da tastiera |
| container | `scripts\run-docker.ps1` / `sh scripts/run-docker.sh` / `docker compose up -d --build` | l'immagine vera; token `sviluppo` salvo `ENCELADO_WEB_TOKEN` in `.env`; `--down` ferma con SIGTERM |
`ENCELADO_DATA_DIR` vale anche fuori dal container: separa i dati dalla configurazione come fa `/data`. Le chiavi salvate da Impostazioni finiscono in `deploy/local/config/etoro.keys.enc` e valgono per F5 e per il container (stessa `ENCELADO_KEY_PASSPHRASE`). `Encelado (campione)` e `--sample` servono lo snapshot finto: nessuna chiave, per lavorare sulle pagine e fotografarle (`scripts\screenshots.ps1`).
Fuori dalla sandbox, senza variabili, il processo locale usa `Documenti\Encelado`: sconsigliato, è la cartella dell'installazione 4.0.0.
+20 -1
View File
@@ -13,7 +13,20 @@
| **Cost gate** | Il rifiuto di un ingresso se il TP non copre almeno `costMultiple` volte il costo stimato (spread reale + markup + commissioni + overnight atteso), o se lo spread è più del doppio della mediana delle ultime 24 ore. |
| **Break-even** | Il costo in pip oltre il quale il P&L medio lordo di un basket diventa negativo: se è vicino a zero, il segnale non ha contenuto. |
| **Vol-parity sizing** | Le unità di ogni gamba sono inversamente proporzionali alla sua volatilità (ATR), così le due gambe contribuiscono allo stesso rischio; il rischio totale è `riskPerBasketPct` dell'equity alla distanza dello stop. |
| **Leg-risk** | Il rischio di restare con una sola gamba: se la seconda non viene eseguita entro `legTimeoutSec`, la prima viene chiusa subito (`leg_risk_unwind`). |
| **Leg-risk** | Il rischio di restare con una sola gamba: se la seconda viene rifiutata, la prima viene chiusa subito (`leg_risk_unwind`); se la seconda è senza esito, il basket aspetta (`PendingB`) finché il registro degli ordini non sa. |
| **Registro degli ordini** | `OrderTracker` e il file `data/state/pending_orders.json`: ogni ordine inviato, scritto prima della chiamata e seguito finché il server non dice eseguito, rifiutato o annullato, o finché la posizione non compare sul conto. |
| **PendingA / PendingB** | Stati del basket con una gamba senza esito: nessun nuovo ordine, valutazione sospesa, ripresa alla risoluzione. |
| **Orfana-bot** | Una posizione sul conto aperta dal bot (registro o firma nel ledger) che non appartiene a nessun basket: adottata e chiusa. |
| **Esterna** | Una posizione sul conto senza la firma del bot: segnalata, contata, mai toccata. |
| **Movimento di cassa** | Deposito, prelievo o accredito virtuale: un salto del saldo che nessuna chiusura spiega. Escluso dal P&L, dal picco e dal drawdown. |
| **Bonifica** | La pulizia una tantum delle orfane con conferma per posizione (`--bonifica`). |
| **Halted-Residuo** | Lo stato del motore dopo un kill-switch che non è riuscito ad appiattire le posizioni del bot: banner con l'elenco, reset rifiutato finché restano. |
| **Verifica di piattezza** | La rilettura del conto dopo il kill-switch finché le posizioni del bot non sono sparite; niente è «chiuso» prima. |
| **Margin guard** | Sotto equity / margine usato = 1,5 niente entrate; sotto 1,2 si chiude il basket con il P&L peggiore. |
| **sizing_bound** | Quale vincolo ha deciso la size di un basket: `risk`, `margin`, `leverage`, `units`; `min_exposure` quando nessuna size è ammessa. |
| **Heartbeat** | `data/state/heartbeat.json`, scritto ogni 30 s: misura l'inattività che fa scattare il recupero. |
| **Recupero** | La procedura dopo un'inattività oltre la soglia: ordini pendenti, riconciliazione, barre perse, ogni basket aperto rivalutato (chiudi/tieni), rapporto, riscaldamento. |
| **Lock di istanza** | `data/state/instance.lock`, tenuto in esclusiva: un solo bot per cartella dati. |
| **Equity stop** | Chiusura di tutto e blocco a un drawdown del 9 % dal picco; riparte solo con un reset motivato. |
| **Kill-switch** | Chiusura immediata di tutto e blocco delle nuove entrate: pulsante, comando o file `STOP`. |
| **Paper / Demo / Live** | Simulatore locale / conto demo eToro / conto reale. Il bot opera da solo in tutte e tre (D-20). |
@@ -30,3 +43,9 @@
| **PSI** | Population Stability Index: misura la deriva della distribuzione di una feature rispetto all'addestramento. |
| **HAR-RV** | Modello eterogeneo autoregressivo della varianza realizzata (medie a 1, 5, 22 giorni). |
| **Run id** | Identificatore della sessione del bot, scritto in ogni riga del ledger con l'hash di `strategy.json`. |
| **Snapshot** | La fotografia immutabile dello stato del motore (conto, basket, contatori, contesto) che il server serializza in JSON e manda alla pagina una volta al secondo. La UI non ha altro. |
| **SSE** | Server-Sent Events: la rotta `/api/stream` che spinge uno snapshot al secondo al browser. |
| **Token web** | `ENCELADO_WEB_TOKEN`: la stringa che apre l'interfaccia; senza, il server ascolta solo su localhost. |
| **Valuta di visualizzazione** | La valuta con cui la pagina mostra gli importi (`ui.displayCurrency`); conversione solo in presentazione con il tasso delle quotazioni, ledger sempre in USD. |
| **Modalità campione** | `--sample`: il server serve uno snapshot finto e non avvia il motore; per lavorare sulle pagine e fotografarle. |
| **Navigation rail** | La colonna di navigazione a sinistra (Dashboard, Storico ordini, Log, Impostazioni), 80 o 256 px. |
+18 -6
View File
@@ -1,12 +1,13 @@
# Problemi noti e limiti
Aggiornato: 2026-09-16. Una voce per limite, con lo stato. Quando un limite viene rimosso, la voce si sposta nel `CHANGELOG.md`.
Aggiornato: 2026-09-23. Una voce per limite, con lo stato. Quando un limite viene rimosso, la voce si sposta nel `CHANGELOG.md`.
## Strategia
- **Il backtest è negativo.** Su 7,75 anni di barre M15 nessuna configurazione della griglia è profittevole al netto dei costi assunti; il break-even è vicino a zero, cioè il segnale non ha contenuto misurabile (`docs/STRATEGY.md`). Il modulo resta uno strumento di forward test in Demo, non un sistema da mettere sul reale.
- **EURAUD** ha tick solo per parti del 2018, 2021 e 2026: il basket EURAUD/AUDCAD è misurabile in backtest solo su quei tratti.
- Il backtest non ha calendario né notizie: blackout e sentiment sono attivi solo dal vivo. L'effetto del blackout sui risultati non è misurato.
- **Il cancello di correlazione ρ_W ≤ 0,6 si apre di rado sulle barre M15**: nelle 4 ore di Demo del 2026-09-16 31 segnali su 31 sono stati rifiutati per quel solo motivo. Non è un bug: è la soglia della specifica; va misurata sul ledger prima di proporre un valore diverso (`knowledge/proposals.csv`).
- I costi del backtest sono assunzioni (spread tipici pubblicati o spread dei tick, overnight 0,3 o 0,9 pip/gamba/giorno). Il costo vero si misura solo nel ledger del Demo.
## eToro
@@ -14,7 +15,9 @@ Aggiornato: 2026-09-16. Una voce per limite, con lo stato. Quando un limite vien
- L'endpoint delle candele non pagina: al massimo ~10 giorni di M15. Lo storico dipende dai tick forniti dall'utente.
- L'API demo mostra spread di mercato di 0,1-0,7 pip senza markup e un overnight di 0,91 USD/giorno per 10 000 EURUSD. Se l'esecuzione reale applica uno spread diverso, lo si vedrà dallo slippage scritto nel ledger a ogni ingresso.
- Il campo dei costi si chiama `value` (non `amount`, come si era scritto in prima battuta): corretto il 2026-09-16 pomeriggio; le righe del ledger della mattina hanno `markupA/B = 0` e `overnight` nullo per questo motivo.
- Il conto reale dell'utente vale 193,18 USD: con l'esposizione minima di 1000 USD per gamba il Live non è praticabile a prescindere dai cancelli.
- Il conto reale dell'utente vale 224,90 USD (2026-09-23): con l'esposizione minima di 1000 USD per gamba il Live non è praticabile a prescindere dai cancelli.
- **Il server non registra l'`x-request-id` come `referenceId`** degli ordini v2: il lookup per riferimento risponde 404 anche per ordini eseguiti. Dalla 5.0 l'esito si legge per `orderId`; il riferimento resta solo come ripiego (ADR-0009).
- **A margine esaurito il server riduce l'ordine** a un importo fisso (2 000 USD di margine osservati il 16-21/9) invece di rifiutarlo; la regola non è documentata nell'OpenAPI. Il bot registra `unita_richieste` e `unita_eseguite` in `orders.jsonl` e dimensiona la gamba B sulle unità eseguite di A; i limiti di margine della Fase 2 evitano di arrivarci.
## Feed
@@ -24,13 +27,22 @@ Aggiornato: 2026-09-16. Una voce per limite, con lo stato. Quando un limite vien
## Bot
- **Una sola istanza** per cartella di lavoro: non c'è un lock; due bot sullo stesso conto si contendono le posizioni. Documentato nel runbook, non imposto dal codice.
- Le posizioni salvate in `baskets_state.json` da una modalità diversa non vengono riprese (si riparte dalla riconciliazione del conto).
- Un movimento di cassa viene riconosciuto dal salto del saldo (oltre 10 USD e 0,25 %): un accredito piccolo sotto quella soglia passa per rumore; un prelievo che coincide con una chiusura viene distinto solo se lo storico del conto risponde.
- Telegram non avvisa dei feed (calendario, RSS) in errore da più di un'ora: il provider dei feed non espone l'ora dell'ultimo successo; il riquadro «Telegram» della dashboard mostra solo lo stato del canale.
- Le sezioni `risk`, `recovery` e `learning` di `strategy.json` non compaiono nel form di Impostazioni (si modificano nel file, come tutto il resto della strategia); Impostazioni ▸ Ricerca mostra lo stato dell'apprendimento in sola lettura.
- Il ciclo settimanale gira solo mentre il bot è acceso la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni).
- La finestra e l'headless usano lo stesso log e lo stesso ledger: se si avviano insieme le righe si mescolano.
- Un movimento di cassa del demo che coincide con un'esecuzione ridotta dal server (il caso del 16-21/9) viene riconosciuto solo se il salto del saldo non è spiegato dalle chiusure: il matching è per differenza, non per una rotta dell'API.
## Container e interfaccia web
- Il server non ha TLS: l'interfaccia è pensata per la rete locale (Unraid) o dietro un reverse proxy che lo aggiunge. Senza `ENCELADO_WEB_TOKEN` ascolta solo su localhost del container.
- Il registro dei container di Gitea è in HTTP: chi fa `docker pull` deve dichiararlo `insecure-registry` (Docker Desktop e Unraid, vedi `docs/DOCKER.md`).
- Il template Unraid non è pubblicato in un repository di template della community: si installa dall'URL raw del file (`deploy/unraid/README.md`).
- La valuta di visualizzazione usa il tasso della quotazione più recente ricevuta dal polling; con il mercato chiuso il tasso invecchia e il tooltip lo dice.
## Codice
- `BasketEngine.cs` è un file unico di ~1900 righe: funziona, ma un intervento vi costa più di quanto dovrebbe. Da spezzare (quote poller, riconciliazione, snapshot) in una sessione dedicata.
- I test dell'interfaccia rendono le pagine in memoria (`UiRenderTests`, con `ENCELADO_RENDER_DIR`), non il comportamento della finestra vera (dialoghi, timer).
- `BasketEngine` è spezzato in sei file parziali dalla 5.0; il file principale resta di ~900 righe (loop, decisioni, esecuzione).
- I test dell'interfaccia coprono l'HTML incorporato, il JSON dello snapshot, il token e lo stream (`EmbeddedUiTests`, `WebHostTests`), non il comportamento della pagina nel browser (JavaScript, dialoghi): quello si controlla a occhio con `--sample` e con gli screenshot in `docs/img/`.
- Il test (l) copre i blocchi nel decisore, non la simulazione completa dell'equity stop nel motore live; quella è coperta dal backtest (`EquityStops` in `BacktestResult`) e dal ledger.
+44 -4
View File
@@ -16,7 +16,7 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15
| `cross` | testo | cross sintetico (`EURCHF`) |
| `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` |
| `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` |
| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione` |
| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; dalla 5.0 anche le righe di evento (senza feature, solo `ts`, `run_id`, `evento`, `basket_id`, campi propri e `motivazione`): `pending` (gamba senza esito, con `leg`, `client_ref`, `order_id`), `pending_risolto` (con `esito`, `fonte`, `position_id`), `orfana_adottata` e `orfana_chiusa` (con `position_id`, `strumento`, `pnl`, `exit_reason`), `movimento_di_cassa` (con `importo`, `saldo_prima`, `saldo_dopo`, `chiusure_nel_frattempo`, `cassa_cumulata`), `margin_guard` (con `equity`, `margine_usato`), `kill_switch_avviato`, `kill_switch_concluso` (con `stato` = `Halted` o `Halted-Residuo`, `chiuse`, `residuo`, `annullati`, `pnl`), `residuo_chiuso`, `reset_rifiutato`, `reset_concluso`, `recupero_avviato` (con `inattivita_min`, `barre`), `recupero_concluso` (con `inattivita_min`, `barre`, `chiusi`, `tenuti`), `comando` (ogni comando dalla finestra, dalla console o da Telegram: `comando`, `argomento`, `ok`; la motivazione porta l'origine e la prima riga dell'esito) |
| `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` |
| `buy_cross` | bool | verso deciso (compra il cross = compra entrambe le gambe nei cinque basket) |
| `z`, `z_in_eff` | numero | z-score del cross e soglia effettiva (scalata dalla vol prevista) |
@@ -36,8 +36,9 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15
| `p_ML`, `mlActive` | numero, bool | probabilità del meta-modello e se è gate o ombra |
| `equity`, `openBaskets` | numero | equity e basket aperti al momento |
| `priceA`, `priceB`, `pipsOpen`, `pnlOpenUsd`, `barsHeld` | numero | stato della posizione (se aperta) |
| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage` | numero | sizing (solo su `Enter`) |
| `reasonCodes` | array | codici: `no_signal`, `rho_low`, `rho_short_low`, `half_life`, `blackout_before`, `blackout_after`, `weekend`, `just_opened`, `session`, `max_baskets`, `same_cross`, `ml_gate`, `cost_gate`, `sizing`, `kill_switch`, `equity_stop`, `daily_loss`, `entries_blocked`, `data_quality`, `warmup`, `not_bar_close`, `enter`, `add`, `hold`, `tp_pips`, `tp_z`, `stop_z`, `stop_max_loss`, `spread_anomaly`, `time_stop`, `rho_break` |
| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage`, `marginUsd` | numero | sizing (solo su `Enter`); `marginUsd` = margine previsto delle due gambe (5.0) |
| `sizing_bound` | testo | quale vincolo ha deciso la size (5.0): `risk`, `margin`, `leverage`, `units` |
| `reasonCodes` | array | codici: `no_signal`, `rho_low`, `rho_short_low`, `half_life`, `blackout_before`, `blackout_after`, `weekend`, `just_opened`, `session`, `max_baskets`, `same_cross`, `ml_gate`, `cost_gate`, `sizing`, `kill_switch`, `equity_stop`, `daily_loss`, `entries_blocked`, `data_quality`, `warmup`, `not_bar_close`, `enter`, `add`, `hold`, `tp_pips`, `tp_z`, `stop_z`, `stop_max_loss`, `spread_anomaly`, `time_stop`, `rho_break`; dalla 5.0 `margin` (nessun margine per un nuovo basket), `margin_guard` (equity / margine usato sotto la soglia), `min_exposure` (la size a margine non raggiunge l'esposizione minima) |
| `motivazione` | testo | la frase, in italiano, con i numeri |
## `data/ledger/baskets.csv`
@@ -48,7 +49,46 @@ Una riga per basket chiuso. `label = 1` se `pnl_net_usd > 0`, altrimenti 0: è l
basket_id;run_id;basket;mode;preset;opened_utc;closed_utc;buy_cross;entry_z;exit_z;pnl_gross_usd;pnl_net_usd;pips_gross;cost_pips;cost_usd;slippage_pips;adds;bars_held;exit_reason;equity_at_entry;p_ml_at_entry;label;durata_min;motivazione
```
`pips_gross` è la somma dei pip delle due gambe ai prezzi di esecuzione (la colonna "Pips" della UI), `cost_pips` il costo stimato all'ingresso, `slippage_pips` la differenza fra quotazione vista e prezzo eseguito sommata sulle gambe, `exit_reason` uno dei codici sopra più `manual`, `closed_by_broker`, `leg_closed_by_broker`, `end_of_data`.
`pips_gross` è la somma dei pip delle due gambe ai prezzi di esecuzione (la colonna "Pips" della UI), `cost_pips` il costo stimato all'ingresso, `slippage_pips` la differenza fra quotazione vista e prezzo eseguito sommata sulle gambe, `exit_reason` uno dei codici sopra più `manual`, `closed_by_broker`, `leg_closed_by_broker`, `end_of_data`, e dalla 5.0 `leg_risk_unwind` (gamba A eseguita in ritardo e richiusa, o B non coperta dal margine), `margin_guard` (chiuso dalla guardia del margine), `orphan_closed` (gamba orfana del bot adottata e chiusa alla riconciliazione), `bonifica_orfana` (chiusa dalla bonifica con conferma), `kill_switch`/`equity_stop` anche per le gambe singole. Le righe di una gamba singola hanno `entry_z`, `exit_z`, `pips_gross` e `cost_*` vuoti e `buy_cross` = verso della gamba.
## `data/ledger/orders.jsonl` (dalla 5.0)
Una riga per **ogni ordine inviato** e per **ogni cambio del suo stato** (append-only): la prima riga di un `client_ref` dice cosa è stato chiesto, l'ultima come è finita. Scritta dal registro degli ordini (`OrderTracker`) attraverso il ledger.
| Campo | Significato |
|---|---|
| `ts`, `run_id`, `mode` | come in `decisions.jsonl` |
| `basket`, `basket_id` | slot (`EURUSD/USDCHF`) e istanza (`B2026…-EURUSDUSDCHF`) |
| `strumento`, `instrument_id`, `verso` | la gamba; `verso` = `long`/`short` dell'ordine (per una chiusura è il verso opposto alla posizione) |
| `leg` | `A`, `B`, `Add`, `Close`, `Unwind` |
| `unita_richieste`, `unita_eseguite` | differiscono quando il server riduce l'ordine (osservato il 2026-09-16) |
| `prezzo_richiesto`, `prezzo_eseguito`, `slippage_pip` | quotazione vista all'invio, prezzo del server, differenza in pip con il segno del costo |
| `stato`, `stato_id` | l'ultima parola del server (`Submitted`, `Received`, `Placed`, `Filled`, `Rejected`, …, `Unknown` quando non ha risposto), con l'id numerico di eToro |
| `esito` | `Pending`, `Filled`, `Rejected`, `Cancelled` |
| `order_id`, `position_id`, `client_ref` | le tre chiavi |
| `fee` | commissioni riportate dal server all'esecuzione |
| `evento` | `inviato`, `stato`, `risolto` |
| `motivazione` | la motivazione della decisione o l'errore del server |
## `data/state/pending_orders.json` (dalla 5.0)
Il registro degli ordini: `savedUtc` e l'array `orders` con gli stessi campi di `orders.jsonl` più `checks`, `lastCheckUtc`, `source` (`venue`, `lookup`, `lookup-v1`, `positions`). Contiene tutti gli ordini senza esito e quelli risolti nelle ultime 48 ore (servono a riconoscere una posizione come propria). Scritto **prima** di ogni chiamata HTTP e a ogni cambio di stato, con `.tmp` + `File.Move`. Un file illeggibile viene messo da parte come `pending_orders.json.illeggibile-<data>`. In modalità Paper il file è `pending_orders_paper.json`.
## `data/state/baskets_state.json` (campi aggiunti dalla 5.0)
`peakNetEquity` (picco dell'equity al netto dei movimenti di cassa), `cumulativeCashFlow`, `lastBalance` e `lastBalanceUtc` (per riconoscere un deposito avvenuto a bot spento), `pendingEntries` (un elemento per basket in `PendingA`/`PendingB`: `name`, `state`, `basketId` e il piano `pending` con unità, TP, stop, riferimenti cliente e la gamba A eseguita), `haltResidue` (le posizioni del bot rimaste sul conto dopo un kill-switch: `positionId`, `symbol`, `isBuy`, `units`, `openedUtc`, `origin`, `basket`, `reason`). `peakEquity` resta per compatibilità e vale `peakNetEquity + cumulativeCashFlow`.
## `data/state/heartbeat.json` e `data/state/instance.lock` (dalla 5.0)
`heartbeat.json`: `{utc, run_id, mode, openBaskets, pendingBaskets, pid, note}` scritto ogni `recovery.heartbeatSeconds` (30), all'avvio (`note = avvio`) e all'arresto (`arresto`), con `.tmp` + `File.Move`. Il tempo trascorso dall'ultimo `utc` è l'inattività che fa scattare il recupero. `instance.lock`: `{pid, runId, sinceUtc, machine}`, tenuto aperto in esclusiva dal motore per tutta la sessione; non va cancellato a mano.
## `reports/recupero_<run_id>.csv` (dalla 5.0)
Una riga per posizione trovata dal recupero: `posizione;basket;decisione;motivo;z;rho;barsHeld;pnl;motivazione`, con `decisione` ∈ {`chiudi`, `tieni`, `rapporto`} e `motivo` = codice di uscita del decisore (`stop_z`, `stop_max_loss`, `time_stop`, `rho_break`, `spread_anomaly`…), `entro le soglie`, `orfana_bot`, `esterna`.
## `reports/bonifica_YYYYMMDD.csv` (dalla 5.0)
Una riga per orfana chiusa dalla bonifica: `ts;position_id;strumento;verso;unita;aperta_utc;pnl_realizzato;basket;motivazione`.
## `results/trials.csv`
+30 -7
View File
@@ -1,8 +1,24 @@
# Apprendimento: livelli 0-3
Aggiornato: 2026-09-16. Tutto è costruito da zero nel Core (`src/Encelado.Core/Baskets/Learning/`), senza pacchetti: regressione logistica online, un MLP a 16 unità ReLU con Adam, un bandit di Thompson, due previsori di volatilità e un indice di stabilità (PSI). Il codice del Bot che li usa a runtime è `src/Encelado.Bot/Baskets/LearningState.cs`.
Aggiornato: 2026-09-23 (ADR-0006, 5.0). Tutto è costruito da zero nel Core (`src/Encelado.Core/Baskets/Learning/`), senza pacchetti: regressione logistica online, un MLP a 16 unità ReLU con Adam, un bandit di Thompson, due previsori di volatilità. Il codice del motore che li usa a runtime è `src/Encelado.Engine/Baskets/LearningState.cs`; lo stesso ciclo gira offline con `backtest learn`.
**Regola che governa tutto**: nessun livello cambia un parametro live da solo. Il meta-modello può soltanto *rifiutare* un ingresso quando è attivo; il bandit *propone* un preset e lo applica solo in Paper/Demo; tutto il resto finisce in `knowledge/proposals.csv` e passa dal forward test pre-registrato.
**Regola che governa tutto**: nessun livello cambia un parametro live da solo. Il meta-modello può soltanto *rifiutare* un ingresso quando è attivo; il bandit *propone* un preset e **non lo applica** (dalla 5.0; fino alla 4.0.0 lo applicava in Paper e Demo); tutto il resto finisce in `knowledge/proposals.csv` e passa dal forward test pre-registrato.
## Stato dalla 5.0 (ADR-0006): quasi tutto in ombra
`strategy.json``learning` è `{enabled: false, weeklyCycle: false, challenger: false}` di fabbrica. Con `enabled = false`:
| Componente | Runtime |
|---|---|
| Ledger, `orders.jsonl`, `baskets.csv` | scritti sempre: sono il dato |
| Livello 0 (calibrazione) | generato dal ciclo, quindi fermo finché il ciclo non gira (`backtest learn`, o «Esegui ciclo di apprendimento» in Impostazioni ▸ Ricerca) |
| Previsione di volatilità | attiva, deterministica, usata da `z_in` effettivo |
| Logistica (livello 1) | **in ombra**: `p_ML` a ogni chiusura di barra nel ledger, apprendimento a ogni chiusura di basket; mai un cancello (`mlActive = false`) |
| MLP challenger (livello 2) | non addestrato |
| Bandit (livello 3) | riceve i premi e **propone** nel log; non applica |
| Ciclo settimanale | non gira nel bot; passa a `backtest learn` o al pulsante di Impostazioni ▸ Ricerca |
**Criterio di riattivazione** (`learning.enabled = true`, scritto dall'operatore, mai dal bot): almeno **300 basket chiusi in Demo** *e* **P&L netto forward ≥ 0** sulla metrica pre-registrata in `knowledge/preregistrazione.csv`. Prima di allora qualunque «adattamento automatico» è rumore: il meta-modello può solo ridurre le perdite di una strategia che il backtest dice non reggere i costi, e a poche entrate al giorno i 300 basket sono mesi. Motivazione completa in `docs/adr/ADR-0006-apprendimento-in-ombra.md`.
## Il dataset
@@ -55,19 +71,26 @@ Promozione a campione: solo se batte la logistica di almeno 0,01 di AUC walk-for
## Livello 3 — Bandit sui preset
`ThompsonBandit`: una Beta(α, β) per braccio = preset × terzile di volatilità prevista (3 × 3). A ogni chiusura il braccio usato riceve 1 se il basket è positivo. La proposta campiona dalle posteriori con un **tetto del 10 %** alle scelte esplorative (`ExplorationCap`; test `TheBanditKeepsExplorationUnderTheCap`). In Paper e Demo la proposta viene applicata a caldo (i basket aperti non vengono toccati) e scritta nel ledger come correzione; in Live mai.
`ThompsonBandit`: una Beta(α, β) per braccio = preset × terzile di volatilità prevista (3 × 3). A ogni chiusura il braccio usato riceve 1 se il basket è positivo. La proposta campiona dalle posteriori con un **tetto del 10 %** alle scelte esplorative (`ExplorationCap`; test `TheBanditKeepsExplorationUnderTheCap`). Dalla 5.0 la proposta viene scritta nel log e in `knowledge/proposals.csv` e **non viene applicata** in nessuna modalità (D-30); fino alla 4.0.0 in Paper e Demo cambiava il preset a caldo.
## Previsione della volatilità
`VolForecaster`: sui rendimenti a 15 minuti del cross calcola la varianza realizzata giornaliera e mantiene due previsori a 1-4 ore, **EWMA** (span 100) e **HAR-RV** (OLS sulle medie a 1, 5 e 22 giorni, rifittato ogni giorno). Ogni giorno confronta l'errore quadratico delle due previsioni sulla finestra mobile e usa quello migliore (`ActiveModel`). Il rapporto σ prevista / σ media 30 giorni scala la soglia `z_in` (`volScaleZIn`) e la size, ed è la feature `vol_ratio`.
## Deriva (PSI)
## Deriva
`Psi.Compute` confronta la distribuzione di ogni feature nelle ultime 50 decisioni con quella del dataset di addestramento (10 bin). Sopra 0,25 la feature è in deriva; con tre feature in deriva il meta-modello, se attivo, torna in ombra fino al ciclo successivo. Il ciclo settimanale scrive il PSI nel file degli insight.
L'indice di stabilità (PSI) previsto dalla specifica non è collegato a niente: il codice è stato rimosso nella pulizia della 5.0 (era una classe mai chiamata). Quando il meta-modello sarà attivabile, il controllo della deriva va reintrodotto insieme al criterio di ritorno in ombra, non prima.
## Ciclo settimanale
## Ciclo settimanale e comando `learn`
`LearningState.RunCycle`, la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni):
`LearningState.RunCycle` — nel bot solo con `learning.weeklyCycle = true` (la domenica dopo le 10 UTC o al primo avvio dopo sette giorni); altrimenti a mano, dallo strumento o dall'interfaccia:
```powershell
dotnet run --project tools/Encelado.Backtest -- learn --data <cartella data del bot> [--knowledge <cartella>] [--strategy config/strategy.json]
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data" -p:Comando=learn
```
`--data` è la cartella che contiene `ledger/` (una copia di `/data/data` del container va benissimo); `--knowledge` è dove scrivere (default: `knowledge` accanto ai dati). Lo strumento stampa quante righe ha il dataset e lo stato del modello dopo il ciclo. Il pulsante «Esegui ciclo di apprendimento» in Impostazioni ▸ Ricerca fa la stessa cosa sui dati del container. Il ciclo:
1. ricostruisce il dataset dal ledger;
2. valuta e riaddestra logistica (walk-forward) e MLP (fold purgati);
+74
View File
@@ -0,0 +1,74 @@
# Piano 5.0 — valutazione, ordine e stima
Scritto: 2026-09-23 (Fase 0 del prompt "Encelado 5.0"). **Stato al 2026-09-23 sera: tutte le fasi 0-9 sono eseguite e committate; resta il rilascio 5.0.0 (decisione dell'utente).** Ogni punto del prompt è valutato come **fattibile**, **da chiarire** (con la domanda in `docs/QUESTIONS.md`) o **da rifiutare** (con il motivo). Le stime sono in sessioni di lavoro; una sessione è mezza giornata con verifica verde e commit.
## 0. Verifica della diagnosi preliminare
Verificata il 2026-09-23 sul codice, sul conto demo via API (sola lettura) e sui file di questa macchina.
| Affermazione del prompt | Esito |
|---|---|
| `EtoroBroker.OpenAsync` legge `orderId` e non lo usa più; il polling passa solo da `orders:lookup?referenceId=` | **Confermato** (`src/Encelado.Etoro/EtoroBroker.cs`, metodo `OpenAsync` e `LookupOrderAsync`). |
| Il lookup per `referenceId` risponde 404 | **Confermato e spiegato**: `GET api/v1/trading/info/demo/orders/381739181` (uno degli ordini del bot) risponde `referenceID: 00000000-0000-0000-0000-000000000000`. Per gli ordini v2 il server **non ha registrato** l'`x-request-id` come riferimento, quindi la ricerca per riferimento non può trovarli. La ricerca per `orderId` funziona (verificata su due ordini, v1 e `orders:lookup?orderId=`). |
| `BasketExecutor.SendAsync` ripete il lookup e dichiara la gamba «non eseguita»; il basket è rifiutato e l'ordine resta sul server | **Confermato** nel codice. Dallo storico del conto demo: **21 posizioni forex del bot** fra il 16/9 19:00 e il 21/9 10:15 UTC (EURUSD 1, USDCAD 1, AUDUSD 4, NZDUSD 3, EURAUD 12), tutte gambe singole, tutte chiuse il **21/9 alle 13:26 UTC** in blocco (chiusura manuale, non del bot: il kill-switch non avrebbe trovato slot da chiudere). P&L netto complessivo di quelle 21 gambe: **+289,05 USD**, per fortuna e non per merito. |
| `ReconcileAsync` segnala le posizioni sconosciute una volta e non le tocca; il contatore conta gli slot | **Confermato**. |
| `KillAsync → CloseAllAsync` chiude solo gli slot con `Position` | **Confermato**. |
| Il ledger 16-22/9 ha 65 `segnale_ingresso` e 65 `rifiuto` | **Non verificabile qui**: su questa macchina `Documenti\Encelado\data\ledger\decisions.jsonl` ha 112 righe del solo 16/9 (mattina e pomeriggio, 110 `skip` e 3 `correzione`). Le sessioni dal 16/9 sera in poi hanno girato altrove (installazione 4.0.0 su un'altra macchina o un altro profilo). Chiesto in D-36. |
| «Depositi» = movimenti di cassa del conto virtuale, non operazioni del bot | **Confermato per esclusione**: nessuna rotta di deposito o trasferimento è chiamata dal codice; le rotte di trasferimento dell'API (`api/v1/money/transfers`) non compaiono nel client. La cifra tonda (+30 000) è coerente con un accredito demo. |
| Margine: un basket Aggressive ha impegnato tutto il margine | **Confermato e precisato**: i primi due ordini eseguiti (16/9 19:00:48 EURUSD short 547 214,68 unità, 62 792 USD di margine; 19:00:58 USDCAD long 464 358,5 unità, 46 436 USD) sommano 109 228 USD di margine = l'intera equity, cioè 1 092 281 USD di nozionale a leva 10. Sono due gambe A di due basket diversi (EURUSD/USDCHF e USDCAD/EURUSD), ciascuna sizata al tetto `maxEffectiveLeverage = 10` sul nozionale del basket. **Fatto nuovo**: dal terzo ordine in poi eToro ha eseguito ogni ordine a **esattamente 2 000 USD di margine** (`requestedAmount 2000`, `frozenAmount 2000`, unità con sei decimali ricalcolate dal server, mentre il bot manda unità a due decimali): il server ha ridotto gli ordini a un importo fisso perché il margine era esaurito. Conseguenze: (1) le unità eseguite possono differire molto da quelle richieste, quindi la riconciliazione per «unità ± 1 %» è solo un indizio, non la chiave: la chiave è l'`orderId`; (2) i limiti di margine di §10 sono indispensabili. |
## 1. Valutazione punto per punto
| § | Contenuto | Valutazione | Note |
|---|---|---|---|
| 1 | Scheda Storico (ordini, posizioni, profitti per periodo, export CSV) | **fattibile** | Modulo puro `Core/Baskets/History` + endpoint web. La vista dipende da §11 (web UI). Fonte del realizzato: `api/v1/trading/info/trade/{demo/}history` (già usato), da riconciliare con `baskets.csv` e `orders.jsonl`. |
| 2 | Navigation rail M3 a sinistra | **fattibile** in web (§11); in WPF sarebbe lavoro perso | Da fare dopo D-28. |
| 3 | Versione fuori dalla dashboard, Informazioni e Diagnostica | **fattibile** | Banale in web; in WPF vale la pena solo se D-28 è «no». |
| 4 | Valuta di visualizzazione | **fattibile** | Tassi dalle quote già in polling: EURUSD (1), USDCHF (6), AUDUSD (7), USDCAD (4), NZDUSD (3) ci sono; **GBP e JPY no**: servono GBPUSD e USDJPY nel polling (2 strumenti in più, stessa richiesta). Conversione solo in presentazione. |
| 5 | OrderTracker, lookup per orderId, stati Pending, orfane, contatori, picco netto dei movimenti di cassa, bonifica | **fattibile, prioritario** | Endpoint verificati (D-26, D-27). Il matching per posizioni usa strumento + verso + finestra temporale; le unità solo come conferma (vedi §0). Movimenti di cassa: il conto demo non compare in `api/v1/balances` (solo i conti reali), quindi si riconoscono dal salto del saldo non spiegato dalle chiusure (`CashFlowDetector`), in Demo e in Live allo stesso modo. |
| 6 | Recupero dopo inattività, heartbeat | **fattibile** | Riusa riconciliazione, riscaldamento e decisore già esistenti. |
| 7 | Telegram | **fattibile** | Solo `HttpClient`; token e chatId da ambiente (D-31). Un solo task di long polling. |
| 8 | Apprendimento: disattivare MLP, bandit, ciclo settimanale; spostarli nello strumento | **fattibile**, decisione D-30 | Il bandit oggi **applica** il preset in Demo (`ObserveOutcome`): va spento comunque, è un cambio di parametro automatico che contraddice la regola «nessun parametro cambiato dal bot». |
| 9 | Kill-switch reale e ripristino guidato | **fattibile, prioritario** | Endpoint di cancellazione esiste: `DELETE api/v2/trading/execution/{demo/}orders/{orderId}` (200 = richiesta accettata, esito da confermare con lookup: 7 o 9 annullato, 6 in corso). |
| 10 | Margine: soglie, buffer, sizing = min(rischio, margine), ordine per \|z\|, margin guard | **fattibile, prioritario** | In Demo `available` viene da `pnl` (`credit + bonusCredit`), in Live idem; `api/v1/balances` aggiunge `totalUsedMargin`/`currentPNL` solo per il reale. |
| 11 | UI web M3 servita dal bot, ritiro di WPF | **da chiarire (D-28)** | Tecnicamente fattibile senza NuGet (`Microsoft.AspNetCore.App` è un framework reference). È la scelta più impattante del prompt: cambia progetti, installer, test. Si fa dopo la conferma. |
| 12 | Engine/Server, Docker, IKeyStore | **da chiarire (D-28, D-29)** | Dipende da §11. Il `Dockerfile` multi-stage e l'`entrypoint` con PUID/PGID sono standard. |
| 13 | Template Unraid | **da chiarire (D-29)** | Serve il registry e l'URL dell'icona. |
| 14 | Documentazione, ADR, skill di progetto | **fattibile** | Le skill in `.claude/skills/` vanno bene; `encelado-diagnose` legge i file nuovi. |
| 15 | Domande | poste in `docs/QUESTIONS.md` (D-26…D-37) | D-26 e D-27 già risposte via API. |
### Punti da rifiutare o ridimensionare (con motivo)
- **§5.2 «polling per orderId e in parallelo per referenceId»**: il riferimento non è registrato dal server per gli ordini v2 (vedi §0). Si interroga **per `orderId`**, e per riferimento solo come ripiego quando la risposta al `POST` è andata persa (timeout di rete) — che è l'unico caso in cui l'`orderId` non c'è. Non «in parallelo»: la quota dei lookup è 60/min condivisa con l'esito delle chiusure, e due lookup ogni 400 ms per gamba la esaurirebbero in un minuto con tre basket.
- **§5.4 «unità ± 1 %» come criterio di adozione**: eToro può ridurre l'ordine (vedi §0), quindi le unità non sono affidabili. Criterio applicato: `orderId`/`positionId` nel registro (certo) → strumento + verso + orario ± 90 s coerenti con un ordine del registro o con una riga `segnale_ingresso`/`rifiuto`/`pending` del ledger (probabile; le unità ± 1 % alzano la confidenza ma non sono richieste). Le posizioni «probabili» sono comunque orfane-bot: sul conto demo non c'è altro che operi sul forex a leva 10 con quegli orari di chiusura di barra.
- **§8 bandit in Demo**: già oggi cambia il preset da solo (`ObserveOutcome``SetPreset`). Non è «da tenere in ombra»: è da spegnere del tutto a runtime, perché è un cambio automatico di parametro.
- **§11 test (aa) «UiRenderTests sostituiti»**: i test di rendering WPF restano finché esiste WPF; vengono rimossi con il progetto (Fase 6-7), non prima.
## 2. Ordine di esecuzione e criteri
Invariato rispetto a §16 del prompt, con una precisazione: le Fasi 1-3 non dipendono da nessuna domanda aperta (D-26/27 sono verificate, D-32/34/35 hanno default che sono parametri) e vengono eseguite subito; le Fasi 4-5 nemmeno; le Fasi 6-9 dipendono da D-28/D-29 e aspettano la risposta.
| Fase | Contenuto | Stima | Dipendenze |
|---|---|---|---|
| 0 | questo piano, post-mortem, domande | 0,5 | — |
| 1 | §5: `OrderTracker` + `pending_orders.json` + `orders.jsonl`; lookup per `orderId` con matching sulle posizioni; stati `PendingA/PendingB`; classificazione `basket / orfana-bot / esterna` e chiusura delle orfane; contatori e P&L del conto nello snapshot; picco al netto dei movimenti di cassa; comando `bonifica`; spezzare `BasketEngine.cs` in file parziali (riconciliazione, stato, comandi) | 2 | — |
| 2 | §10: sezione `risk` in `strategy.json`, sizing a margine, ricontrollo di `available` prima di B, ordine per \|z\|, margin guard, `sizing_bound` nel ledger | 1 | Fase 1 |
| 3 | §9: kill-switch con cancellazione, chiusura di basket + orfane, esterne opzionali, verifica di piattezza, `Halted-Residuo`; procedura di ripristino in cinque passi nel motore (la finestra WPF la usa come oggi, la web UI la mostrerà passo per passo) | 1 | Fasi 1-2 |
| 4 | §6: heartbeat, `recovery` in `strategy.json`, procedura di recupero, lock di istanza, rapporto `recupero_<run_id>.csv` | 1 | Fasi 1-3 |
| 5 | §7: `INotifier`, `TelegramNotifier`, stato orario, eventi, riepilogo, comandi | 1 | Fase 4 |
| 6 | §12: `Encelado.Engine`, `Encelado.Server`, Kestrel, SSE, API JSON, `IKeyStore` | 2 | **D-28** |
| 7 | §11 + §1-4 + §8: UI web M3, Storico, valuta, navigazione, Ricerca | 3 | Fase 6 |
| 8 | §12-13: Dockerfile, compose, template Unraid, docs | 1 | Fase 6, **D-29** |
| 9 | §14: documenti, ADR, skill, CHANGELOG, 5.0.0 | 0,5 | tutto |
Totale: circa 13 sessioni. Ogni fase termina con `dotnet build`, `dotnet test`, documenti aggiornati e un commit.
## 3. Decisioni di progetto prese in Fase 0 (vincolanti per le fasi seguenti)
1. **Un ordine non si abbandona mai.** Ogni invio viene registrato in `data/state/pending_orders.json` prima della chiamata HTTP; all'avvio i pendenti si risolvono prima di qualsiasi decisione; una posizione che porta la firma del bot è del bot.
2. **La chiave è l'`orderId`.** Il riferimento cliente resta nell'intestazione (idempotenza) e nel registro, ma non è il mezzo con cui si chiede l'esito.
3. **Nessun esito sintetico.** `OrderOutcome.Status` riporta ciò che il server ha detto; quando non ha detto niente lo stato è `Unknown` e l'ordine resta pendente nel registro.
4. **Le unità eseguite le dice il server.** Il sizing propone, l'esecuzione registra ciò che l'API riporta (`positionExecutions[].openingData.units`), e la gamba B si dimensiona sulle unità **eseguite** di A, non su quelle richieste.
5. **Il margine è un vincolo di primo livello**, non una conseguenza: nessun ordine parte se `available` non copre il margine con il buffer.
6. **Il picco di equity è al netto dei movimenti di cassa**: depositi e prelievi spostano il riferimento, non il drawdown.
7. **Il codice nuovo di logica pura va nel Core** (`OrderTracker`, `PositionClassifier`, `CashFlowDetector`, `History`, sizing a margine) così la migrazione a `Encelado.Engine` della Fase 6 sposta file, non riscrive niente.
@@ -0,0 +1,82 @@
# Post-mortem: ordini dall'esito ignoto e gambe orfane (16-21 settembre 2026)
Scritto: 2026-09-23. Riguarda il forward test in Demo della versione 4.0.0.
## Che cosa è successo
Dal 16 settembre (sera) al 21 settembre il bot ha inviato ordini di apertura della gamba A di vari basket, ha dichiarato ogni gamba «non eseguita» perché non riusciva a leggerne l'esito, ha rifiutato il basket e non ha inviato la gamba B. Gli ordini però erano stati eseguiti dal server: sul conto demo si sono accumulate **posizioni singole senza copertura**, con lo stop nativo ma senza take-profit né stop di basket, che il bot vedeva come «posizioni sconosciute» e non toccava. Nessun basket è mai passato in `Open`; il contatore in dashboard diceva `0/5` mentre il conto aveva fino a venti posizioni aperte.
## Evidenze
Dallo storico del conto demo (`api/v1/trading/info/trade/demo/history`, letto il 2026-09-23):
| Apertura (UTC) | Strumento | Verso | Unità | Margine USD | P&L netto |
|---|---|---|---|---|---|
| 16/9 19:00:48 | EURUSD | short | 547 214,68 | 62 792,33 | 355,69 |
| 16/9 19:00:58 | USDCAD | long | 464 358,50 | 46 435,84 | +414,56 |
| 16/9 20:00:14 | AUDUSD | long | 28 207,949 | 2 000,00 | +122,14 |
| 16/9 22:15:01 | AUDUSD | long | 28 226,660 | 1 999,99 | +135,49 |
| 16/9 23:01:22 | AUDUSD | long | 28 221,482 | 2 000,00 | +131,79 |
| 17/9 00:45:04 | EURAUD | long | 17 444,888 | 1 999,99 | 84,13 |
| 17/9 01:15:03 | EURAUD | long | 17 448,661 | 1 999,99 | 76,67 |
| 17/9 02:00:23 | EURAUD | long | 17 452,394 | 1 999,99 | 72,96 |
| 17/9 03:15:01 | EURAUD | long | 17 450,257 | 1 999,99 | 51,91 |
| 17/9 12:30:02 | AUDUSD | short | 28 073,722 | 2 000,00 | 26,39 |
| 18/9 04:00:03 | NZDUSD | short | 34 883,315 | 1 999,99 | +17,79 |
| 18/9 04:15:04 | NZDUSD | short | 34 843,813 | 2 000,00 | +40,42 |
| 18/9 07:00:32 | EURAUD | short | 17 418,146 | 1 999,99 | +18,89 |
| 18/9 08:15:04 | NZDUSD | short | 34 950,981 | 1 999,99 | 20,97 |
| 18/9 09:15:03 | EURAUD | short | 17 419,919 | 1 999,99 | +26,97 |
| 18/9 10:15:01 | EURAUD | short | 17 415,984 | 2 000,00 | +27,83 |
| 21/9 06:45:02 | EURAUD | short | 17 427,466 | 1 999,99 | +12,68 |
| 21/9 07:15:03 | EURAUD | short | 17 432,862 | 2 000,00 | +9,57 |
| 21/9 08:15:02 | EURAUD | short | 17 425,358 | 1 999,99 | 0,62 |
| 21/9 09:00:44 | EURAUD | short | 17 421,555 | 1 999,99 | +7,95 |
| 21/9 10:15:03 | EURAUD | short | 17 420,945 | 1 999,99 | +12,31 |
Tutte chiuse il 21/9 alle 13:26 UTC in blocco (chiusura manuale). Totale: 21 gambe, **+289,05 USD** netti, 0 commissioni. Il segno positivo è casuale: gambe singole senza copertura, nessuna regola di uscita applicata per cinque giorni.
Osservazioni:
1. Gli orari sono chiusure di barra M15 (`:00`, `:15`, `:30`, `:45` più pochi secondi): sono ordini del bot. Ogni volta che il segnale restava oltre `zIn` alla barra successiva, il basket — tornato `Idle` dopo il «rifiuto» — inviava **un'altra gamba A**. Le dodici EURAUD sono lo stesso segnale ripetuto per dodici barre.
2. `GET api/v1/trading/info/demo/orders/381739181` (l'ordine EURUSD) risponde `referenceID: 00000000-0000-0000-0000-000000000000`: il server **non ha associato** l'`x-request-id` all'ordine. La ricerca `orders:lookup?referenceId=<x-request-id>` non poteva che rispondere 404. La ricerca per `orderId` risponde 200 con `status.id = 3 (Filled)` e la posizione.
3. I primi due ordini hanno impegnato **109 228 USD di margine, cioè tutta l'equity** (1 092 281 USD di nozionale a leva 10): erano le gambe A di due basket diversi, ciascuna sizata fino al tetto `maxEffectiveLeverage = 10` calcolato sul nozionale del *basket* senza guardare il margine disponibile.
4. Dal terzo ordine in poi ogni esecuzione vale **esattamente 2 000 USD di margine**: `orders:lookup?orderId=382724150` mostra `requestedAmount 2000`, `frozenAmount 2000`, `requestType byUnits`, `requestedUnits 17420.945125`. Il bot manda unità arrotondate a due decimali; sei decimali significano che il server ha **ricalcolato le unità da un importo**: con il margine esaurito eToro ha ridotto l'ordine a un importo fisso invece di rifiutarlo. Le unità eseguite non erano quelle richieste.
5. Il saldo è passato da 109 228 a 149 517 USD fra il 16 e il 22/9. Dal ledger ricevuto il 23/9 (D-36) non è un accredito unico: l'equity registrata a ogni riga sale in **circa ventuno scatti di ≈ +2 000 USD**, in coincidenza con le ventuno esecuzioni che il server aveva ridotto a 2 000 USD di margine. Il conto demo ha cioè accreditato fondi virtuali per ogni ordine ridotto: una regola del demo, non un'operazione del bot né un deposito dell'utente. In dashboard gli accrediti hanno alzato il picco di equity e falsato drawdown e P&L del giorno; dalla 5.0 sono `movimento_di_cassa` e non contano.
## Causa
Tre difetti concatenati, nessuno dei quali da solo avrebbe prodotto il danno:
1. **Esito cercato con la chiave sbagliata** (`EtoroBroker.OpenAsync`): l'`orderId` restituito dal `POST` veniva letto e ignorato; l'esito era chiesto solo per `referenceId`, che il server non registra per gli ordini v2. Dopo `fillTimeoutSeconds` il metodo restituiva un esito **costruito a mano** (`"Received"`, «esito non ancora noto») invece di dire «non so».
2. **Ordine abbandonato** (`BasketExecutor.SendAsync`, `BasketEngine.ExecuteEntryAsync`): allo scadere di `legTimeoutSec` la gamba era «non eseguita», il basket rifiutato e riportato a `Idle`, e nessuno seguiva più l'ordine. Alla barra dopo si ripartiva da zero.
3. **Posizione sconosciuta = posizione intoccabile** (`BasketEngine.ReconcileAsync`): la regola di sicurezza «non toccare ciò che non è tuo» era corretta per le posizioni esterne, ma il bot non aveva alcun modo per riconoscere le proprie. Il contatore contava gli slot, non le posizioni; il kill-switch chiudeva gli slot, non le posizioni.
Aggravante: il **sizing non guardava il margine**. Con `orderLeverage = maxEffectiveLeverage = 10` un solo basket al tetto impegna il 100 % dell'equity.
## Correzione (Fase 1-3 del piano 5.0)
| Difetto | Correzione | Dove |
|---|---|---|
| esito per `referenceId` | esito per **`orderId`** (`orders:lookup?orderId=`, ripiego `api/v1/trading/info/{demo/}orders/{id}`); il riferimento solo se la risposta al `POST` è andata persa; matching sulle posizioni (strumento + verso + orario ± 90 s) come ultima risorsa | `EtoroBroker.OpenAsync`, `LookupOrderByIdAsync` |
| esito sintetico | `OrderOutcome` con stato `Unknown` e `Pending = true`; mai un nome di stato che il server non ha detto | `EtoroBroker`, `OrderOutcome` |
| ordine abbandonato | **`OrderTracker`** con registro persistente `data/state/pending_orders.json` scritto **prima** dell'HTTP; stati `PendingA` / `PendingB`; risoluzione a ogni ciclo e all'avvio; `orders.jsonl` append-only | `Core/Baskets/OrderTracker.cs`, `BasketExecutor`, `BasketEngine` |
| posizioni proprie non riconosciute | **`PositionClassifier`**: `basket` / `orfana-bot` / `esterna`; le orfane-bot vengono adottate e chiuse; contatori separati in dashboard; banner se equity saldo non torna con le posizioni | `Core/Baskets/PositionClassifier.cs`, `BasketEngine.Reconcile.cs` |
| kill-switch che chiude gli slot | kill-switch che chiude **le posizioni** (basket + orfane), cancella i pendenti, verifica la piattezza, stato `Halted-Residuo` | `BasketEngine.Kill.cs` (Fase 3) |
| sizing senza margine | `risk.maxMarginUsePct`, `maxMarginPerBasketPct`, `marginBufferPct`, sizing = min(rischio, margine), ricontrollo di `available` prima di B, margin guard | `VolParitySizing`, `BasketDecider`, `BasketExecutor` (Fase 2) |
| accredito che gonfia il picco | `CashFlowDetector`: salto di saldo non spiegato dalle chiusure = movimento di cassa; picco e equity di inizio giornata al netto | `Core/Baskets/CashFlowDetector.cs`, `BasketEngine.State.cs` |
## Come si verifica che non si ripeta
- Test (m): lookup 404 persistente + posizione presente → `Filled` per matching (`EtoroBrokerTests`).
- Test (n): A eseguita, B rifiutata → A richiusa entro 5 s (`LegRiskTests`).
- Test (o): A pendente oltre il timeout poi eseguita → `PendingA → Open` con B, oppure unwind se il segnale è decaduto (`OrderTrackerTests`).
- Test (p): posizione con la firma del bot → `orfana-bot` e chiusa (`PositionClassifierTests`).
- Test (q): un deposito non muove il picco né il drawdown (`CashFlowTests`).
- In Demo: 24 ore con ordini eseguiti riconosciuti e **zero** orfane (contatore «Gambe orfane» a 0 per tutta la sessione; `orders.jsonl` senza stati `Unknown` non risolti).
- Skill `encelado-diagnose`: rapporto di riconciliazione fra `orders.jsonl`, `pending_orders.json` e le posizioni del conto.
## Che cosa resta aperto
- ~~Il ledger delle sessioni 16-21/9 non è su questa macchina~~ Ricevuto il 23/9 (D-36): 1 813 righe, 65 `segnale_ingresso` e 65 `rifiuto` «esito non ancora noto», preset AGGRESSIVE impostato dalla finestra, quattro run. Conferma la catena: lo stesso segnale ripetuto a ogni barra, ogni gamba A inviata e mai seguita, `unitsA` a due decimali contro le unità a sei decimali eseguite dal server. La chiusura in blocco del 21/9 13:26 UTC l'ha fatta l'utente dalla piattaforma (D-37): +289,05 USD realizzati, per fortuna.
- La regola con cui eToro riduce un ordine a margine esaurito non è documentata nell'OpenAPI; il bot non deve più trovarsi in quella condizione (Fase 2), ma la registra se accade (`units_eseguite ≠ units_richieste` in `orders.jsonl`).
+188
View File
@@ -0,0 +1,188 @@
# PROMPT PER CLAUDE CODE — Encelado 5.0: bonifica esecuzione, storico ordini, navigazione laterale, valuta, recupero dopo inattività, Telegram, apprendimento, kill-switch, margine, Material Design, Docker + Unraid
> Apri il repository `Encelado` e leggi, in quest'ordine: `CLAUDE.md`, `docs/STATE.md`, `docs/ARCHITECTURE.md`, `docs/RISK_RULES.md`, `docs/RUNBOOK.md`, `docs/KNOWN_ISSUES.md`, poi questo documento **per intero**. Questo prompt aggiorna la specifica precedente ("Correlation Baskets su eToro"): dove i due si contraddicono vale questo. Le regole di `CLAUDE.md` restano tutte (C#, nessun NuGet nell'applicazione, CSV `;` con `motivazione`, UTC, ledger append-only, mai un ordine reale senza flag e frase, un ADR per ogni scelta non ovvia, `docs/STATE.md` e `CHANGELOG.md` aggiornati a fine sessione).
>
> **Metodo di lavoro richiesto:** (1) rileggi questo prompt e produci `docs/PIANO_5.0.md` con la valutazione di ogni punto (fattibile / da chiarire / da rifiutare con motivo), l'ordine di esecuzione e la stima; (2) poni in blocco le domande di §15 e attendi; (3) implementa per fasi (§16), una fase per commit, con test verdi e documenti aggiornati; (4) **se hai un dubbio, chiedi**: `docs/QUESTIONS.md`, numerazione D-26 in poi, default prudente se non arriva risposta.
---
## 0. Diagnosi preliminare (già fatta leggendo codice e ledger: verificala, poi correggi)
Dal ledger `data/ledger/decisions.jsonl` (16-22 settembre): **65 `segnale_ingresso` e 65 `rifiuto`**, tutti con lo stesso testo: *«gamba A (…) non eseguita: Received → esito non ancora noto»*. Nessun basket è mai passato in `Open`, `data/ledger/baskets.csv` non esiste, `baskets_state.json` ha `baskets: []`, `learning_state.json` dice «ledger senza basket chiusi». Eppure il log di stato mostra `aperto 348,91` poi `380,24` con `basket aperti 0/5`, e l'equity in dashboard (137 901) è sotto il saldo (139 228) mentre "P&L aperto" mostra 0,00.
Catena causale nel codice:
1. `EtoroBroker.OpenAsync` (`src/Encelado.Etoro/EtoroBroker.cs`): il `POST orders` risponde con un `orderId`, che viene letto e **mai più usato**. Il polling dell'esito passa solo da `LookupOrderAsync(clientRef)` = `GET orders:lookup?referenceId=<x-request-id>`, che restituisce **404 → `null`** per tutti i 10 s di `fillTimeoutSeconds`; il metodo torna `OrderOutcome("Received", "esito non ancora noto")` costruito a mano (riga `return last ?? new OrderOutcome(...)`).
2. `BasketExecutor.SendAsync` ripete lo stesso lookup per altri `legTimeoutSec` (5 s), sempre `null`, e dichiara la gamba A «non eseguita». Il basket viene rifiutato; **l'ordine A resta sul server e viene eseguito** (o lo è già stato): nasce una gamba orfana, senza copertura, con lo stop nativo ma senza TP né gestione.
3. `BasketEngine.ReconcileAsync` vede la posizione, non la trova in `_knownPositions`, la scrive una volta nel log come «non appartiene a nessun basket: la lascio com'è» e **non la tocca mai più**. Il contatore basket resta 0/5 perché conta gli slot con `Position`, non le posizioni del conto.
4. `KillAsync → CloseAllAsync` chiude solo gli slot con `Position` (zero): il kill-switch «riesce» senza chiudere nulla. È il punto 8 dell'utente.
Conseguenze da correggere: (a) l'esito ordine va cercato **per `orderId`** (endpoint da verificare sulla documentazione: `GET api/v2/trading/info/{demo/}orders/{orderId}` o equivalente) e, in subordine, per `referenceId`, e comunque **riconciliato con l'elenco posizioni** (strumento + verso + unità ± 1 % + orario ± 90 s); (b) un ordine dall'esito ignoto **non si abbandona mai**: entra in un registro persistente di ordini pendenti e viene seguito finché non è eseguito, rifiutato o annullato; (c) una gamba eseguita a basket abortito va **chiusa subito**, non lasciata; (d) le posizioni del conto che portano la firma del bot (clientRef/orderId nel registro, oppure strumento+unità+orario coerenti con un `segnale_ingresso` del ledger) sono **del bot**, non «sconosciute»; (e) il contatore in dashboard deve distinguere «basket del bot», «gambe orfane del bot», «posizioni esterne».
Sui **«depositi» nella cronologia eToro**: il codice non chiama alcun endpoint di deposito o trasferimento (verificato: rotte usate = instruments, rates, candles, eligibility, costs, pnl, orders, orders:lookup, market-close-orders, positions, history, me, balances). Il saldo è passato da 109 228 (16/9) a 139 228 (18/9): **+30 000 esatti**, cifra tonda tipica di un accredito di fondi virtuali sul portafoglio demo, non di un trade. Le voci «deposito» sono movimenti di cassa del conto virtuale (accrediti di eToro o richiesti dall'utente), non operazioni del bot. Nella nuova scheda Storico (§1) vanno mostrate a parte, come `movimento di cassa`, mai sommate al P&L. Se dopo l'analisi dello storico via API (`api/v1/trading/info/trade/{demo/}history` e `api/v1/balances`) risultasse un'altra origine, scrivila in `docs/KNOWN_ISSUES.md`.
Sul **margine**: il sizing attuale è solo `rischio % / distanza dello stop`. Un basket Aggressive ha prodotto 1 092 281 USD di nozionale (leva 10) su 109 228 USD di equity: **una sola** operazione impegnava tutto il margine del conto; con `maxBaskets = 5` il secondo basket non poteva esistere. È il punto 10 dell'utente e va risolto in §10.
---
## 1. Scheda «Storico ordini» (nuova)
Nuova sezione di navigazione `Storico` con tre viste:
- **Ordini** (ultimi N, filtro per basket/strumento/verso/esito/periodo): una riga per ordine inviato (apertura, aggiunta, chiusura, unwind), con `ts, basket_id, strumento, verso, unità, prezzo richiesto, prezzo eseguito, slippage (pip), stato (eseguito/rifiutato/annullato/pendente/ignoto→risolto), orderId, positionId, fee, motivazione`. Fonte: nuovo file `data/ledger/orders.jsonl` (append-only, §3) scritto dall'esecutore **a ogni invio e a ogni cambio di stato**, più riconciliazione con lo storico eToro.
- **Posizioni** (aperte e chiuse): una riga per posizione, con origine `basket | orfana-bot | esterna | movimento di cassa`, apertura/chiusura, P&L lordo/netto in USD e in valuta di visualizzazione, pip, durata, motivo di uscita, fee e overnight; le posizioni aperte in tempo reale con P&L corrente.
- **Profitti per periodo**: tabella `periodo; n_basket; n_posizioni; vinti; persi; win_rate; pnl_lordo; fee; pnl_netto; media_per_basket; max_dd; motivazione` per `oggi, ieri, 7 giorni, 30 giorni, mese corrente, mese precedente, anno, tutto, intervallo personalizzato`, con un piccolo grafico dell'equity (SVG generato lato server o canvas lato client, nessuna libreria). Fonte di verità del realizzato: lo **storico eToro** (`GetClosedTradesAsync`), riconciliato con `baskets.csv`; i movimenti di cassa restano fuori dal P&L e compaiono in una riga separata.
- Pulsante **Esporta CSV** (`;`, colonna `motivazione`) per ciascuna vista.
Modulo: `src/Encelado.Core/Baskets/History/` (`OrderRecord`, `PositionRecord`, `PeriodStats`, `HistoryBuilder` puro e testabile) + endpoint `GET /api/history/{orders|positions|periods}`.
## 2. Navigazione verticale a sinistra, espandibile e comprimibile
Sostituisci la barra in alto con le tre schede con una **Navigation rail** Material 3 a sinistra (80 px chiusa: icona + etichetta breve; 256-300 px aperta: icona + etichetta estesa), apribile/chiudibile dal pulsante menu in alto e con stato persistito (`ui.navExpanded`). Voci: `Dashboard`, `Storico`, `Log`, `Impostazioni`; in basso, nel rail, il chip ambiente (`PAPER/DEMO/LIVE`) e lo stato (`in esecuzione / fermo / bloccato`). Su schermi stretti (< 600 px) il rail diventa un drawer modale. La barra superiore resta sottile: titolo della pagina, orologio UTC + fuso scelto, `AVVIA/FERMA`, `KILL-SWITCH`.
## 3. Versione fuori dalla schermata principale
Rimuovi dalla dashboard `v4.0.0 · strategia <hash> · run <id>`. In `Impostazioni → Informazioni`: nome, **autore (Alberto Balbo)**, versione, data di build, commit, licenza, link alla documentazione. In `Impostazioni → Diagnostica` (sezione comprimibile): hash della strategia, `run_id`, percorsi dei file, versione .NET, uptime, quote API usate.
## 4. Valuta di visualizzazione selezionabile
`ui.displayCurrency` (default = valuta del conto, `USD`) selezionabile fra `USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD` in Impostazioni e con un selettore rapido nella barra. Tassi: dalle quote eToro già in polling (EURUSD id 1, ecc.; per le coppie mancanti derivali via USD) aggiornati con le quote; in assenza di quota, l'ultimo tasso noto con etichetta «tasso di N minuti fa». Conversione **solo nel livello di presentazione** (snapshot → UI/Telegram/export); ledger, `baskets.csv`, `trials.csv` e tutte le decisioni restano in USD. Ogni importo convertito porta nel tooltip il valore in USD e il tasso usato. Formattazione con `CultureInfo` invariante e simbolo/decimali per valuta (JPY senza decimali).
## 5. Conteggio basket, posizioni aperte e «depositi» (correzione del bug di §0)
Implementa nell'ordine:
1. **`OrderTracker`** (`src/Encelado.Core/Baskets/OrderTracker.cs`) con registro persistente `data/state/pending_orders.json`: `clientRef, orderId, symbol, instrumentId, isBuy, units, basket_id, leg (A|B|add|close|unwind), sentUtc, lastStatus, resolvedUtc, positionId`. Ogni invio si registra **prima** della chiamata HTTP; ogni esito lo aggiorna; all'avvio il registro viene ricaricato e i pendenti risolti prima di qualunque decisione.
2. **`EtoroBroker.OpenAsync`**: dopo il `POST`, polling **per `orderId`** (endpoint da verificare, D-26) e, in parallelo, per `referenceId`; se entrambi rispondono 404, dopo 2 s interroga le posizioni e applica la regola di matching di §0(a); se il matching riesce l'ordine è `Filled` con quel `positionId`. Restituisce `Pending` solo se davvero non c'è traccia; **mai** un `"Received"` sintetico. Verifica la mappa degli stati (`filled = 3 or 5`, `rejected = 4,7,8,9,10`) sulla documentazione e scrivila in `docs/DATA_SOURCES.md`.
3. **`BasketExecutor`**: se la gamba A resta `Pending` allo scadere di `legTimeoutSec`, il basket passa in stato **`PendingA`** (nuovo) invece di essere rifiutato: nessun nuovo ingresso su quel basket, `OrderTracker` continua a seguire l'ordine; alla risoluzione: eseguito → invia B (se il segnale è ancora valido e il cost gate passa) oppure **chiudi A subito** (`leg_risk_unwind`) se non lo è; rifiutato → `Idle`. Stesso schema per B (`PendingB`). Registra tutto in `orders.jsonl` e in `decisions.jsonl` (`evento = pending`, `pending_risolto`).
4. **`ReconcileAsync`**: classifica ogni posizione del conto in `basket` (nota), `orfana-bot` (nel registro ordini o firma coerente con un `segnale_ingresso`/`rifiuto` del ledger entro ±90 s, stesso strumento, stesso verso, unità ± 1 %), `esterna`. Le orfane-bot vengono **adottate e chiuse** (§7 recupero decide se prima provare a ricomporre il basket con la gamba mancante: default **no**, chiudi). Le esterne restano intoccate salvo `risk.closeForeignOnKill`.
5. **Bonifica una tantum** all'avvio della 5.0 (`--bonifica` in headless / pulsante in Diagnostica): elenca le posizioni orfane-bot attualmente sul conto demo, le chiude una per una con conferma, scrive per ognuna una riga `baskets.csv` con `exit_reason = bonifica_orfana` e il P&L realizzato letto dallo storico, e produce `reports/bonifica_YYYYMMDD.csv`.
6. **Dashboard**: `Basket aperti a/b` resta, affiancato da `Gambe orfane: n` (rosso se > 0) e `Posizioni esterne: n`; `P&L aperto` mostra il valore **del conto** (`_account.UnrealizedPnl`) con sotto «di cui basket: x»; `Equity Saldo` e `P&L aperto` devono coincidere a meno delle fee: se non coincidono per più di 60 s, banner giallo «posizioni non riconciliate».
7. **Movimenti di cassa**: `HistoryBuilder` legge saldi e storico, riconosce depositi/prelievi/accrediti virtuali e li espone in Storico come `movimento di cassa`, esclusi dal P&L e dal calcolo del drawdown (il picco di equity va **ricalcolato al netto dei movimenti di cassa**: oggi un accredito di 30 000 gonfia il picco e falsa il DD).
Test obbligatori: (m) lookup 404 persistente + posizione presente → `Filled` per matching; (n) A eseguita e B rifiutata → unwind entro 5 s; (o) A pendente oltre il timeout poi eseguita → `PendingA → Open` con B, oppure unwind se il segnale è decaduto; (p) posizione orfana con firma del bot → classificata `orfana-bot` e chiusa; (q) picco di equity insensibile a un deposito.
## 6. Recupero dopo inattività
Heartbeat `data/state/heartbeat.json` scritto ogni 30 s (`utc, run_id, mode, openBaskets`). All'avvio e a ogni iterazione del ciclo: `inattività = now ultimo heartbeat`; se `> recovery.thresholdMinutes` (default 10; copre riavvii, sospensione del PC, aggiornamenti Windows, riavvio del container) entra la **procedura di recupero**, nell'ordine:
1. Blocca le nuove entrate; scrivi `evento = recupero_avviato` con la durata dell'inattività.
2. Risolvi il registro ordini pendenti (§5.1); riconcilia e classifica le posizioni (§5.4).
3. Riscalda le serie (ultime 1000 candele M15 via API) e ricalcola per ogni basket noto `z, ρ_W, ρ_20, HL, spread, costo, barsHeld` **includendo le barre trascorse durante l'inattività**.
4. Per ogni basket aperto applica le regole di uscita di §5.4 della specifica precedente come se fosse una valutazione ordinaria (`stop_z`, `stop_max_loss`, `time_stop` con le barre di inattività, `rho_break`, spread anomalo, fine settimana/blackout) e decide **`chiudi` o `tieni`**; per ogni gamba orfana-bot: `chiudi`; per le posizioni esterne: solo rapporto.
5. Esegui le chiusure decise; per i `tieni` ripristina TP/stop di basket e riprendi il monitoraggio tick-by-tick.
6. Se il mercato è chiuso (fine settimana) programma la valutazione alla riapertura e non fare nulla di irreversibile.
7. Scrivi `reports/recupero_<run_id>.csv` (una riga per posizione: `decisione; motivo; z; ρ; barsHeld; pnl; motivazione`), riga `recupero_concluso` nel ledger, notifica Telegram con il riepilogo; sblocca le entrate solo dopo `recovery.warmupMinutes` (default 15) di quote coerenti.
Parametri in `strategy.json``recovery`. Test: (r) simulazione di 6 h di inattività con un basket che nel frattempo ha superato `z_stop` → chiuso; con un basket ancora dentro le soglie → tenuto e riarmato; con una gamba orfana → chiusa.
## 7. Notifiche Telegram
Modulo `src/Encelado.Core/Notifications/TelegramNotifier.cs` (Bot API via `HttpClient`, `sendMessage` con `parse_mode=HTML`, coda in `Channel<T>`, 1 messaggio/s, retry con backoff, nessuna libreria) + `INotifier` con `NullNotifier`. Configurazione in `encelado.json``notifications.telegram`: `enabled, chatId, hourlyStatus, eventAlerts, dailySummaryUtcHour` (token e chatId anche da `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID`; mai nel repo).
- **Stato ogni ora** (allo scoccare dell'ora UTC): attivo/fermo/bloccato, modalità e ambiente, uptime, equity/saldo/disponibile/margine usato (valuta di visualizzazione), P&L oggi e aperto, basket aperti (per ognuno: coppie, verso, z, pip, P&L, durata), gambe orfane, posizioni esterne, DD dal picco, prossimo evento ad alto impatto, stato API (latenza, quote usate), ultimo errore.
- **Eventi**: apertura basket, aggiunta, chiusura (con motivo e P&L), unwind, ordine pendente risolto, kill-switch, equity stop, perdita giornaliera raggiunta, recupero avviato/concluso, cambio preset, avvio/arresto del bot, errori API persistenti, scarto orologio, feed in errore per > 1 h.
- **Riepilogo giornaliero** alle `dailySummaryUtcHour` (default 21): P&L del giorno, n. basket, win rate, costi, DD.
- **Comandi in ingresso** (long polling `getUpdates`, solo dal `chatId` autorizzato, ogni comando loggato nel ledger): `/stato`, `/posizioni`, `/storico 7d`, `/pausa` (blocca entrate), `/riprendi`, `/chiudi <basket>`, `/kill CONFERMO` (kill-switch), `/reset <motivazione>`; qualunque altro testo → «comando non riconosciuto». Il polling usa un solo task e non compete con le quote eToro.
Test: (s) formattazione dei messaggi su snapshot fissi; (t) throttling e retry con `HttpMessageHandler` finto; (u) rifiuto dei comandi da chatId non autorizzato.
## 8. Valutazione del sistema di autoapprendimento (decisione richiesta)
Stato di fatto: `docs/STRATEGY.md`**verdetto negativo** (7,75 anni, nessuna configurazione con P&L netto positivo, segnale ≈ 2 pip contro 3 pip di costo, walk-forward Sharpe 0,91); il forward test in Demo ha **0 basket chiusi** in sei giorni per il bug di §0; il ciclo settimanale ha girato a vuoto («niente da addestrare»). Un meta-modello può solo filtrare i basket, non creare un edge che non c'è: con un segnale sotto i costi il massimo che può fare è ridurre le perdite. Serve inoltre un minimo di ~300 basket chiusi per attivarsi: con la frequenza osservata (poche entrate al giorno, quando entrano) sono mesi.
**Decisione da applicare (ADR-0006), salvo diversa risposta a D-30:**
- **Tenere**: il ledger (è il dato), il Livello 0 (calibrazione: tabelle per bucket di z/ρ/ora/evento, quasi gratis e utili a leggere il forward test), la previsione di volatilità (già usata dal decisore in modo deterministico), la logistica **in ombra** (costo nullo, produce `p_ML` nel ledger).
- **Disattivare a runtime** (codice conservato nel Core, `learning.enabled = false` di fabbrica, nessun task avviato): MLP challenger, bandit sui preset, ciclo settimanale. Spostare la loro esecuzione nello strumento `tools/Encelado.Backtest` (`learn` su un ledger esportato), così restano disponibili quando ci saranno dati.
- **UI**: rimuovere il riquadro «Apprendimento» dalla dashboard; in `Impostazioni → Ricerca` una sezione comprimibile con lo stato del modello in ombra (n. basket visti, AUC mobile, PSI) e il pulsante «esegui ciclo di apprendimento» manuale.
- **Criterio di riattivazione**, scritto in `docs/ML_AND_LEARNING.md`: ≥ 300 basket chiusi in Demo **e** P&L netto forward ≥ 0 sulla pre-registrazione; prima di allora qualunque «adattamento automatico» è rumore.
## 9. Kill-switch che chiude davvero e procedura di ripristino
**Kill-switch** (`KillAsync`), nuovo comportamento, idempotente e ripetibile:
1. `halt = true`, blocco immediato di ogni nuova entrata e aggiunta; riga `kill_switch_avviato`.
2. Annulla gli ordini pendenti nel registro (endpoint di cancellazione se esiste, D-27; altrimenti attende la risoluzione entro 30 s e chiude ciò che si è eseguito).
3. Chiude **tutte le posizioni del bot**: basket noti + gambe orfane-bot (§5.4), tre tentativi per gamba con backoff, verifica tra un tentativo e l'altro sull'elenco posizioni.
4. Posizioni **esterne**: chiuse solo se `risk.closeForeignOnKill = true` **oppure** se la finestra di conferma del kill-switch ha la spunta «chiudi anche le posizioni esterne» (default deselezionata, con l'elenco delle posizioni e il loro P&L).
5. **Verifica di piattezza**: rilegge le posizioni finché non restano solo esterne non incluse (timeout 120 s); se restano residui, banner rosso con l'elenco e stato `Halted-Residuo`; nessuna dichiarazione di «tutto chiuso» senza questa verifica.
6. Persiste `killSwitched`, scrive una riga `baskets.csv` per basket e una riga `orders.jsonl` per gamba, notifica Telegram (esito, residui).
Il file `STOP` e il comando `/kill CONFERMO` seguono lo stesso percorso.
**Ripristino** (`Impostazioni → Ripristino` e comando `reset <motivazione>`), procedura guidata in cinque passi mostrati uno per volta: (1) stato attuale: posizioni residue (con pulsante «chiudi ora»), ordini pendenti, ultimo errore; (2) rimozione del file `STOP` (automatica, con avviso se ricompare); (3) motivazione obbligatoria ≥ 10 caratteri → riga `correzione`; (4) riconciliazione completa + riscaldamento serie + ricalcolo del picco di equity (al netto dei movimenti di cassa); (5) ripartenza con entrate bloccate per `recovery.warmupMinutes`; notifica Telegram. Documenta in `docs/RUNBOOK.md` con una tabella «sintomo → passo». Test: (v) kill con 2 basket + 1 orfana + 1 esterna → chiude 5 gambe, lascia l'esterna, verifica la piattezza; (w) reset senza motivazione rifiutato; (x) reset con residui → stato `Halted-Residuo`.
## 10. Saldo disponibile e coerenza delle aperture
Leggi a ogni ciclo `AccountSnapshot` completo (`balance, equity, available, marginUsed, credit`) da `api/v1/balances` + `pnl` e mostra `Margine usato / disponibile` in dashboard. Nuove regole in `strategy.json → risk` (documentate in `docs/RISK_RULES.md`):
- `maxMarginUsePct` (default **40 %** dell'equity): margine totale impegnato dopo l'apertura ≤ soglia.
- `maxMarginPerBasketPct` (default **12 %**): margine delle due gambe di un basket ≤ soglia.
- `marginBufferPct` (default 25 %): `available ≥ (marginA + marginB) × 1,25` **prima** di inviare A, e ricontrollo di `available` (con la posizione A già aperta) prima di inviare B; se B non è più coperta, unwind di A.
- Sizing = **min**(sizing a rischio, sizing a margine); il ledger scrive quale vincolo ha deciso (`sizing_bound = risk | margin | min_exposure`) e il margine impegnato previsto; l'esposizione minima eToro (1000 USD per posizione) resta il floor: se il min non la raggiunge, niente ingresso (`reasonCodes: min_exposure`).
- Ordine dei basket quando più segnali arrivano insieme: per `|z|` decrescente, uno alla volta, ricalcolando `available` dopo ogni apertura.
- Margin call preventiva: se `equity / marginUsed < 1,5` blocca le entrate; `< 1,2` chiude il basket con il peggior P&L (riga `margin_guard`).
Test: (y) con equity 100 000 e Aggressive il nozionale per basket è limitato dal margine, non dal rischio; (z) il secondo basket viene rifiutato quando `available` non copre `1,25 ×` margine.
## 11. Interfaccia secondo Material Design 3 (web, senza librerie) — e conseguenza sul progetto WPF
Docker su Unraid è Linux: **WPF non può girare nel container**. Decisione proposta (**ADR-0007**, da confermare con D-28): l'interfaccia diventa una **web app servita dal bot stesso** (Kestrel, `Microsoft.AspNetCore.App` è un framework reference dell'SDK, non un pacchetto NuGet), usata sia su Windows (`http://localhost:8080`) sia nel container; il progetto WPF viene ritirato (resta nella storia git) e il vecchio eseguibile Windows diventa `Encelado.Server` avviabile come console/servizio. Nessun framework front-end, nessun font remoto: HTML + CSS + JavaScript vanilla incorporati come risorse (`EmbeddedResource`), aggiornamenti in tempo reale via **Server-Sent Events** (`/api/stream` con lo snapshot ogni secondo), comandi via `POST /api/commands/...`, autenticazione con **token locale** (`ENCELADO_WEB_TOKEN`, cookie HttpOnly; senza token il server ascolta solo su localhost).
Linee guida M3 da applicare (scrivile in `docs/UI_GUIDELINES.md` con i token scelti):
- **Colore**: ruoli M3 (`primary/on-primary/primary-container`, `surface`, `surface-container-lowest…highest`, `outline`, `error`), palette scura di default e chiara selezionabile, contrasto ≥ 4,5:1; una sola tinta d'accento; verde/rosso solo per P&L e stati.
- **Tipografia**: scala M3 (`display, headline, title, body, label`), `Roboto, "Segoe UI", system-ui` con fallback, cifre tabulari (`font-variant-numeric: tabular-nums`) per tutti i numeri.
- **Forma ed elevazione**: angoli 12-16 px, elevazione tramite superfici tonali (non ombre pesante), state layer su hover/focus/pressed.
- **Componenti** (in CSS/JS proprio): navigation rail + drawer (§2), top app bar, cards (elevated/filled/outlined), data table con densità compatta e intestazioni fisse, chips di stato (assist/filter), buttons (filled/tonal/outlined/text), switch, select/menu, dialog per conferme (kill-switch, chiusura basket, reset), snackbar per esiti, linear progress durante le operazioni, tooltip con formula/fonte su ogni numero, badge sui contatori.
- **Layout adattivo** per classi di finestra M3 (compact < 600, medium < 840, expanded ≥ 840): rail/drawer, griglia a 4/8/12 colonne, tabella dei basket che passa a cards sotto 600 px.
- **Accessibilità**: navigazione da tastiera completa, `aria-label`, focus visibile, preferenze `prefers-reduced-motion` e `prefers-color-scheme`.
Contenuti (stesse informazioni dello screenshot attuale, riorganizzate):
- **Dashboard**: KPI cards `Equity`, `P&L oggi`, `P&L aperto (conto) / di cui basket`, `Drawdown`, `Basket aperti a/b · orfane · esterne`, `Margine usato / disponibile`; tabella basket con `Basket (cross)`, `Stato`, `z`, `ρ`, `HL`, `Pips`, `TP`, `P&L`, `Costo`, `Prossimo evento`, pulsante `Chiudi` (la colonna `p ML` sparisce dalla tabella e va nel tooltip); cards `Prossimo evento`, `Collegamento eToro`, `Telegram` (ultimo invio, coda); striscia `Attività`.
- **Storico**: §1. **Log**: come oggi, con filtro e ricerca. **Impostazioni**: come oggi + `Notifiche`, `Valuta`, `Ripristino`, `Ricerca`, `Diagnostica`, `Informazioni`.
Test: (aa) `UiRenderTests` sostituiti da test sullo snapshot JSON dell'API e da un test che valida l'HTML incorporato (well-formed, nessun riferimento esterno, nessun `<script src=http…>`); (bb) SSE che emette uno snapshot al secondo con un client finto.
## 12. Docker (esecuzione fuori da Windows)
- Ristruttura i progetti: `Encelado.Core` (invariato), `Encelado.Etoro` (invariato), nuovo **`Encelado.Engine`** (net10.0, portabile: `Baskets/`, `Configuration/`, `Engine/`, `Logging/`, `Notifications/`, `History/` oggi dentro `Encelado.Bot`), nuovo **`Encelado.Server`** (`Microsoft.NET.Sdk.Web`, host Kestrel + UI incorporata + CLI headless con gli stessi comandi di oggi). `EtoroKeyStore` (DPAPI) diventa `IKeyStore` con tre implementazioni: DPAPI (solo Windows), file cifrato con AES da `ENCELADO_KEY_PASSPHRASE`, variabili d'ambiente (default nel container). `TreatWarningsAsErrors` e `Nullable` invariati; nessun NuGet.
- `Dockerfile` multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0` per build/test/publish (`-c Release`, framework-dependent), `mcr.microsoft.com/dotnet/aspnet:10.0` per l'esecuzione; utente non root con `PUID/PGID` (default 99/100 per Unraid) applicati da un entrypoint che sistema i permessi di `/config` e `/data`; `TZ` rispettato dai log (il ledger resta UTC); `HEALTHCHECK` con `dotnet Encelado.Server.dll --health` (verifica API eToro raggiungibile, heartbeat recente, disco scrivibile); arresto pulito su `SIGTERM` (`closeOnShutdown` invariato, flush di ledger e stato, heartbeat finale); log su stdout **e** su file.
- Volumi: `/config` (`encelado.json`, `strategy.json`, `instruments.json`, chiavi cifrate), `/data` (`data/`, `knowledge/`, `reports/`, `results/`, `logs/`). Percorsi in `encelado.json` relativi a `/config`; su Windows restano `Documenti\Encelado`.
- Variabili: `ETORO_API_KEY`, `ETORO_USER_KEY`, `ETORO_ENVIRONMENT` (`demo|real`), `ENCELADO_EXECUTION_MODE` (`Paper|Demo|Live`), `ENCELADO_CONFIRM_LIVE` (deve valere `CONFERMO LIVE` per il Live), `ENCELADO_WEB_PORT` (8080), `ENCELADO_WEB_TOKEN`, `ENCELADO_DISPLAY_CURRENCY`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `TZ`, `PUID`, `PGID`.
- **Una sola istanza** per cartella dati: lock file `data/state/instance.lock` con PID e orario (risolve il problema noto).
- `docker-compose.yml`, `.dockerignore`, `docs/DOCKER.md` (build, avvio, aggiornamento, backup di `/config` e `/data`, rotazione chiavi, cosa succede ai basket aperti al riavvio → §6). La catena `build/Release.proj` acquisisce il target `Docker` (build + tag `encelado:<versione>`), senza toccare il resto (regola 5 di `CLAUDE.md`); l'installatore Windows resta per `Encelado.Server`.
## 13. Template XML per Unraid
Crea `deploy/unraid/encelado.xml` (formato Community Applications, `Container version="2"`), con: `Name` Encelado, `Repository` (registry e immagine da D-29, es. `gitea.<dominio>/alberto/encelado:latest`), `Registry`, `Network bridge`, `Privileged false`, `Support`, `Project`, `Overview` (descrizione in italiano), `Category` `Tools:`, `WebUI` `http://[IP]:[PORT:8080]/`, `Icon` (URL raw di `assets/encelado.png` 256×256, da aggiungere al repo insieme all'`.ico`), `ExtraParams --restart=unless-stopped`, `Config` per: porta 8080 (`Type=Port`), `/config``/mnt/user/appdata/encelado/config` e `/data``/mnt/user/appdata/encelado/data` (`Type=Path`, `Mode=rw`), tutte le variabili di §12 (`Type=Variable`, `Mask=true` su chiavi e token, `Display=always` per modalità/ambiente/valuta/TZ, `Display=advanced` per PUID/PGID/porta), con `Description` in italiano per ogni voce. Aggiungi `deploy/unraid/README.md` (come importare il template: «Add Container → Template repositories» o copia in `/boot/config/plugins/dockerMan/templates-user/`).
## 14. Documentazione-memoria per l'AI e base di conoscenza
Aggiorna: `CLAUDE.md` (nuova struttura dei progetti, comandi Docker/web, regole invariate), `docs/STATE.md`, `docs/ARCHITECTURE.md` (OrderTracker, stati `PendingA/PendingB/Halted-Residuo`, web/SSE, Engine/Server, notifiche, recupero), `docs/RISK_RULES.md` (margine, kill-switch, esterne), `docs/RUNBOOK.md` (ripristino guidato, Docker, Telegram, bonifica), `docs/LEDGER_SCHEMA.md` (`orders.jsonl`, `pending_orders.json`, `heartbeat.json`, nuovi `evento`), `docs/DATA_SOURCES.md` (rotte eToro verificate con stati ordine, Telegram), `docs/ML_AND_LEARNING.md` (§8), `docs/KNOWN_ISSUES.md` (rimuovi ciò che si chiude, aggiungi ciò che resta), `docs/UI_GUIDELINES.md`, `docs/DOCKER.md`, `docs/GLOSSARY.md`, `CHANGELOG.md`; ADR-0006 (apprendimento), ADR-0007 (UI web al posto di WPF), ADR-0008 (Docker/Engine/Server), ADR-0009 (OrderTracker e adozione delle orfane). Genera anche **`docs/POSTMORTEM_ordini_pendenti.md`**: cosa è successo, evidenze dal ledger, causa, correzione, come si verifica che non si ripeta.
Crea inoltre le **skill di progetto per Claude Code** in `.claude/skills/<nome>/SKILL.md` (frontmatter `name`, `description`), così che il lavoro futuro parta sempre dallo stesso posto:
- `encelado-start`: leggi `docs/STATE.md` e `CLAUDE.md`, esegui `dotnet build`, riassumi fase e problemi aperti, chiedi cosa fare.
- `encelado-verify`: `dotnet build`, `dotnet test`, verifica dell'HTML incorporato, `dotnet msbuild build/Release.proj -t:Verifica`, riepilogo esiti.
- `encelado-diagnose`: legge `decisions.jsonl`, `orders.jsonl`, `pending_orders.json`, `heartbeat.json` e l'elenco posizioni (se chiavi presenti) e produce un rapporto di riconciliazione in CSV `;`.
- `encelado-docs`: aggiorna `docs/STATE.md`, `CHANGELOG.md`, ADR e `docs/QUESTIONS.md` a fine sessione, poi propone il messaggio di commit.
- `encelado-release`: catena di rilascio + build Docker + aggiornamento del template Unraid.
- `encelado-ui`: applica i token e le regole di `docs/UI_GUIDELINES.md` quando si tocca la UI.
## 15. Domande da porre prima di iniziare (in blocco, con default)
| # | Domanda | Default |
|---|---|---|
| D-26 | Qual è l'endpoint corretto per leggere un ordine per `orderId` e quali sono gli stati (id/nome)? Fornisci la pagina della documentazione o l'esito di una chiamata reale. | verifica sul portale; se non esiste, riconciliazione per posizioni entro 2 s |
| D-27 | Esiste un endpoint di cancellazione ordini? | no: attesa risoluzione ≤ 30 s |
| D-28 | Confermi il ritiro del progetto WPF a favore della web UI servita dal bot (Windows e Docker)? | sì (ADR-0007) |
| D-29 | Registry per l'immagine (Gitea del tuo server, GHCR, Docker Hub) e URL raw per l'icona del template Unraid? | Gitea (`build/gitea.example.json`), icona nel repo |
| D-30 | Confermi la disattivazione a runtime di MLP, bandit e ciclo settimanale (§8)? | sì (ADR-0006) |
| D-31 | Chat ID e token Telegram verranno forniti via variabili d'ambiente? Vuoi i comandi in ingresso o solo le notifiche? | env; comandi attivi |
| D-32 | Le posizioni esterne vanno chiuse dal kill-switch di default? | no |
| D-33 | Valute da offrire nel selettore oltre a USD/EUR? | USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD |
| D-34 | Soglie di margine (40 % totale, 12 % per basket, buffer 25 %) e soglia di inattività (10 min) vanno bene? | sì |
| D-35 | Le posizioni orfane oggi sul demo: le chiudo io con la bonifica (§5.5) o le chiudi tu a mano prima? | bonifica con conferma per posizione |
## 16. Fasi e criteri di accettazione
| Fase | Contenuto | Accettazione |
|---|---|---|
| 0 | `docs/PIANO_5.0.md`, domande D-26…D-35, `docs/POSTMORTEM_ordini_pendenti.md` | piano approvato, domande risposte o default registrati |
| 1 | §5 OrderTracker, lookup per orderId, stati Pending, adozione orfane, contatori, picco al netto dei movimenti di cassa, bonifica | test (m)-(q) verdi; 24 h di Demo con ordini eseguiti riconosciuti e **zero** orfane |
| 2 | §10 margine e coerenza aperture | test (y)(z); ledger con `sizing_bound` |
| 3 | §9 kill-switch reale + ripristino guidato | test (v)-(x); prova manuale in Demo con verifica di piattezza |
| 4 | §6 recupero dopo inattività, heartbeat, lock di istanza | test (r); prova: fermare 1 h con basket aperto |
| 5 | §7 Telegram (stato orario, eventi, riepilogo, comandi) | test (s)-(u); messaggi reali ricevuti |
| 6 | §12 Engine/Server, web host, SSE, API, key store portabile | avvio su Windows e in container con la stessa `/config` |
| 7 | §11 UI Material 3 + §2 navigazione + §3 versione + §4 valuta + §1 Storico + §8 pannello Ricerca | test (aa)(bb); revisione visiva con te; stesse informazioni dello screenshot presenti |
| 8 | §12 Dockerfile/compose/docs + §13 template Unraid | immagine avviata su Unraid con template importato, healthcheck verde |
| 9 | §14 documentazione, ADR, skill di progetto, `CHANGELOG`, versione **5.0.0**, rilascio | `encelado-verify` verde; commit a fine sessione |
**Regola finale: prima la correttezza dell'esecuzione (Fasi 1-3), poi tutto il resto. Finché un ordine dall'esito ignoto può restare sul conto senza padrone, nessuna nuova funzione va in Demo.**
+19
View File
@@ -41,3 +41,22 @@ Ogni domanda è numerata per fase. Quando l'utente non ha risposto, è stato app
| D-23 | Fuso orario della finestra: quello del computer o selezionabile? | computer, selezionabile | **Applicato**: `ui.timeZone` = `computer` di fabbrica; elenco dei fusi di Windows in Impostazioni; `ENCELADO_TIME_ZONE` da ambiente. Solo la finestra cambia: il log porta l'offset, il ledger è UTC. |
| D-24 | L'endpoint dei costi restituiva markup e overnight a zero: era davvero zero? | verificare | **Verificato via API il 2026-09-16 12:45 UTC**: il campo si chiama `value`, non `amount`. EURUSD 10 000 unità leva 10: markup 0,0, spread di mercato 0,1 USD (0,1 pip), overnight 0,91 USD/giorno (≈ 0,9 pip/gamba/giorno). Parser corretto; aggiunto lo scenario di costi `api` al backtest. |
| D-25 | Google News vieta `/rss/search` nel robots.txt: forzare, sostituire o omettere? | omettere | **Default applicato: omettere** (il bot rispetta il robots.txt). SNB e RBNZ restano senza fonte; documentato in `KNOWN_ISSUES.md`. |
## Fase 0 della 5.0 — 2026-09-23
Domande del prompt «Encelado 5.0» (§15) più quelle emerse dalla verifica. D-26 e D-27 sono state verificate via API (sola lettura, collegamento MCP dell'utente) e non aspettano risposta. Le Fasi 1-5 hanno usato i default; D-28, D-29, D-36 e D-37 hanno avuto risposta il 2026-09-23 e le Fasi 6-9 sono state eseguite.
| # | Domanda | Default proposto | Stato / risposta |
|---|---|---|---|
| D-26 | Endpoint per leggere un ordine per `orderId` e mappa degli stati? | verifica sul portale | **Verificato via API il 2026-09-23**: `GET api/v2/trading/info/{demo/}orders:lookup?orderId=<id>` (stessa risposta del lookup per riferimento) e `GET api/v1/trading/info/{demo/}orders/{orderId}` (risposta v1: `statusID`, `positions[]`). Stati: 1 Received, 2 Placed, 3 Filled, 4 Rejected, 5 PartiallyFilled, 6 PendingCancel, 7 Canceled, 8 Expired, 9 CanceledPartiallyFilled, 10 RejectedPartiallyFilled, 11 WaitingForMarket, 12 PendingTriggeredRate. Eseguito = 3 o 5; rifiutato = 4, 7, 8, 9, 10; in corso = 1, 2, 6, 11, 12. **Il lookup per `referenceId` non funziona per gli ordini v2**: il server ha registrato `referenceID = 00000000-…` per gli ordini del bot (verificato su 381739181). Scritto in `docs/DATA_SOURCES.md`. |
| D-27 | Esiste un endpoint di cancellazione ordini? | no | **Sì, verificato**: `DELETE api/v2/trading/execution/{demo/}orders/{orderId}` (quota 20/min condivisa con gli ordini). Il 200 conferma solo la richiesta; l'esito si legge con il lookup: 7 o 9 = annullato, 6 = in corso. Il kill-switch lo usa e poi attende la risoluzione (≤ 30 s). |
| D-28 | Confermi il ritiro del progetto WPF a favore della web UI servita dal bot? | sì (ADR-0007) | **Risposta dell'utente (2026-09-23)**: «Sì, confermo: il bot deve essere eseguito SOLO tramite container. Elimina tutta la parte della grafica Windows». Applicata: progetto WPF rimosso, `Encelado.Engine` + `Encelado.Server`, interfaccia web Material 3, chiavi in file cifrato al posto di DPAPI, VS Code con F5 sul server (ADR-0007, ADR-0008). |
| D-29 | Registry per l'immagine e URL dell'icona del template Unraid? | Gitea (`build/gitea.example.json`), icona nel repo | **Risposta dell'utente (2026-09-23)**: «Per il registro useremo Gitea per il momento. L'icona non si può pubblicare da qualche parte su Gitea?». Applicata: immagine `192.168.30.23:3000/alby96/encelado:<versione>` sul registro dei container di Gitea (stesso token della release); icona `assets/encelado.png` servita da Gitea come file raw del repository (`Icon` del template). |
| D-30 | Confermi la disattivazione a runtime di MLP, bandit e ciclo settimanale? | sì (ADR-0006) | **Default applicato** (ADR-0006, 2026-09-23): il bandit propone e non applica (Fase 1); `learning.enabled = false` di fabbrica spegne ciclo settimanale e challenger e impedisce al cancello ML di attivarsi; la logistica resta in ombra. Il comando `learn` dello strumento arriva con la Fase 6. Resta aperta solo se vuoi il contrario. |
| D-31 | Token e chat ID Telegram via variabili d'ambiente? Comandi in ingresso o solo notifiche? | env; comandi attivi | **Default applicato** (Fase 5). |
| D-32 | Le posizioni esterne vanno chiuse dal kill-switch di default? | no | **Default applicato**: `risk.closeForeignOnKill = false`; la conferma del kill-switch ha la spunta «chiudi anche le esterne», deselezionata. |
| D-33 | Valute del selettore oltre USD/EUR? | USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD | **Default applicato** (Fase 7). Per GBP e JPY il polling aggiunge GBPUSD e USDJPY. |
| D-34 | Soglie di margine (40 % totale, 12 % per basket, buffer 25 %) e soglia di inattività (10 min)? | sì | **Default applicato** (Fasi 2 e 4), tutte in `strategy.json` e cambiabili dall'operatore. |
| D-35 | Le orfane sul demo: bonifica con conferma o chiusura manuale? | bonifica con conferma | **Superata dai fatti**: il 2026-09-23 il conto demo ha 0 posizioni e 0 ordini; le 21 gambe del bot sono state chiuse in blocco il 21/9 alle 13:26 UTC (chiusura manuale, non del bot). La bonifica resta come comando (`--bonifica`, e pulsante in Diagnostica) per il futuro e non ha niente da chiudere oggi. |
| D-36 | Il ledger delle sessioni 16-21/9 (65 `segnale_ingresso`/`rifiuto`) non è su questa macchina: `Documenti\Encelado\data\ledger\decisions.jsonl` ha solo il 16/9. Su quale macchina o profilo ha girato il bot? Puoi copiare qui `decisions.jsonl`, `baskets_state.json` e `learning_state.json` di quella sessione? | procedere con le evidenze dell'API | **Risposta dell'utente (2026-09-23)**: allegato il bundle della cartella dati dell'installazione 4.0.0. Letto: 1 813 righe dal 16/9 15:55 al 22/9 08:01, 65 `segnale_ingresso` e 65 `rifiuto` con motivazione «esito non ancora noto», preset AGGRESSIVE impostato dalla finestra, quattro run; `baskets_state.json` con `killSwitched = true` («kill-switch dalla finestra») e picco 149 552. L'equity registrata sale da 109 228 a 149 517 USD in circa ventuno scatti di ≈ +2 000 USD, uno per ogni esecuzione ridotta dal server: gli «accrediti» erano crediti virtuali del demo per ordine, non depositi. Post-mortem aggiornato. |
| D-37 | Confermi che la chiusura in blocco del 21/9 alle 13:26 UTC l'hai fatta tu dalla piattaforma? | sì | **Risposta dell'utente (2026-09-23)**: «Sì, l'ho fatta io». Chiuso. |
+18 -5
View File
@@ -14,6 +14,12 @@ Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e c
| Basket aperti | 2 / 3 / 5 (preset) | `strategy.json` → preset o `maxBaskets` | operatore |
| Un solo basket per cross sintetico | `Exclusive` | `strategy.json``sameCrossPolicy` | operatore (`Half` dimezza la size di entrambi) |
| Leva effettiva massima | 10:1 sul nozionale complessivo | `strategy.json``maxEffectiveLeverage` | operatore, tetto 30 |
| Margine per basket | il margine delle due gambe (nozionale / leva dell'ordine) ≤ **12 %** dell'equity; la size è il **minimo** fra la size a rischio e quella a margine e il ledger scrive quale vincolo ha deciso (`sizing_bound`) e il margine previsto (`marginUsd`) | `strategy.json``risk.maxMarginPerBasketPct` | operatore |
| Margine totale | margine impegnato dopo l'apertura ≤ **40 %** dell'equity | `strategy.json``risk.maxMarginUsePct` | operatore |
| Buffer di cassa | `available ≥ (marginA + marginB) × 1,25` prima di inviare A (entra nel calcolo della size); `available ≥ marginB × 1,25` ricontrollato con A già sul conto prima di inviare B, altrimenti A viene richiusa (`leg_risk_unwind`) | `strategy.json``risk.marginBufferPct` | operatore |
| Esposizione minima | se la size a margine scende sotto i 1000 USD di esposizione minima di eToro su una gamba, niente ingresso (`min_exposure`) | codice + eligibility | nessuno |
| Ordine dei segnali | più segnali sulla stessa barra vengono eseguiti uno alla volta per \|z\| decrescente, rileggendo il conto prima di ciascuno: il secondo basket vede il margine impegnato dal primo | codice | nessuno |
| Margin guard | `equity / margine usato < 1,5` → niente nuove entrate (`margin_guard`); `< 1,2` → chiusura del basket con il P&L peggiore (riga `margin_guard` nel ledger) | `strategy.json``risk.marginCallBlockRatio`, `risk.marginCallCloseRatio` | operatore |
| Leva dichiarata per gamba | 10 | `strategy.json``orderLeverage` | operatore; la leva effettiva resta governata dal sizing |
| Stop nativo su ogni gamba | sì, sempre (eToro lo richiede su short e leva > 1) | codice (`BasketExecutor.Request`) | nessuno; la distanza deriva da `maxLossPerBasketPct` entro i limiti di eligibility |
| Cost gate | TP ≥ 3 × costo; spread ≤ 2 × mediana 24 h | `strategy.json``costMultiple`, `spreadMedianMultiple` | operatore |
@@ -24,13 +30,20 @@ Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e c
| API in errore | 5 letture consecutive fallite → niente nuove entrate finché non risponde | codice | nessuno |
| Quotazione vecchia | > 15 s → niente nuove entrate | codice (`BasketEngine.MaxQuoteAgeSeconds`) | nessuno |
| Qualità dati | buco > 2 h feriale o salto > 8 σ → decisioni sospese su quella barra | codice | nessuno |
| Leg-risk | seconda gamba non eseguita entro `legTimeoutSec` (5 s) → chiudi subito la prima, basket in pausa 1 h | `strategy.json``legTimeoutSec` (pausa: codice) | operatore (timeout) |
| Gamba orfana | una gamba sparisce dal conto → l'altra viene chiusa alla riconciliazione successiva | codice | nessuno |
| Leg-risk | seconda gamba **rifiutata** → chiudi subito la prima (`leg_risk_unwind`), basket in pausa 1 h. Seconda gamba **senza esito** entro `legTimeoutSec` (5 s) → basket in `PendingB`: il registro degli ordini continua a chiedere; eseguita → basket aperto; rifiutata → prima gamba richiusa | `strategy.json``legTimeoutSec` (pausa: codice) | operatore (timeout) |
| Ordine dall'esito ignoto | mai abbandonato: registrato in `data/state/pending_orders.json` **prima** dell'invio; esito chiesto per `orderId` (ogni 2 s nel primo minuto, poi ogni 10 s, poi ogni minuto) e riconosciuto anche dalla posizione comparsa sul conto (stesso strumento e verso, entro 90 s); una gamba A senza esito porta il basket in `PendingA` (nessun nuovo ordine su quel basket); all'avvio i pendenti si risolvono prima di qualsiasi decisione | codice (ADR-0009) | nessuno |
| Gamba A eseguita in ritardo | segnale ancora valido e nessun blocco → gamba B (ridimensionata sulle unità eseguite di A); altrimenti chiusura immediata di A (`leg_risk_unwind`) | codice | nessuno |
| Gamba orfana del bot | una gamba di un basket sparisce dal conto → l'altra viene chiusa alla riconciliazione successiva. Una posizione che porta la firma del bot (id nel registro, oppure strumento + verso + orario entro 90 s da una riga `segnale_ingresso`/`rifiuto`/`ingresso`/`pending` del ledger) ma non appartiene a nessun basket è `orfana-bot`: **adottata e chiusa** (tre tentativi, poi entrate bloccate con avviso). Contatore «orfane» in dashboard, rosso se > 0 | codice; `--bonifica` all'avvio la elenca e chiede conferma per ognuna | operatore (bonifica) |
| Movimenti di cassa | un salto del saldo non spiegato dalle chiusure (oltre 10 USD e 0,25 %) è un deposito o un prelievo: scritto nel ledger come `movimento_di_cassa`, escluso dal P&L, dal picco di equity e dal drawdown | codice (`EquityTracker`) | nessuno |
| Posizioni non riconciliate | se il P&L aperto del conto e la somma delle posizioni non tornano (oltre 5 USD e 1 %) per più di 60 s, o un basket ha una gamba che il conto non mostra: avviso «posizioni non riconciliate» (banner giallo, riga di stato) | codice | nessuno |
| Chiusura incompleta | una gamba non chiude dopo 3 tentativi → stato `Error`, entrate bloccate, allarme | codice | nessuno; si risolve a mano sul conto e con la riconciliazione |
| Kill-switch | pulsante con conferma; file `STOP` in `Documenti\Encelado` (controllato ogni 5 s) | codice | operatore; il reset richiede di rimuovere il file e una motivazione |
| Posizioni sconosciute sul conto | segnalate una volta nel log, **mai toccate** | codice | nessuno |
| Kill-switch | pulsante con conferma, `kill` da console, file `STOP` in `Documenti\Encelado` (controllato ogni 5 s): blocco delle entrate, annullamento degli ordini senza esito (attesa ≤ 30 s), chiusura di **tutte le posizioni del bot** (basket, gambe in attesa, orfane; tre tentativi per gamba), esterne solo se chiesto o `risk.closeForeignOnKill`, **verifica di piattezza** sul conto (≤ 120 s); ciò che resta è `Halted-Residuo` con l'elenco, e nessuna dichiarazione di «tutto chiuso» senza la verifica | codice (ADR-0009, §9 del piano 5.0) | operatore |
| Recupero dopo inattività | oltre `recovery.thresholdMinutes` (10) senza heartbeat o senza cicli: entrate bloccate, ordini pendenti risolti, riconciliazione, riscaldamento con le barre perse, ogni basket aperto rivalutato con le regole di uscita ordinarie (con le barre di inattività nel time-stop) → chiudi o tieni; orfane chiuse, esterne riportate; rapporto `reports/recupero_<run_id>.csv`; entrate riaperte dopo `recovery.warmupMinutes` (15); a mercato chiuso niente di irreversibile | `strategy.json``recovery` | operatore |
| Una sola istanza | `data/state/instance.lock` tenuto in esclusiva: un secondo bot sulla stessa cartella dati non parte | codice | nessuno |
| Ripristino | procedura in cinque passi: stato, rimozione del file `STOP`, motivazione ≥ 10 caratteri (`correzione`), riconciliazione + riscaldamento + picco di equity al netto dei movimenti di cassa, ripartenza con entrate bloccate per `recovery.warmupMinutes`; rifiutato (`reset_rifiutato`) finché una posizione del bot resta sul conto | codice | operatore |
| Posizioni esterne | posizioni senza la firma del bot: segnalate una volta nel log, contate in dashboard, **mai toccate** (dalla Fase 3: chiuse dal kill-switch solo con `risk.closeForeignOnKill` o con la spunta esplicita) | codice | operatore |
| Chiavi API | solo `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o `ETORO_API_KEY`/`ETORO_USER_KEY`; mai nel repo (`.gitignore`: `*.local.json`, `.env`) | codice | operatore |
| Ambiente visibile | badge `PAPER/DEMO/LIVE` nella barra, nel log e nel ledger (`mode`) | codice | nessuno |
| Controlli all'avvio | chiavi (profilo), orologio, strumenti e limiti, conto, riconciliazione, calendario | codice | nessuno; se falliscono il bot resta in sola lettura o non parte |
| Averaging | `Off` in live; `AddOnce` ammesso in paper; moltiplicatore di lotto 1,0 | `strategy.json``averagingMode`, `lotMultiplier` (max 1,5, solo backtest) | operatore |
| Parametri cambiati dal bot | mai. Le proposte vanno in `knowledge/proposals.csv` e passano dal forward test | codice | operatore |
| Parametri cambiati dal bot | mai. Le proposte vanno in `knowledge/proposals.csv` e passano dal forward test. Dalla 5.0 anche il bandit **propone soltanto**: fino alla 4.0.0 applicava il preset da solo in Paper e Demo (D-30) | codice | operatore |
+95 -34
View File
@@ -1,15 +1,20 @@
# Runbook
Aggiornato: 2026-09-16. Come si avvia, si ferma, si sblocca e si ripara il bot. I file dell'operatore stanno in `Documenti\Encelado\`; le chiavi in `%LOCALAPPDATA%\Encelado\etoro.dat`.
Aggiornato: 2026-09-23 (5.0). Come si avvia, si ferma, si sblocca e si ripara il bot. Dalla 5.0 il bot gira nel container (`docs/DOCKER.md`, `deploy/unraid/`): i file dell'operatore stanno in `/config` (configurazione, chiavi cifrate, file `STOP`) e `/data` (ledger, stato, log). Fuori dal container, solo per lo sviluppo, valgono `Documenti\Encelado` e le stesse regole.
## Prima volta
1. Avvia `Encelado.exe`. Vengono creati `Documenti\Encelado\encelado.json` (configurazione) e `strategy.json` (strategia) dalle copie di fabbrica.
2. La finestra chiede le due chiavi di eToro Public API (`x-api-key` e `x-user-key`, dal portale sviluppatori; demo e reale hanno chiavi diverse). Le verifica con due letture (profilo e conto) e le salva cifrate con DPAPI. Da quel momento il bot parte da solo, anche in `--headless`.
3. Controlla in **Impostazioni**: ambiente `demo`, modalità `Demo`, fuso orario.
4. Premi **AVVIA**.
1. Avvia il container (template Unraid o `docker run`, vedi `docs/DOCKER.md`) con `ENCELADO_WEB_TOKEN` impostato. Al primo avvio vengono creati `encelado.json` e `strategy.json` in `/config` dalle copie di fabbrica.
2. Apri `http://<ip>:8080/`, inserisci il token (resta in un cookie per trenta giorni).
3. Chiavi eToro Public API (`x-api-key` e `x-user-key`, dal portale sviluppatori; demo e reale hanno chiavi diverse): o nelle variabili `ETORO_API_KEY`/`ETORO_USER_KEY` del container (hanno la precedenza), oppure da **Impostazioni ▸ Chiavi eToro** con `ENCELADO_KEY_PASSPHRASE` impostata: il server le verifica con due letture (profilo e conto) e le salva cifrate in `/config/etoro.keys.enc`.
4. Controlla in **Impostazioni**: ambiente `demo`, modalità `Demo`, fuso orario (o `TZ` del container), valuta di visualizzazione.
5. Con `ENCELADO_AUTOSTART=1` (default) il motore è già partito; altrimenti premi **AVVIA**.
In alternativa alle chiavi salvate: variabili d'ambiente `ETORO_API_KEY` e `ETORO_USER_KEY` (hanno la precedenza), utili su un VPS.
Per Telegram: `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` (vedi la sezione Telegram).
## Sviluppo sul PC
Per vedere e provare il bot senza Unraid: **F5** in VS Code (`Encelado (server)`), `scripts\run-dev.ps1` o `scripts\run-docker.ps1` (il container dalla compose). Tutti usano la sandbox `deploy/local/` come `/config` e `/data`; dettagli in `docs/DOCKER.md`. Con `--sample` (o `Encelado (campione)`) la pagina mostra dati finti senza chiavi.
## Modalità
@@ -17,64 +22,120 @@ In alternativa alle chiavi salvate: variabili d'ambiente `ETORO_API_KEY` e `ETOR
|---|---|---|
| `Paper` | simulatore locale sopra le quotazioni reali (`data/state/paper_state.json`) | nessuna |
| `Demo` (default) | conto demo eToro, denaro virtuale | nessuna |
| `Live` | conto reale | `run.allowLive = true` **e** la frase `CONFERMO LIVE` (finestra) o `--confirm-live "CONFERMO LIVE"` (headless) |
| `Live` | conto reale | `run.allowLive = true` **e** la frase `CONFERMO LIVE` (dialogo di AVVIA, oppure `ENCELADO_CONFIRM_LIVE` per l'avvio automatico, oppure `--confirm-live`) |
In ogni modalità il bot apre e chiude da solo (decisione D-20). Il badge in alto a destra dice sempre in che ambiente sei.
In ogni modalità il bot apre e chiude da solo (decisione D-20). Il chip in basso a sinistra del rail dice sempre in che ambiente sei (`PAPER` blu, `DEMO` giallo, `LIVE` rosso).
## Headless (VPS, test lunghi)
## Console e comandi
```powershell
Encelado.exe --headless [--minutes 240] [--confirm-live "CONFERMO LIVE"]
```
Il processo stampa il log su stdout (`docker logs -f encelado`) e una riga di stato ogni `run.statusSeconds`. Con un terminale collegato (`docker attach`, o il processo lanciato a mano) accetta comandi da tastiera: `status`, `close <basket>`, `kill`, `residuo [id]`, `preset <nome>`, `bonifica`, `reset <motivazione>`, `stop`. Gli stessi comandi esistono nell'interfaccia (pulsanti e dialoghi) e in Telegram. Argomenti: `--no-autostart`, `--minutes N` (arresto automatico), `--bonifica`, `--confirm-live "CONFERMO LIVE"`, `--port N`, `--sample`, `--health`.
Log sulla console e nel file; una riga di stato ogni `run.statusSeconds`. Comandi da tastiera: `status`, `close <basket>`, `kill`, `preset <nome>`, `reset <motivazione>`, `stop`. Variabile `ENCELADO_EXECUTION_MODE` per forzare la modalità senza toccare il file.
**Una sola istanza per cartella di lavoro**: due bot sullo stesso conto e sullo stesso ledger si contendono le posizioni. Prima di aprire la finestra mentre gira l'headless, fermalo.
**Una sola istanza per cartella dati**, imposta dal lock `data/state/instance.lock`: un secondo processo sulla stessa cartella viene rifiutato con il pid del primo.
## Fermare
- **FERMA** nella finestra, `stop` in headless, Ctrl+C. I basket aperti **restano sul conto** con gli stop nativi (`run.closeOnShutdown = false`): nessuno applica TP e stop di basket finché il bot non riparte, che li riprende dallo stato salvato e dalla riconciliazione.
- **FERMA** nell'interfaccia, `stop` da console, `docker stop` (SIGTERM, 45 s di grazia), Ctrl+C. I basket aperti **restano sul conto** con gli stop nativi (`run.closeOnShutdown = false`): nessuno applica TP e stop di basket finché il bot non riparte, che li riprende dallo stato salvato e dalla riconciliazione (e, se è passato più di dieci minuti, con il recupero dopo inattività).
- Con `run.closeOnShutdown = true` la fermata chiude tutto a mercato.
## Kill-switch
Chiude tutte le gambe a mercato e blocca le nuove entrate. Tre modi: il pulsante **KILL-SWITCH** nella dashboard (chiede conferma), `kill` in headless, oppure un file chiamato `STOP` nella cartella `Documenti\Encelado` (controllato a ogni ciclo; utile da remoto). Il blocco resta finché non fai un **reset**.
Tre modi: il pulsante **KILL-SWITCH** nella barra in alto (chiede conferma e, se ci sono posizioni esterne, se chiudere anche quelle: default no), `kill` da console o `/kill CONFERMO` da Telegram, oppure un file chiamato `STOP` nella cartella `/config` (controllato ogni 5 s; utile da remoto: `touch /mnt/user/appdata/encelado/config/STOP`). La procedura è la stessa per i tre e **non dichiara niente chiuso senza averlo riletto dal conto**:
## Equity stop e reset
1. blocco immediato di ogni nuova entrata, riga `kill_switch_avviato` nel ledger;
2. annullamento degli ordini nel registro senza esito (`DELETE …/orders/{id}`), poi attesa della loro risoluzione fino a 30 s: un ordine eseguito nel frattempo diventa una posizione da chiudere;
3. chiusura di **tutte le posizioni del bot**: gambe dei basket (tre tentativi per gamba con verifica sul conto), gambe in attesa, orfane-bot; le posizioni esterne solo se lo hai chiesto o se `risk.closeForeignOnKill = true`;
4. verifica di piattezza: il conto viene riletto finché le posizioni del bot non sono sparite (fino a 120 s);
5. riga `kill_switch_concluso` con l'esito, stato salvato, notifica.
Quando l'equity scende del 9 % dal picco (`equityStopPct`) il bot chiude tutto e si blocca: banner rosso nella dashboard, riga `equity_stop` nel ledger. Per ripartire: **Sblocca…** nel banner, oppure `reset <motivazione>` in headless. La motivazione (almeno dieci caratteri) finisce nel ledger come riga `correzione`; il picco riparte dall'equity corrente. Non si sblocca senza scrivere perché.
Se qualcosa resta sul conto lo stato è **`Halted-Residuo`**: banner rosso con l'elenco, chip «bloccato · residuo», il reset è rifiutato finché non è piatto. Per riprovare: **Sblocca…** nel banner (il ripristino offre «Chiudi ora» sui residui), `residuo` da console (`residuo <id>` per una sola posizione), oppure chiusura a mano su eToro; la riconciliazione se ne accorge. Il blocco resta finché non fai un **reset**.
La perdita giornaliera del 3 % (`dailyLossPct`) blocca solo le nuove entrate fino alla mezzanotte UTC e non richiede reset.
## Equity stop
Quando l'equity (al netto dei movimenti di cassa) scende del 9 % dal picco (`equityStopPct`) il bot chiude tutto e si blocca: banner rosso nella dashboard, riga `equity_stop` nel ledger. Per ripartire serve il reset. La perdita giornaliera del 3 % (`dailyLossPct`) blocca solo le nuove entrate fino alla mezzanotte UTC e non richiede reset.
## Ripristino (reset) in cinque passi
**Sblocca…** nel banner o **Avvia il ripristino…** in Impostazioni, `reset <motivazione>` da console, `/reset <motivazione>` da Telegram. Il reset è una procedura, non un interruttore; il dialogo mostra i cinque passi e l'esito di ciascuno, che finisce anche nel ledger:
| Passo | Che cosa fa | Se fallisce |
|---|---|---|
| 1. stato | riporta il motivo del blocco, i residui, gli ordini senza esito, le entrate bloccate | — |
| 2. file STOP | lo rimuove da solo; se ricompare il kill-switch riparte | «file STOP non rimovibile»: permessi della cartella `/config` (PUID/PGID) |
| 3. motivazione | almeno dieci caratteri, scritta nel ledger come `correzione` | «motivazione mancante»: riscrivila |
| 4. riconciliazione | rilegge il conto, verifica che nessuna posizione del bot sia rimasta, riscalda le serie dall'API, riporta il picco di equity all'equity corrente (al netto dei movimenti di cassa) | «residui ancora sul conto» → resta `Halted-Residuo` (riga `reset_rifiutato`): chiudili con «Chiudi ora» / `residuo` / a mano e ripeti |
| 5. ripartenza | toglie il blocco; **entrate bloccate per 15 minuti** di riscaldamento (`riscaldamento dopo il reset` nel banner giallo), poi il bot riprende da solo | — |
Sintomo → passo:
| Sintomo | Passo da guardare |
|---|---|
| banner «KILL-SWITCH CON RESIDUO» | 4: chiudi i residui, poi ripeti il reset |
| «rimuovi prima il file STOP» / «file STOP non rimovibile» | 2: `/config` non è scrivibile dall'utente del container o il file viene ricreato da un altro processo |
| «motivazione mancante» | 3 |
| dopo il reset il bot non apre per un quarto d'ora | 5: è il riscaldamento voluto |
| dopo il reset compare «posizioni non riconciliate» | 4 non è andato a buon fine sul conto: confronta `pending_orders.json` e le posizioni su eToro (pagina Storico ▸ Posizioni) |
## Ordini senza esito
eToro lavora gli ordini in modo asincrono e a volte non risponde al lookup. Il bot non dimentica mai un ordine: ogni invio è scritto in `data/state/pending_orders.json` **prima** della chiamata, l'esito viene chiesto per `orderId` e, se il server non lo trova, ricostruito dalla posizione comparsa sul conto. Un basket con una gamba senza esito compare in dashboard come «attesa gamba A/B» e nel contatore «in attesa»: non manda altri ordini finché il registro non lo risolve. Alla risoluzione: gamba A eseguita e segnale ancora valido → parte la gamba B; segnale decaduto → la gamba A viene richiusa subito (`leg_risk_unwind`); rifiutata → il basket torna libero. Se un ordine resta senza esito per più di dieci minuti il log lo ripete ogni dieci minuti: guarda Storico ▸ Ordini (esito `Pending`) e, se serve, la posizione su eToro; non c'è niente da fare a mano finché la gamba non compare sul conto, e quando compare il bot la gestisce. All'avvio i pendenti del run precedente vengono risolti prima di qualsiasi decisione.
## Riconciliazione
Ogni 20 secondi il bot rilegge conto e posizioni. Una gamba sparita dal conto (chiusa a mano, stop nativo) fa chiudere l'altra; una posizione sconosciuta viene segnalata e ignorata; una chiusura incompleta dopo tre tentativi mette il basket in stato `Error` e blocca le nuove entrate (banner giallo) finché non è risolta sul conto: chiudi la gamba a mano su eToro, la riconciliazione successiva la vede e sblocca.
Ogni 20 secondi il bot rilegge conto e posizioni e **classifica ogni posizione**: `basket` (gamba nota), `orfana-bot` (aperta dal bot ma senza basket: id nel registro degli ordini, oppure strumento, verso e orario coerenti con una decisione del ledger entro 90 s), `esterna` (tutto il resto). Una gamba di basket sparita dal conto (chiusa a mano, stop nativo) fa chiudere l'altra; un'orfana-bot viene **adottata e chiusa** (riga `orfana_adottata` e `orfana_chiusa` nel ledger, riga in `baskets.csv` con `exit_reason = orphan_closed`; dopo tre tentativi falliti le entrate si bloccano e il banner lo dice: chiudila a mano su eToro); un'esterna viene segnalata una volta, contata e mai toccata. Una chiusura incompleta dopo tre tentativi mette il basket in stato `Error` e blocca le nuove entrate finché non è risolta sul conto. Se il P&L aperto del conto e quello delle posizioni non tornano per più di un minuto compare «posizioni non riconciliate»: di solito è un'esecuzione in corso; se persiste, confronta `pending_orders.json` con le posizioni su eToro.
Un deposito o un prelievo sul conto (anche l'accredito di fondi virtuali del demo) viene riconosciuto dal salto del saldo non spiegato dalle chiusure e scritto nel ledger come `movimento_di_cassa`: non è P&L, non muove il picco di equity né il drawdown; nello Storico compare come riga «movimento di cassa» e nei periodi è una colonna a parte.
## Recupero dopo inattività (riavvio, aggiornamento dell'immagine, container fermo)
Il bot scrive `data/state/heartbeat.json` ogni 30 s (`recovery.heartbeatSeconds`). All'avvio legge quello precedente e a ogni ciclo misura il tempo dall'ultimo giro: oltre `recovery.thresholdMinutes` (10) parte il **recupero**, nell'ordine: entrate bloccate e riga `recupero_avviato`; ordini senza esito risolti; riconciliazione con classificazione (le orfane si chiudono, le esterne si riportano); serie riscaldate dall'API con le barre perse e `barsHeld` di ogni basket aperto aumentato delle barre trascorse; ogni basket aperto rivalutato come a una chiusura di barra ordinaria (stop di z, perdita massima, time-stop con le barre di inattività, correlazione rotta, spread anomalo): **chiudi** o **tieni**; rapporto `reports/recupero_<run_id>.csv` (una riga per posizione: `posizione;basket;decisione;motivo;z;rho;barsHeld;pnl;motivazione`), riga `recupero_concluso`, notifica; entrate riaperte dopo `recovery.warmupMinutes` (15) di quotazioni. Se il mercato è chiuso (fine settimana) il recupero aspetta le prime quotazioni fresche e lo dice ogni dieci minuti: niente di irreversibile a mercato chiuso. Un recupero fallito lascia le entrate bloccate con il motivo: guarda il log e fai un reset.
## Telegram
Configurazione in `encelado.json``notifications.telegram` (`enabled`, `chatId`, `hourlyStatus`, `eventAlerts`, `dailySummaryUtcHour`, `commands`) e in Impostazioni → Notifiche. Il token del bot va **solo** nella variabile d'ambiente `TELEGRAM_BOT_TOKEN` (la chat in `TELEGRAM_CHAT_ID`, che ha la precedenza sul file). Per creare il bot: `@BotFather``/newbot` → token; per il chat id: scrivi al bot e leggi `chat.id` da `https://api.telegram.org/bot<token>/getUpdates`, oppure `@userinfobot`. Cosa arriva:
- **stato ogni ora** (allo scoccare dell'ora UTC): attivo/fermo/bloccato, modalità, preset, uptime, equity/saldo/disponibile/margine, P&L di oggi e aperto (conto e basket), drawdown dal picco, basket aperti con z/pip/P&L/durata, in attesa, ordini pendenti, orfane, esterne, blocchi, prossimo evento, stato API e quote, stato del canale;
- **eventi**: avvio e arresto del bot, basket aperto e chiuso (con motivo e P&L), gamba in attesa, ordine pendente risolto, unwind, gamba orfana, kill-switch (con esito e residui), equity stop, perdita giornaliera, margin guard, recupero dopo inattività, reset, cambio preset, API in errore persistente, scarto orologio;
- **riepilogo giornaliero** all'ora impostata (21 UTC): basket chiusi, vinti, lordo, costi, netto, drawdown, motivi di uscita.
Comandi accettati **solo dalla chat autorizzata** (ogni altro mittente viene ignorato e annotato nel log; ogni comando eseguito finisce nel ledger come `comando`): `/stato`, `/posizioni`, `/storico 7d` (anche `1d`, `30d`), `/pausa` (blocca le entrate, le uscite restano attive), `/riprendi`, `/chiudi <basket>`, `/kill CONFERMO` (senza la parola non fa niente), `/reset <motivazione>` (almeno dieci caratteri); tutto il resto risponde «comando non riconosciuto». Il polling usa un solo task con `getUpdates` e non tocca le quote di eToro; gli invii sono al massimo uno al secondo con tre tentativi (2, 5, 15 s; su 429 rispetta `retry_after`). La card «Telegram» della dashboard e la riga di stato del log riportano lo stato del canale (ultimo invio, coda, falliti, ultimo errore).
## Bonifica delle gambe orfane
Una tantum, dopo un'anomalia: **Impostazioni ▸ Diagnostica ▸ Bonifica: elenca le orfane** mostra ogni posizione del conto con origine, P&L e motivo della classificazione e offre **Chiudi** su ciascuna orfana-bot; oppure `--bonifica` all'avvio (il motore parte senza chiudere le orfane da solo e chiede `chiudere? [s/N]` da console) o `bonifica` da console. Ogni chiusura confermata scrive una riga in `baskets.csv` (`exit_reason = bonifica_orfana`, P&L dallo storico) e in `reports/bonifica_YYYYMMDD.csv`. Alla fine il bot torna a chiudere le orfane da solo. Al 2026-09-23 il conto demo è piatto: non c'è niente da bonificare.
## Errori API
| Sintomo | Cosa fa il bot | Cosa fare |
|---|---|---|
| 401/403 | avvio fallito, "eToro ha rifiutato le chiavi" | rigenera le chiavi sul portale, reinseriscile da Impostazioni |
| 401/403 | avvio fallito, "eToro ha rifiutato le chiavi" | rigenera le chiavi sul portale, reinseriscile da Impostazioni ▸ Chiavi eToro o nelle variabili del container |
| 429 | rispetta `Retry-After`, rallenta | niente; se persiste alza `run.pollSeconds` |
| 5 letture consecutive fallite | banner "entrate bloccate", uscite attive | aspetta; controlla rete e stato di eToro |
| scarto orologio > 5 s | banner, entrate bloccate | sincronizza l'ora di Windows |
| scarto orologio > 5 s | banner, entrate bloccate | sincronizza l'ora dell'host (il container eredita l'orologio) |
| quotazioni più vecchie di 15 s | niente nuove entrate | come sopra |
| `/api/health` rosso (`docker inspect`) | il container viene riavviato da `--restart` se il processo muore; un healthcheck rosso con processo vivo dice cosa manca (heartbeat vecchio, disco non scrivibile) | `docker logs`, poi `docker restart` |
## Feed
Calendario e notizie sono in cache su disco (`data/cache`) e vengono riletti ogni 10 minuti con attese crescenti dopo un errore. Un feed che non risponde non ferma il bot: senza calendario non c'è blackout, senza notizie il sentiment è 0, e il log lo dice. Google News blocca via `robots.txt` le ricerche RSS: quelle fonti vivono solo di cache (vedi `docs/DATA_SOURCES.md`).
## Storico
**Storico ordini** nell'interfaccia: *Ordini* da `orders.jsonl` (unità richieste ed eseguite — se differiscono il server ha ridotto l'ordine —, prezzi, slippage, stato, esito, id di ordine e posizione), *Posizioni* dallo storico eToro classificate per origine, *Profitti per periodo* con la curva dell'equity realizzata. Ogni vista si esporta in CSV (`;`, colonna `motivazione`). Senza chiavi la pagina mostra solo i dati del ledger e lo dice.
## File utili
| Cosa | Dove |
| Cosa | Dove (container) |
|---|---|
| log | `Documenti\Encelado\logs\encelado.log` (CSV `;`) |
| ledger | `data\ledger\decisions.jsonl`, `data\ledger\baskets.csv` |
| stato | `data\state\baskets_state.json` (ripreso all'avvio) |
| barre | `data\market\candles_<SYMBOL>_M15.csv` |
| modelli | `data\models\` |
| conoscenza | `knowledge\` |
| strumenti | `instruments.json` accanto alla configurazione |
| log | `/data/logs/encelado.log` (CSV `;`) e `docker logs` |
| ledger | `/data/data/ledger/decisions.jsonl`, `baskets.csv`, `orders.jsonl` |
| stato | `/data/data/state/baskets_state.json` (ripreso all'avvio), `pending_orders.json` (registro degli ordini), `heartbeat.json`, `instance.lock` |
| barre | `/data/data/market/candles_<SYMBOL>_M15.csv` |
| modelli | `/data/data/models/` |
| conoscenza | `/data/knowledge/` |
| rapporti | `/data/reports/` (qualità dati, bonifica, recupero) |
| configurazione, strumenti, chiavi, STOP | `/config/encelado.json`, `strategy.json`, `instruments.json`, `etoro.keys.enc`, `STOP` |
Fuori dal container gli stessi percorsi stanno sotto `Documenti\Encelado` (`data\…`, `knowledge\…`, `logs\…`).
## Checklist prima del Live (§9.4 della specifica)
@@ -84,9 +145,9 @@ Tutte vere, altrimenti no:
- [ ] il P&L netto del forward test è positivo con PSR ≥ 0,95 sulla metrica pre-registrata in `knowledge/preregistrazione.csv`;
- [ ] nessun test di falsificazione contraddice il risultato (oggi `reports/falsificazione.csv` dice il contrario: vedi `docs/STRATEGY.md`);
- [ ] `run.allowLive = true`, ambiente `real`, chiavi del reale inserite e verificate;
- [ ] conto reale capiente rispetto a `riskPerBasketPct` e all'esposizione minima di 1000 USD per gamba (con 193 USD non lo è);
- [ ] la frase `CONFERMO LIVE` scritta all'avvio.
- [ ] conto reale capiente rispetto a `riskPerBasketPct` e all'esposizione minima di 1000 USD per gamba (con 224,90 USD non lo è);
- [ ] la frase `CONFERMO LIVE` scritta all'avvio (dialogo o `ENCELADO_CONFIRM_LIVE`).
## Aggiornare
L'installatore conserva `Documenti\Encelado` e le chiavi. Se dopo un aggiornamento il log segnala "chiavi di una versione precedente", da Impostazioni → **Ripristina i valori predefiniti** (backup automatico con la data accanto al file).
`docker pull` dell'immagine nuova e riavvio del container (su Unraid: *Controlla aggiornamenti*). `/config` e `/data` restano; il riavvio è un'inattività come le altre e passa dal recupero. Se dopo un aggiornamento il log segnala una configurazione di una versione precedente, da Impostazioni → **Ripristina i valori predefiniti** (backup automatico con la data accanto al file) e poi rimetti i valori tuoi.
+19 -21
View File
@@ -1,34 +1,32 @@
# Stato del lavoro
Aggiornato: 2026-09-16 (fine della seconda sessione, rilascio 4.0.0).
Aggiornato: 2026-09-23 (sessione 5.0, Fasi 6-9 concluse: il piano 5.0 è completo).
## Fase in corso
**Forward test in Demo.** Il codice copre le fasi 0-7 della specifica; la strategia è in esercizio autonomo sul conto demo di eToro per accumulare basket nel ledger. Il backtest è negativo (`docs/STRATEGY.md`): il Demo misura, non guadagna.
**Piano 5.0 concluso** (`docs/PIANO_5.0.md`): tutte le fasi 0-9 sono fatte. Il bot è `Encelado.Server` (Kestrel + motore + interfaccia web incorporata) e gira nel container (ADR-0007, ADR-0008); la finestra WPF non esiste più (D-28). Versione di sviluppo 5.0.0 in `Directory.Build.props`; **il rilascio 5.0.0 non è ancora stato fatto** (tag, immagine sul registro di Gitea, release): lo decide l'utente con `dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=5.0.0` dopo il push del ramo.
## Fatto nell'ultima sessione (2026-09-16, pomeriggio)
## Fatto nell'ultima sessione (2026-09-23, Fasi 6-9)
- **Rework completo del codice**: rimossi Binance, Alpaca, cTrader/proba, SQLite, GBDT, RL, TA-Lib, indicatori e backtest a coppie (ADR-0004). Restano Core (basket, broker, notizie, statistica), Etoro, Bot, strumento di ricerca. Nessun pacchetto NuGet nell'applicazione. Test da 322 a 172, tutti verdi.
- **Niente approvazioni manuali** (decisione dell'utente, D-20, ADR-0005): modalità `Paper` / `Demo` (default) / `Live`; coda delle approvazioni rimossa; il Live conserva `run.allowLive` e la frase `CONFERMO LIVE`.
- **Interfaccia rifatta**: barra in alto con tre schede (Dashboard, Log, Impostazioni), stato, ambiente, ora nel fuso scelto, AVVIA; dashboard con i cinque numeri, la tabella dei basket, tre riquadri di contesto e l'attività. Tema nuovo. Test di rendering in PNG (`UiRenderTests`).
- **Fuso orario** selezionabile (`ui.timeZone`, default `computer`, elenco dei fusi di Windows in Impostazioni, `ENCELADO_TIME_ZONE`).
- **Bug corretto**: l'endpoint dei costi di eToro usa il campo `value`; markup e overnight risultavano 0 (D-24). Overnight osservato 0,9 pip/gamba/giorno.
- **Apprendimento collegato al motore**: `LearningState` (logistica in ombra, MLP challenger, bandit, ciclo settimanale, `knowledge/`), previsione di volatilità per basket, feature dal ledger. Standardizzatore adattato all'insieme di addestramento prima del fit dell'MLP (difetto trovato dal test sul cerchio).
- **Backtest completato**: test di falsificazione 5 (segnale invertito) e scenario di costi `api`; `docs/STRATEGY.md` con i numeri e il verdetto negativo.
- Documenti: `STRATEGY.md`, `ML_AND_LEARNING.md`, `RUNBOOK.md`, `GLOSSARY.md`, `KNOWN_ISSUES.md`, ADR-0004, ADR-0005; aggiornati `ARCHITECTURE.md`, `RISK_RULES.md`, `QUESTIONS.md` (D-17…D-25), `DATA_SOURCES.md`, `LEDGER_SCHEMA.md`, `CLAUDE.md`, catena di rilascio.
- Sessione di test autonoma in Demo avviata alle 12:56 UTC (4 ore, `--headless`): connessione stabile, nessun ingresso (ρ_W fra 0,13 e 0,42 contro la soglia 0,6; z massimo 1,91 contro 2,0).
- **Fase 6 — Engine + Server**: `src/Encelado.Bot``src/Encelado.Engine` (libreria, senza UI); `src/Encelado.Server` (Sdk.Web, nessun NuGet) con `WebHost` (token in cookie HttpOnly o Bearer, senza token solo localhost), API JSON scritta a mano, SSE `/api/stream` a uno snapshot al secondo, `LogBuffer`, `HistoryService`; chiavi da variabili o file cifrato `etoro.keys.enc` (AES-256-GCM, `ENCELADO_KEY_PASSPHRASE`) al posto di DPAPI; `AppPaths` con `/config` e `/data`; `HeadlessRunner` ridotto a console (stato, comandi, bonifica); `--sample`, `--health`, `--no-autostart`, `ENCELADO_AUTOSTART`, `ENCELADO_CONFIRM_LIVE`.
- **Fase 7 — interfaccia web Material 3** (`Web/wwwroot`, vanilla): navigation rail 80/256 px (drawer sotto 600 px), barra con orologi UTC e fuso, selettore della valuta (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD con tasso dalle quotazioni e tooltip in USD), AVVIA/FERMA, KILL-SWITCH con «chiudi anche le esterne»; dashboard con sei KPI (incluso il margine), tabella dei basket (cards da telefono), card evento/eToro/Telegram, attività, banner; **Storico ordini** (ordini, posizioni classificate, profitti per periodo con curva SVG, CSV); Log con filtri; Impostazioni con configurazione validata, chiavi eToro, ripristino in cinque passi, Ricerca, Diagnostica (con bonifica), Informazioni (versione, build, commit da `AssemblyMetadata`); temi scuro/chiaro. `HistoryBuilder` nel Core; `SnapshotJson`, `SampleSnapshot`, `SettingsService` nell'Engine; comando `learn` nello strumento (`Learn.cs`); `ui.displayCurrency/theme/navExpanded` e coppie di conversione nel polling.
- **Fase 8 — Docker e Unraid**: `Dockerfile` multi-stage (build, **test**, publish, runtime `aspnet:10.0` + gosu + tzdata), `deploy/docker/entrypoint.sh` (TZ, PUID/PGID), `docker-compose.yml`, `.dockerignore`, `HEALTHCHECK --health`; `deploy/unraid/encelado.xml` (Container v2, icona `assets/encelado.png` raw da Gitea, D-29) e README; `build/Release.proj` senza Inno Setup: `Pubblica` framework-dependent, `Docker`, `Pacchetto` (zip + template + tag), `Rilascia` (push dell'immagine sul registro di Gitea + release); VS Code con F5 sul server (`Encelado (server)`, `Encelado (campione)`, backtest) e attività Docker. Immagine `192.168.30.23:3000/alby96/encelado:5.0.0` (346 MB) costruita dalla catena (`-t:Docker -p:Versione=5.0.0`, senza tag) con 210 test verdi nello stadio Linux e provata: avvio, semina di `/config`, permessi 99:100, `/api/health`, token, sonda `--health`, nessun avviso di Kestrel o di `useradd`. Il primo arresto con `docker stop` **non** era pulito (SIGKILL dopo la grazia): corretto con `PosixSignalRegistration` per SIGTERM in `Program.cs` e **verificato**: `docker stop` chiude in 1 s con «SIGTERM: arresto» ed exit 0.
- **Avvio in locale (richiesta dell'utente)**: sandbox `deploy/local/` (`config/` = `/config`, `data/` = `/data`, ignorata da git) usata da F5 (`Encelado (server)`, `ENCELADO_CONFIG_DIR` + `ENCELADO_DATA_DIR`, che ora vale anche fuori dal container), dagli script `scripts/run-dev.ps1|.sh`, `run-docker.ps1|.sh` (compose, token `sviluppo`), `screenshots.ps1|.sh` e dalle attività di VS Code (`avvia in locale (script)`, `avvia in docker`, `ferma docker`, `screenshot`); provati tutti e tre i modi e lo script degli screenshot. `/api/health` in `--sample` non finge più un motore acceso.
- **Fase 9 — documenti e pulizia**: ADR-0007, ADR-0008, `docs/DOCKER.md`, `docs/UI_GUIDELINES.md`, `README.md` con gli screenshot (`docs/img/`), `CLAUDE.md`, ARCHITECTURE, RUNBOOK, KNOWN_ISSUES, GLOSSARY, DATA_SOURCES, ML_AND_LEARNING, QUESTIONS (D-28, D-29, D-36, D-37 risposte), POSTMORTEM (gli «accrediti» erano crediti demo per ogni ordine ridotto), skill aggiornate, `build/README.md`. **Codice morto rimosso**: progetto WPF, DPAPI, test WPF, `UiRenderTests`, ~45 membri mai usati (statistiche di ricerca, `Psi`, `EmptyContextProvider`, `EnvironmentKeyStore`, display helper dello snapshot, `INotifyPropertyChanged` in `SettingField`, …). Test nuovi: `EmbeddedUiTests`, `WebHostTests`, `HistoryBuilderTests`, `KeyStoreTests`; suite a **210 verdi**.
## Prossimi passi
1. Lasciare girare il Demo per settimane; leggere `data/ledger/baskets.csv` e `knowledge/insights_*.md` prima di toccare qualsiasi parametro.
2. Se il ledger mostra che ρ_W ≤ 0,6 non si verifica mai, proporre in `proposals.csv` una soglia diversa **con** una pre-registrazione, non cambiarla a mano.
3. Spezzare `BasketEngine.cs` (~1900 righe) in quote poller, riconciliazione, snapshot.
4. Aggiungere un lock di istanza (un solo bot per cartella di lavoro).
5. Valutare una fonte per SNB e RBNZ che non sia Google News.
1. **Rilascio 5.0.0** (utente): push del ramo, `-t:Rilascia -p:Versione=5.0.0` (richiede Docker acceso, `build/gitea.json` con permesso `package`, registro `192.168.30.23:3000` dichiarato `insecure-registry`), poi installazione su Unraid dal template.
2. Riaccendere il Demo nel container per 24 ore di verifica: contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti, `sizing_bound = margin` sui primi ingressi; kill-switch a mano con verifica di piattezza; fermare il container un'ora con un basket aperto per vedere il recupero; messaggi Telegram reali.
3. Revisione visiva dell'interfaccia con l'utente (gli screenshot sono dal campione `--sample`); eventuali ritocchi ai testi e ai tooltip.
4. Avviso Telegram sui feed fermi da più di un'ora (resta in KNOWN_ISSUES).
5. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`.
## Problemi aperti
- Backtest negativo: la strategia non regge i costi (`docs/STRATEGY.md`, `docs/KNOWN_ISSUES.md`).
- Il conto reale vale 193,18 USD: il Live non è praticabile a prescindere.
- Google News blocca le ricerche RSS via robots.txt; RBA risponde 403 a intermittenza; Fed 404 a tratti.
- Il file di configurazione dell'utente porta ancora `allowDemoAuto` (avviso all'avvio; il ripristino dei valori di fabbrica lo toglie).
- Il disco C: del PC di sviluppo si era riempito (0 byte) a metà sessione; ora ha 12 GB liberi. Le immagini e la cache di build di Docker pesano: `docker system prune` ogni tanto.
- Backtest negativo: la strategia non regge i costi (`docs/STRATEGY.md`).
- Il conto reale vale 224,90 USD: il Live non è praticabile.
- Google News blocca le ricerche RSS; RBA 403 a intermittenza; Fed 404 a tratti.
- Il server non ha TLS e il registro di Gitea è in HTTP (rete locale).
- `Modifiche.txt` nella radice è un appunto dell'utente, modificato e non committato: decidere se tenerlo nel repository.
+1 -1
View File
@@ -20,7 +20,7 @@ In tutti e cinque la valuta comune ha ruoli opposti nelle due coppie, quindi `X
**Cancelli all'ingresso** (§5.3): correlazione rolling `ρ_W ≤ 0,6`, semiperiodo fra 5 e 120 barre, forza di trend sotto soglia, blackout del calendario, fine settimana, cost gate (`TP ≥ 3 × costo`, spread ≤ 2 × mediana 24 h), massimo di basket aperti, un solo basket per cross sintetico, quote fresche, orologio allineato, nessun blocco attivo.
**Sizing** (vol-parity): unità inversamente proporzionali all'ATR di ogni gamba, rischio totale alla distanza dello stop = `riskPerBasketPct` dell'equity (0,5 % Moderate), esposizione minima di eToro 1000 USD per gamba, leva effettiva ≤ 10.
**Sizing** (vol-parity): unità inversamente proporzionali all'ATR di ogni gamba, rischio totale alla distanza dello stop = `riskPerBasketPct` dell'equity (0,5 % Moderate), esposizione minima di eToro 1000 USD per gamba, leva effettiva ≤ 10. **Dalla 5.0** la size è il minimo fra quella a rischio e quella a margine (`risk` in `strategy.json`: 12 % dell'equity per basket, 40 % in totale, disponibile con buffer del 25 %): il 16/9 un solo basket sizato a rischio aveva impegnato il 100 % del margine. Il backtest applica gli stessi limiti dalla 5.0; i numeri di §3 sono della 4.0.0 (senza limite di margine) e vanno rifatti prima di trarne altre conclusioni: con meno nozionale il P&L per basket si riduce in valore assoluto, il segno del verdetto no.
**Uscite** (§5.4): take-profit di basket in pip (10 nel Moderate) **oppure** rientro dello z sotto `z_out` (0,25), a seconda di `exitMode`; stop di basket a `|z| ≥ z_stop` (3,5) o perdita ≥ 1,5 % dell'equity; time-stop dopo 4 giorni; spread anomalo per 3 barre consecutive; correlazione rotta; kill-switch ed equity stop.
+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 due colonne, contesto su una colonna, impostazioni su una colonna |
| expanded | ≥ 840 px | rail 80/256 px, KPI su tre colonne (due righe), 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 con `scripts\screenshots.ps1` (o `sh scripts/screenshots.sh`): avvia il server con `--sample` su una porta libera, apre ogni pagina con Edge headless (`?once=1`, 1440×900 e 420×860) e lo ferma. A mano, una pagina:
```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,22 @@
# ADR-0006 — Apprendimento: livello 0 e logistica in ombra restano, MLP, bandit e ciclo settimanale si spengono a runtime
Data: 2026-09-23. Stato: accettata con il default di D-30 (§8 del piano 5.0); revocabile con una risposta diversa.
## Contesto
`docs/STRATEGY.md` dà un verdetto negativo sul backtest (7,75 anni, nessuna configurazione con P&L netto positivo, segnale di circa 2 pip contro 3 pip di costo, walk-forward Sharpe 0,91). Il forward test in Demo ha prodotto **0 basket chiusi** in sei giorni per il difetto degli ordini pendenti (post-mortem), e il ciclo settimanale ha girato a vuoto («ledger senza basket chiusi: niente da addestrare»). Un meta-modello può soltanto filtrare i basket: con un segnale sotto i costi il massimo che può fare è ridurre le perdite, e per attivarsi gli servono almeno 300 basket chiusi, cioè mesi alla frequenza osservata. Nel frattempo il bandit **applicava** il preset da solo in Demo: un parametro cambiato dal bot, contro la regola di `RISK_RULES.md`.
## Decisione
- **Restano attivi**: il ledger (è il dato), il livello 0 (tabelle di calibrazione: quasi gratis, utili a leggere il forward test), la previsione di volatilità (`VolForecaster`, usata dal decisore in modo deterministico per `z_in` effettivo), la regressione logistica **in ombra** (predice `p_ML` nel ledger a ogni chiusura di barra e impara a ogni chiusura di basket; non fa mai da cancello).
- **Si spengono a runtime** (codice conservato nel Core): l'MLP challenger, il bandit di Thompson come attuatore (dalla Fase 1 propone soltanto), il ciclo settimanale. In `strategy.json` la sezione `learning` ha `enabled = false` di fabbrica: con `false` il motore non avvia il ciclo, non addestra il challenger e non può mai attivare il cancello `ml_gate`; il bandit registra i premi e propone nel log.
- **Il ciclo settimanale passa allo strumento** `tools/Encelado.Backtest` (comando `learn` su un ledger esportato), così i modelli si valutano quando ci saranno dati, fuori dal processo che opera. Il comando arriva con la Fase 6, quando `LearningState` si sposta nel progetto portabile `Encelado.Engine` (oggi dipende dal log e dal ledger del Bot).
- **Criterio di riattivazione** (`docs/ML_AND_LEARNING.md`): almeno **300 basket chiusi in Demo** *e* **P&L netto forward ≥ 0** sulla metrica pre-registrata (`knowledge/preregistrazione.csv`). Prima di allora ogni «adattamento automatico» è rumore, e `learning.enabled = true` è una decisione dell'operatore scritta nel file, non del bot.
- **Interfaccia**: il riquadro «Apprendimento» esce dalla dashboard; lo stato del modello in ombra (basket visti, AUC mobile, PSI) e il pulsante «esegui ciclo di apprendimento» vanno in Impostazioni → Ricerca (web UI, Fase 7). Nella finestra WPF resta la riga di stato del modello in ombra, che ora dice «disattivato (ADR-0006)».
## Conseguenze
- Nessun cambiamento nelle decisioni del bot: il cancello ML non era mai stato attivo (0 basket chiusi).
- `strategy.json` cresce della sezione `learning` (`enabled`, `weeklyCycle`, `challenger`); l'hash della configurazione la include.
- I file `data/models/*` e `knowledge/*` restano dove sono e vengono letti dallo strumento.
- Quando il criterio sarà soddisfatto, la riattivazione passa da una proposta in `knowledge/proposals.csv`, dalla pre-registrazione e da `learning.enabled = true`.
@@ -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/`.
@@ -0,0 +1,31 @@
# ADR-0008 — Engine + Server, immagine Docker come pacchetto, Unraid come destinazione
Data: 2026-09-23. Stato: accettata (D-28: «solo tramite container»; D-29: registro dei container di Gitea, icona nel repository).
## Contesto
Con la finestra WPF ritirata (ADR-0007) il bot ha bisogno di un processo che giri per mesi su una macchina sempre accesa, si riavvii da solo, non dipenda dal PC di sviluppo e sia aggiornabile con un comando. L'utente ha un server Unraid e un Gitea privato con il registro dei container.
## Decisione
1. **Tre progetti applicativi**: `Encelado.Core` (logica pura, zero I/O), `Encelado.Engine` (motore, ledger, feed, configurazione, impostazioni, storico, Telegram; ex `Encelado.Bot` senza UI), `Encelado.Server` (l'eseguibile: `Microsoft.NET.Sdk.Web`, Kestrel, API, UI incorporata, ciclo di vita del processo). `Encelado.Etoro` resta il client HTTP. Lo strumento `tools/Encelado.Backtest` referenzia anche Engine per il comando `learn`.
2. **Il pacchetto è l'immagine Docker** (`Dockerfile` alla radice, multi-stage: restore, build, test, publish framework-dependent, runtime `mcr.microsoft.com/dotnet/aspnet:10.0`). Un'immagine con i test rossi non esiste: `dotnet test` gira dentro lo stadio di build. Il commit arriva come `--build-arg GIT_COMMIT` (il contesto non contiene `.git`) e finisce in `AssemblyMetadata` con la data di build, visibili in Impostazioni ▸ Informazioni e in `/api/info`.
3. **Volumi**: `/config` (encelado.json, strategy.json, instruments.json, chiavi cifrate) e `/data` (ledger, stato, barre, cache, conoscenza, rapporti, log). Il rilevamento del container (`ENCELADO_IN_CONTAINER=1`, o `/config` esistente su Linux) rimappa `run.dataPath` e affini sotto `/data` senza toccare la configurazione dell'utente.
4. **Entrypoint** con `gosu`: `PUID`/`PGID` (default 99/100, `nobody:users` di Unraid) diventano proprietari di `/config` e `/data`; `TZ` regola gli orari a schermo; `exec` del processo dotnet come PID 1 per ricevere `SIGTERM` (arresto pulito: ledger chiuso, heartbeat con nota, posizioni lasciate sul conto con gli stop nativi).
5. **Healthcheck** `dotnet Encelado.Server.dll --health`: interroga `/api/health` (heartbeat fresco se il motore gira, disco scrivibile). Niente `curl` nell'immagine.
6. **Catena di rilascio** (`build/Release.proj`): `Verifica``Pubblica` (cartella portabile framework-dependent) → `Docker` (`<registro>/alby96/encelado:<versione>` e `:latest`) → tag → `Rilascia` (push dell'immagine sul registro di Gitea con lo stesso token della release, release con lo zip portabile e il template Unraid allegati). Inno Setup e `Encelado.iss` sono rimossi.
7. **Unraid**: template `deploy/unraid/encelado.xml` (Container v2) con porta 8080, i due volumi sotto `/mnt/user/appdata/encelado/`, tutte le variabili con descrizione in italiano e `Mask` sui segreti; icona `assets/encelado.png` servita come file raw da Gitea (D-29).
## Alternative scartate
- **Servizio Windows / VPS Windows**: obbliga a tenere un Windows acceso e non si aggiorna con un `docker pull`; l'utente ha già Unraid.
- **Pubblicazione self-contained o AOT nel container**: il server ospita ASP.NET Core minimal e non è AOT-compatibile; l'immagine `aspnet` porta già il runtime e resta più piccola di un self-contained.
- **Immagine su Docker Hub o GHCR**: il codice è privato su Gitea; il registro di Gitea usa lo stesso token della release e non richiede altri account.
- **Compose come unica via**: resta per lo sviluppo locale (`docker-compose.yml`), ma la destinazione è il template Unraid.
## Conseguenze
- `Documenti\Encelado` sopravvive solo per l'esecuzione fuori dal container (sviluppo con F5); nel container tutto sta in `/config` e `/data`.
- L'istanza unica per cartella dati (`instance.lock`) vale anche fra un container e un processo locale che montino la stessa cartella.
- Un aggiornamento dell'immagine è un riavvio: il recupero dopo inattività (Fase 4) copre il buco.
- La catena `build/` cambia struttura rispetto a Mimante/AutoBidder (niente installatore): la differenza è documentata in `build/README.md`.
@@ -0,0 +1,31 @@
# ADR-0009 — Registro persistente degli ordini e adozione delle gambe orfane
Data: 2026-09-23. Stato: accettata (Fase 1 del piano 5.0, `docs/PIANO_5.0.md`; post-mortem in `docs/POSTMORTEM_ordini_pendenti.md`).
## Contesto
Fra il 16 e il 21 settembre 2026 il bot ha lasciato sul conto demo 21 gambe singole senza copertura. L'esito di ogni ordine era cercato con la chiave sbagliata (`referenceId`, che eToro non registra per gli ordini v2), l'ordine senza esito veniva dichiarato «non eseguito» e dimenticato, e la posizione che ne nasceva era «sconosciuta» e per regola intoccabile. Tre difetti che, insieme, hanno trasformato una regola di prudenza («non toccare ciò che non è tuo») in un accumulo di rischio scoperto.
## Decisione
1. **Ogni ordine entra in un registro persistente prima della chiamata HTTP** (`OrderTracker`, `data/state/pending_orders.json`, scrittura atomica). Il registro tiene riferimento cliente, `orderId`, strumento, verso, unità richieste ed eseguite, basket, gamba, orario, ultimo stato del server, esito e posizione. All'avvio viene ricaricato e ogni ordine senza esito viene risolto **prima** di qualsiasi decisione.
2. **La chiave dell'esito è l'`orderId`** (`orders:lookup?orderId=`, ripiego `api/v1/trading/info/{demo/}orders/{id}`). Il riferimento cliente resta nell'intestazione per l'idempotenza e serve solo quando la risposta al `POST` è andata persa. Quando il server non ha traccia sotto nessuna chiave, una posizione dello stesso strumento e verso comparsa entro 90 s dall'invio **è** l'esecuzione (le unità possono differire: il server può ridurre l'ordine).
3. **Nessun esito sintetico.** `OrderOutcome.Status` riporta la parola del server, oppure `Unknown`. Un ordine senza esito allo scadere del timeout della gamba porta il basket in `PendingA` o `PendingB`: nessun nuovo ordine su quel basket, il registro continua a chiedere (ogni 2 s nel primo minuto, poi ogni 10 s, poi ogni minuto), e alla risoluzione l'ingresso viene completato (gamba B, ridimensionata sulle unità eseguite di A), oppure annullato con la chiusura immediata della gamba eseguita se il segnale è decaduto o il bot è bloccato.
4. **Ogni posizione del conto viene classificata** a ogni riconciliazione (`PositionClassifier`): `basket` (gamba nota), `orfana-bot` (id nel registro, oppure strumento + verso + orario entro 90 s coerenti con una riga `segnale_ingresso`/`rifiuto`/`ingresso`/`pending` del ledger), `esterna` (tutto il resto). Le orfane-bot vengono **adottate e chiuse** (tre tentativi, poi blocco delle entrate con avviso); le esterne restano intoccate. Il contatore in dashboard distingue basket aperti, ingressi in attesa, orfane ed esterne.
5. **Il picco di equity è al netto dei movimenti di cassa** (`EquityTracker`): un salto del saldo non spiegato dalle chiusure è un deposito o un prelievo, viene scritto nel ledger come `movimento_di_cassa` e non muove né il picco né il drawdown.
6. **`orders.jsonl`** (append-only) riceve una riga a ogni invio e a ogni cambio di stato: è la fonte della scheda Storico → Ordini.
## Alternative scartate
- *Interrogare in parallelo `orderId` e `referenceId`*: la quota dei lookup (60/min, condivisa con l'esito delle chiusure) non regge due richieste ogni 400 ms per gamba, e il riferimento non è registrato dal server.
- *Adottare le orfane solo con unità ± 1 %*: il server ha ridotto gli ordini a 2 000 USD di margine, le unità non sono un identificatore. La finestra temporale sullo strumento e sul verso lo è, sul conto demo dove nient'altro opera così.
- *Ricomporre il basket con la gamba mancante quando si adotta un'orfana*: rifiutato (default della domanda D-35/§5.4): la gamba è vecchia di un tempo ignoto, il segnale che l'ha generata non c'è più; si chiude.
## Conseguenze
- `IBroker` ha due metodi in più (`LookupOrderByIdAsync`, `CancelOrderAsync`); ogni implementazione, anche quelle di prova, li fornisce.
- `BasketExecutor` accetta un `OrderTracker` e pubblica `ResumeAfterAAsync`, `CompleteAfterBAsync`, `UnwindLegAsync`, `ClosePositionAsync`.
- `BasketEngine` è spezzato in file parziali (loop e decisioni; registro; riconciliazione; stato; comandi; snapshot).
- Il bandit **non applica** più il preset da solo in Demo: propone e basta (D-30).
- Nuovi test (m)-(q) in `tests/Encelado.Tests/ExecutionTests.cs`.
- La bonifica delle orfane esistenti è un comando (`--bonifica` in headless, `bonifica` da console): sul conto demo del 2026-09-23 non c'è niente da bonificare (chiusura manuale del 21/9).
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 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

+42
View File
@@ -0,0 +1,42 @@
<#
.SYNOPSIS
Compila e avvia Encelado in locale, senza container, nella sandbox deploy/local.
.DESCRIPTION
Stessa disposizione del container: deploy/local/config (encelado.json, strategy.json,
chiavi cifrate) e deploy/local/data (ledger, stato, log). La cartella è ignorata da git.
Senza ENCELADO_WEB_TOKEN il server ascolta solo su localhost e non chiede il login.
Le chiavi eToro: variabili ETORO_API_KEY/ETORO_USER_KEY già nell'ambiente, oppure
Impostazioni ▸ Chiavi eToro con ENCELADO_KEY_PASSPHRASE impostata.
scripts\run-dev.ps1 # motore fermo: premi AVVIA nella pagina
scripts\run-dev.ps1 -Autostart # motore avviato subito (servono le chiavi)
scripts\run-dev.ps1 -Sample # dati finti, nessuna chiave: per guardare le pagine
scripts\run-dev.ps1 -Port 8090
#>
param(
[int] $Port = 8080,
[switch] $Autostart,
[switch] $Sample,
[switch] $NoBrowser
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
$env:ENCELADO_CONFIG_DIR = Join-Path $root "deploy\local\config"
$env:ENCELADO_DATA_DIR = Join-Path $root "deploy\local\data"
$env:ENCELADO_WEB_PORT = "$Port"
New-Item -ItemType Directory -Force $env:ENCELADO_CONFIG_DIR, $env:ENCELADO_DATA_DIR | Out-Null
dotnet build src/Encelado.Server -c Debug -nologo -v q
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$args = @()
if ($Sample) { $args += "--sample" } elseif (-not $Autostart) { $args += "--no-autostart" }
$url = "http://localhost:$Port/"
Write-Host "Encelado in locale: $url (configurazione in deploy\local\config, dati in deploy\local\data)" -ForegroundColor Cyan
Write-Host "Comandi da tastiera: status, close <basket>, kill, residuo, preset <nome>, reset <motivazione>, bonifica, stop. Ctrl+C = arresto." -ForegroundColor DarkGray
if (-not $NoBrowser) { Start-Job -ScriptBlock { param($u) Start-Sleep -Seconds 3; Start-Process $u } -ArgumentList $url | Out-Null }
dotnet src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll @args
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env sh
# Builds and starts Encelado locally, without the container, in the deploy/local sandbox
# (deploy/local/config as /config, deploy/local/data as /data; ignored by git).
# sh scripts/run-dev.sh # engine stopped: press AVVIA in the page
# sh scripts/run-dev.sh --sample # fake data, no keys: to look at the pages
# sh scripts/run-dev.sh --autostart # engine started at once (keys needed)
# Without ENCELADO_WEB_TOKEN the server listens on localhost only and asks no login.
set -eu
cd "$(dirname "$0")/.."
export ENCELADO_CONFIG_DIR="$(pwd)/deploy/local/config"
export ENCELADO_DATA_DIR="$(pwd)/deploy/local/data"
export ENCELADO_WEB_PORT="${ENCELADO_WEB_PORT:-8080}"
mkdir -p "$ENCELADO_CONFIG_DIR" "$ENCELADO_DATA_DIR"
MODE="--no-autostart"
for a in "$@"; do
case "$a" in
--sample) MODE="--sample" ;;
--autostart) MODE="" ;;
esac
done
dotnet build src/Encelado.Server -c Debug -nologo -v q
echo "Encelado in locale: http://localhost:$ENCELADO_WEB_PORT/ (config in deploy/local/config, dati in deploy/local/data)"
exec dotnet src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll $MODE
+40
View File
@@ -0,0 +1,40 @@
<#
.SYNOPSIS
Costruisce l'immagine e avvia Encelado nel container con la docker-compose.yml alla radice.
.DESCRIPTION
Le cartelle deploy/local/config e deploy/local/data vengono montate come /config e /data:
sono le stesse di scripts\run-dev.ps1, quindi configurazione e chiavi salvate valgono
in entrambi i modi. Il token dell'interfaccia è "sviluppo" salvo ENCELADO_WEB_TOKEN in
un file .env accanto alla compose (dove vanno anche ETORO_API_KEY, ETORO_USER_KEY,
TELEGRAM_*). Segue il log finché non premi Ctrl+C; il container resta acceso:
scripts\run-docker.ps1 -Down per fermarlo (SIGTERM, stop pulito).
scripts\run-docker.ps1 # build + up -d + log
scripts\run-docker.ps1 -NoBuild # senza ricostruire l'immagine
scripts\run-docker.ps1 -Down # docker compose down
#>
param(
[switch] $NoBuild,
[switch] $Down,
[switch] $NoBrowser
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
if ($Down) { docker compose down; exit $LASTEXITCODE }
New-Item -ItemType Directory -Force "deploy\local\config", "deploy\local\data" | Out-Null
$env:GIT_COMMIT = (git rev-parse --short=12 HEAD 2>$null); if (-not $env:GIT_COMMIT) { $env:GIT_COMMIT = "n/d" }
$env:PUID = "1000"; $env:PGID = "1000"
if ($NoBuild) { docker compose up -d } else { docker compose up -d --build }
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$port = if ($env:ENCELADO_WEB_PORT) { $env:ENCELADO_WEB_PORT } else { "8080" }
$token = if ($env:ENCELADO_WEB_TOKEN) { "(da ENCELADO_WEB_TOKEN)" } else { "sviluppo" }
$url = "http://localhost:$port/"
Write-Host "Encelado nel container: $url token: $token (docker compose down per fermare)" -ForegroundColor Cyan
if (-not $NoBrowser) { Start-Job -ScriptBlock { param($u) Start-Sleep -Seconds 4; Start-Process $u } -ArgumentList $url | Out-Null }
docker compose logs -f encelado
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env sh
# Builds the image and starts Encelado in the container with the docker-compose.yml at the root.
# deploy/local/config and deploy/local/data are mounted as /config and /data (the same
# folders scripts/run-dev.sh uses). Token "sviluppo" unless ENCELADO_WEB_TOKEN is in .env.
# sh scripts/run-docker.sh # build + up -d + follow the log (Ctrl+C leaves it running)
# sh scripts/run-docker.sh --no-build
# sh scripts/run-docker.sh --down # docker compose down (SIGTERM, clean stop)
set -eu
cd "$(dirname "$0")/.."
case "${1:-}" in
--down) exec docker compose down ;;
esac
mkdir -p deploy/local/config deploy/local/data
export GIT_COMMIT="$(git rev-parse --short=12 HEAD 2>/dev/null || echo n/d)"
export PUID="${PUID:-1000}" PGID="${PGID:-1000}"
if [ "${1:-}" = "--no-build" ]; then docker compose up -d; else docker compose up -d --build; fi
echo "Encelado nel container: http://localhost:${ENCELADO_WEB_PORT:-8080}/ token: ${ENCELADO_WEB_TOKEN:-sviluppo} (sh scripts/run-docker.sh --down per fermare)"
exec docker compose logs -f encelado
+47
View File
@@ -0,0 +1,47 @@
<#
.SYNOPSIS
Rigenera docs/img/*.png: server in modalità campione (--sample) e Edge headless su ogni pagina.
.DESCRIPTION
Niente chiavi, niente mercato: lo snapshot è finto (SampleSnapshot). Serve Microsoft Edge.
La pagina viene aperta con ?once=1 (una sola lettura dello snapshot, nessuno stream), altrimenti
il browser headless non considera mai il caricamento concluso.
scripts\screenshots.ps1 [-Port 58085]
#>
param([int] $Port = 58085)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
$edge = @("$env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $edge) { throw "Microsoft Edge non trovato" }
dotnet build src/Encelado.Server -c Debug -nologo -v q
$work = Join-Path $env:TEMP ("encelado-shot-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force "$work\config", "docs\img" | Out-Null
$env:ENCELADO_CONFIG_DIR = "$work\config"
$server = Start-Process -FilePath "dotnet" -ArgumentList "src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll", "--sample", "--port", "$Port" -PassThru -WindowStyle Hidden
try {
$ready = $false
for ($i = 0; $i -lt 30 -and -not $ready; $i++) { Start-Sleep -Seconds 1; try { $ready = (Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:$Port/api/health" -TimeoutSec 2).StatusCode -eq 200 } catch {} }
if (-not $ready) { throw "il server campione non risponde sulla porta $Port" }
$shots = @(
@{ name = "dashboard"; page = "dashboard"; size = "1440,900" },
@{ name = "storico"; page = "storico"; size = "1440,900" },
@{ name = "log"; page = "log"; size = "1440,900" },
@{ name = "impostazioni"; page = "impostazioni"; size = "1440,900" },
@{ name = "mobile"; page = "dashboard"; size = "420,860" }
)
foreach ($s in $shots) {
$out = Join-Path $root ("docs\img\" + $s.name + ".png")
$profile = Join-Path $work ("edge-" + $s.name)
# Start-Process: Edge scrive avvisi su stderr e in PowerShell 5.1 diventerebbero errori bloccanti.
$edgeArgs = @("--headless=new", "--disable-gpu", "--no-first-run", "--hide-scrollbars", "--user-data-dir=$profile", "--window-size=$($s.size)", "--virtual-time-budget=5000", "--screenshot=$out", "http://127.0.0.1:$Port/?once=1#/$($s.page)")
Start-Process -FilePath $edge -ArgumentList $edgeArgs -Wait -NoNewWindow -RedirectStandardError "$profile.err" -RedirectStandardOutput "$profile.out"
if (Test-Path $out) { Write-Host (" {0,-14} {1,7:N0} byte" -f $s.name, (Get-Item $out).Length) } else { Write-Warning "$($s.name): screenshot non scritto" }
}
}
finally {
if ($server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force }
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env sh
# Regenerates docs/img/*.png: the server in sample mode (--sample) and a headless Edge/Chrome on every page.
# No keys, no market: the snapshot is fake. Pages are opened with ?once=1 (one snapshot, no stream),
# otherwise the headless browser never considers the page loaded.
# sh scripts/screenshots.sh [port]
set -eu
cd "$(dirname "$0")/.."
PORT="${1:-58085}"
BROWSER="${ENCELADO_BROWSER:-}"
for c in "/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" "/c/Program Files/Microsoft/Edge/Application/msedge.exe" "$(command -v microsoft-edge || true)" "$(command -v google-chrome || true)" "$(command -v chromium || true)"; do
[ -n "$BROWSER" ] && break
[ -n "$c" ] && [ -x "$c" ] && BROWSER="$c"
done
[ -n "$BROWSER" ] || { echo "nessun browser Chromium trovato (imposta ENCELADO_BROWSER)" >&2; exit 1; }
dotnet build src/Encelado.Server -c Debug -nologo -v q
WORK="$(mktemp -d)"
trap '[ -n "${PID:-}" ] && kill "$PID" 2>/dev/null; rm -rf "$WORK"' EXIT
mkdir -p "$WORK/config" docs/img
ENCELADO_CONFIG_DIR="$WORK/config" dotnet src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll --sample --port "$PORT" >/dev/null 2>&1 &
PID=$!
i=0; until curl -sf "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; do i=$((i+1)); [ "$i" -lt 30 ] || { echo "il server campione non risponde" >&2; exit 1; }; sleep 1; done
shot() { # shot <name> <page> <size>
"$BROWSER" --headless=new --disable-gpu --no-first-run --hide-scrollbars "--user-data-dir=$WORK/edge-$1" "--window-size=$3" --virtual-time-budget=5000 "--screenshot=$(pwd)/docs/img/$1.png" "http://127.0.0.1:$PORT/?once=1#/$2" >/dev/null 2>&1 || true
[ -s "docs/img/$1.png" ] && echo " $1: $(wc -c < "docs/img/$1.png") byte" || echo " $1: screenshot non scritto" >&2
}
shot dashboard dashboard 1440,900
shot storico storico 1440,900
shot log log 1440,900
shot impostazioni impostazioni 1440,900
shot mobile dashboard 420,860
-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,242 +0,0 @@
using System.Globalization;
using System.Runtime.InteropServices;
using Encelado.Bot.Configuration;
using Encelado.Bot.Engine;
using Encelado.Bot.Logging;
using Encelado.Core.Baskets;
namespace Encelado.Bot.Baskets;
/// <summary>
/// The bot without a window: the same supervisor and engine, the log on the console,
/// a status line every minute and commands from standard input. For a VPS, a service,
/// or a long unattended test.
/// <para>
/// Commands: <c>status</c>, <c>close &lt;basket&gt;</c>, <c>kill</c>, <c>preset &lt;nome&gt;</c>,
/// <c>reset &lt;motivazione&gt;</c>, <c>stop</c>. Arguments: <c>--headless</c>,
/// <c>--confirm-live "CONFERMO LIVE"</c>, <c>--minutes N</c> (stop by itself after N minutes).
/// </para>
/// </summary>
public static class HeadlessRunner
{
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AttachConsole(int processId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AllocConsole();
private const int AttachParentProcess = -1;
public static async Task<int> RunAsync(BotConfig config, string[] args)
{
ArgumentNullException.ThrowIfNull(config);
ArgumentNullException.ThrowIfNull(args);
if (OperatingSystem.IsWindows() && !AttachConsole(AttachParentProcess))
{
AllocConsole();
}
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine();
Console.WriteLine($"Encelado headless — configurazione {App.ConfigPath}");
foreach (string warning in App.ConfigWarnings)
{
Log.Warn($"configurazione: {warning}");
}
if (App.SeedNote is { } seeded)
{
Log.Warn(seeded);
}
// Keys: environment, then the encrypted store. Never asked for on the console.
if (!EtoroKeyStore.Resolve(config, out string origin))
{
Log.Error("nessuna chiave eToro: inseriscile una volta dalla finestra (avvio senza --headless) oppure con ETORO_API_KEY e ETORO_USER_KEY", null);
return 3;
}
Log.Info($"chiavi eToro: {origin}");
ExecutionMode mode = config.Run.Mode;
if (mode.IsLive() && !HasLivePhrase(args))
{
Log.Error($"la modalità Live richiede l'argomento --confirm-live \"{Ui.PromptWindow.LivePhrase}\"", null);
return 4;
}
if (mode.IsLive())
{
Log.Warn("avvio in Live sul conto REALE confermato da riga di comando");
}
int minutes = 0;
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals("--minutes", StringComparison.OrdinalIgnoreCase) && int.TryParse(args[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int m))
{
minutes = m;
}
}
await using BotSupervisor supervisor = new(config) { StartConfirmed = true };
using CancellationTokenSource stopping = new();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Log.Info("Ctrl+C: arresto");
stopping.Cancel();
};
CommandResult started = await supervisor.StartAsync().ConfigureAwait(false);
if (!started.Ok)
{
Log.Error($"avvio fallito: {started.Message}", null);
return 5;
}
Log.Info($"bot avviato in {mode}. Comandi: status, close <basket>, kill, preset <nome>, reset <motivazione>, stop");
if (minutes > 0)
{
Log.Info($"arresto automatico fra {minutes} minuti");
stopping.CancelAfter(TimeSpan.FromMinutes(minutes));
}
Task input = Task.Run(() => ReadCommandsAsync(supervisor, stopping), stopping.Token);
DateTime lastStatus = DateTime.MinValue;
try
{
while (!stopping.IsCancellationRequested)
{
await Task.Delay(1000, stopping.Token).ConfigureAwait(false);
if (supervisor.State is BotState.Faulted or BotState.Stopped)
{
Log.Warn("il motore si è fermato");
break;
}
if (DateTime.UtcNow - lastStatus >= TimeSpan.FromSeconds(config.Run.StatusSeconds))
{
lastStatus = DateTime.UtcNow;
PrintStatus(supervisor.Snapshot());
}
}
}
catch (OperationCanceledException)
{
// Stop requested.
}
await supervisor.StopAsync().ConfigureAwait(false);
await Log.FlushAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
return 0;
}
private static bool HasLivePhrase(string[] args)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals("--confirm-live", StringComparison.OrdinalIgnoreCase) && args[i + 1] == Ui.PromptWindow.LivePhrase)
{
return true;
}
}
return false;
}
private static async Task ReadCommandsAsync(BotSupervisor supervisor, CancellationTokenSource stopping)
{
while (!stopping.IsCancellationRequested)
{
string? line;
try
{
line = await Console.In.ReadLineAsync(stopping.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (IOException)
{
return;
}
if (line is null)
{
// No console attached (a service): keep running until cancelled.
await Task.Delay(TimeSpan.FromMinutes(1), stopping.Token).ConfigureAwait(false);
continue;
}
string[] parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
continue;
}
string arg = parts.Length > 1 ? parts[1].Trim() : string.Empty;
CommandResult result;
switch (parts[0].ToLowerInvariant())
{
case "stop" or "quit" or "exit":
stopping.Cancel();
return;
case "status":
PrintStatus(supervisor.Snapshot());
continue;
case "close":
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Close, arg, "chiusura manuale da console"), CancellationToken.None).ConfigureAwait(false);
break;
case "kill":
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, string.Empty, "kill-switch da console"), CancellationToken.None).ConfigureAwait(false);
break;
case "preset":
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.SetPreset, arg, "cambio preset da console"), CancellationToken.None).ConfigureAwait(false);
break;
case "reset":
result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.ResetEquityStop, string.Empty, arg), CancellationToken.None).ConfigureAwait(false);
break;
default:
Console.WriteLine("comandi: status, close <basket>, kill, preset <nome>, reset <motivazione>, stop");
continue;
}
Console.WriteLine((result.Ok ? "ok: " : "NO: ") + result.Message);
}
}
private static void PrintStatus(BotSnapshot s)
{
Console.WriteLine();
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$"── {DateTime.UtcNow:HH:mm:ss} UTC · {s.Mode} · preset {s.Preset} · API {s.ApiState} {(double.IsFinite(s.ApiLatencyMs) ? s.ApiLatencyMs.ToString("0") + " ms" : "")} · skew {s.ClockSkewSeconds:+0.0;-0.0} s"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$" BALANCE {s.Balance:N2} EQUITY {s.Equity:N2} TOTAL {s.OpenPnl:+0.00;-0.00} ({s.OpenPnlPct:P2}) TODAY {s.TodayPnl:+0.00;-0.00} ({s.TodayPnlPct:P2}) DD {s.DrawdownPct:P2} basket {s.OpenBaskets}/{s.MaxBaskets}") +
(s.Halted ? $" BLOCCO: {s.HaltReason}" : string.Empty) +
(s.EntriesBlockedReason is { Length: > 0 } blocked ? $" entrate bloccate: {blocked}" : string.Empty));
Console.WriteLine($" {"Coppie",-14} {"(n)",3} {"$",9} {"%",7} {"Pips",6} {"TP",3} {"ρ",6} {"z",6} {"HL",4} {"Costo",5} {"p_ML",6} Stato");
foreach (BasketRow b in s.Baskets)
{
Console.WriteLine($" {b.Name,-14} {b.OpenLegs,3} {b.PnlDisplay,9} {b.PnlPctDisplay,7} {b.PipsDisplay,6} {b.TpDisplay,3} {b.RhoDisplay,6} {b.ZDisplay,6} {b.HalfLifeDisplay,4} {b.CostDisplay,5} {b.PMlDisplay,6} {(b.Enabled ? b.State : "OFF")} {b.Tooltip}");
}
if (s.Quotes.Count > 0)
{
Console.WriteLine(" " + string.Join(" ", s.Quotes.Select(static q => $"{q.Symbol} {q.BidDisplay}/{q.AskDisplay} ({q.SpreadDisplay})")));
}
if (s.Context is { } c)
{
Console.WriteLine($" vol: {c.VolForecast} · ML: {c.MlState} · bandit: {c.BanditProposal} · calendario: {c.CalendarState} · notizie: {c.NewsState}");
foreach (CalendarRow ev in c.NextEvents.Take(5))
{
Console.WriteLine($" evento {ev.TimeLocal} {ev.Currency} {ev.Title} ({ev.InMinutes})");
}
}
}
}
@@ -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,684 +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 DispatcherTimer _timer;
private readonly DashboardPage _dashboard = new();
private readonly LogPage _log = new();
private readonly SettingsPage _settings = new();
private readonly UserControl[] _pages;
private bool _busy;
private bool _closing;
private bool _closed;
public MainWindow()
{
InitializeComponent();
_vm = new MainViewModel
{
StatusLines = _config.Logging.StatusLines,
Log = new LogViewModel(_config.Logging.BufferedLines),
};
_supervisor = new BotSupervisor(_config);
_supervisor.AttachLogSink();
_supervisor.EventLogged += _vm.Log.Enqueue;
DataContext = _vm;
_pages = [_dashboard, _log, _settings];
foreach (UserControl page in _pages)
{
page.DataContext = _vm;
}
_dashboard.Actions = this;
_log.Actions = this;
_settings.Actions = this;
Nav.ItemsSource = new[] { "Dashboard", "Log", "Impostazioni" };
Nav.SelectedIndex = 0;
VersionText.Text = $"v{Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}";
LoadLogo();
RefreshSettings();
// One snapshot per second: fast enough to feel live, cheap enough that the UI
// never competes with the trading loop for CPU.
_timer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1),
};
_timer.Tick += (_, _) => Refresh();
_timer.Start();
Loaded += OnLoaded;
Closing += OnClosing;
}
private void OnNavigated(object sender, SelectionChangedEventArgs e)
{
if (Nav.SelectedIndex >= 0 && Nav.SelectedIndex < _pages.Length)
{
PageHost.Content = _pages[Nav.SelectedIndex];
}
}
// -----------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------
private void OnLoaded(object sender, RoutedEventArgs e)
{
ApplyNativeDarkTitleBar();
Refresh();
foreach (string warning in App.ConfigWarnings)
{
Log.Warn($"configurazione: {warning}");
}
Log.Info($"Encelado avviato — configurazione {App.ConfigPath}");
Log.Info($"log in {_config.Logging.ResolveDirectory()}; orari mostrati nel fuso {UiClock.ZoneName}");
if (App.SeedNote is { } seeded)
{
Log.Warn(seeded);
}
if (EtoroKeyStore.Resolve(_config, out string origin))
{
Log.Info($"chiavi eToro: {origin}");
}
else
{
Log.Info("nessuna chiave eToro trovata: apro la finestra di accesso (le quotazioni richiedono le chiavi anche in Paper)");
PromptForCredentials();
}
RefreshSettings();
WarnIfConfigurationIsStale();
}
/// <summary>
/// Says so, now, if the configuration on disk comes from a previous version. An
/// installation keeps the user's <c>encelado.json</c> across an update — which is
/// right, the tuning is theirs — so a release that changes the file's shape leaves a
/// file behind that the loader can read but that describes nothing the bot still does.
/// </summary>
private void WarnIfConfigurationIsStale()
{
bool stale = App.ConfigWarnings.Any(static w => w.Contains("versione precedente", StringComparison.Ordinal));
if (!stale)
{
return;
}
Log.Warn("la configurazione contiene chiavi di una versione precedente");
MessageBox.Show(
this,
"Il file di configurazione proviene da una versione precedente e contiene sezioni o chiavi che questa " +
"versione ignora.\n\nVai in Impostazioni e premi «Ripristina i valori predefiniti»: il file attuale " +
"viene salvato con la data accanto all'originale, quindi non perdi niente. Le chiavi eToro non vengono toccate.",
"Configurazione da aggiornare",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
private void LoadLogo()
{
try
{
LogoImage.Source = new BitmapImage(
new Uri("pack://application:,,,/Assets/encelado.ico", UriKind.Absolute));
}
catch (Exception ex)
{
Log.Debug($"logo non caricato: {ex.Message}");
}
}
/// <summary>Asks the desktop window manager to draw <b>its own</b> title bar dark.</summary>
private void ApplyNativeDarkTitleBar()
{
const int DwmwaUseImmersiveDarkMode = 20;
try
{
nint handle = new WindowInteropHelper(this).Handle;
int enabled = 1;
_ = DwmSetWindowAttribute(handle, DwmwaUseImmersiveDarkMode, ref enabled, sizeof(int));
}
catch (DllNotFoundException)
{
// Not Windows, or a stripped image. Nothing to do.
}
}
[DllImport("dwmapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int value, int size);
// -----------------------------------------------------------------------
// Live refresh
// -----------------------------------------------------------------------
private void Refresh()
{
_vm.Apply(_supervisor.Snapshot());
_vm.Log.Flush();
_vm.Clock = UiClock.Format(DateTime.UtcNow, "HH:mm:ss");
_vm.ClockZone = UiClock.Label;
}
// -----------------------------------------------------------------------
// Bot control
// -----------------------------------------------------------------------
private async void OnTogglePower(object sender, RoutedEventArgs e)
{
if (_busy)
{
return;
}
if (!_vm.IsRunning && !EnsureCredentials())
{
return;
}
if (!_vm.IsRunning && !ConfirmStart())
{
return;
}
// Never touch PowerBtn.IsEnabled here. It is bound to CanToggle, and assigning a
// dependency property imperatively replaces the binding with a local value — the
// button then stays disabled for ever. The view model owns the whole thing.
_busy = true;
_vm.IsBusy = true;
try
{
CommandResult result = _vm.IsRunning
? await _supervisor.StopAsync().ConfigureAwait(true)
: await _supervisor.StartAsync().ConfigureAwait(true);
if (!result.Ok)
{
MessageBox.Show(this, result.Message, "Encelado",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
finally
{
_busy = false;
_vm.IsBusy = false;
Refresh();
}
}
/// <summary>The only confirmation left: the live mode wants the typed phrase. Paper and Demo start as they are.</summary>
private bool ConfirmStart()
{
_supervisor.StartConfirmed = false;
ExecutionMode mode = _config.Run.Mode;
if (!mode.IsLive())
{
_supervisor.StartConfirmed = true;
return true;
}
PromptWindow prompt = new(
"Conto REALE",
"La modalità Live manda ordini al conto reale di eToro con denaro vero, senza chiedere conferma per i singoli ordini. Per continuare scrivi la frase esatta.",
$"SCRIVI «{PromptWindow.LivePhrase}»",
v => v == PromptWindow.LivePhrase ? null : $"La frase deve essere esattamente «{PromptWindow.LivePhrase}».",
"Avvia sul reale")
{ Owner = this };
bool ok = prompt.ShowDialog() == true;
_supervisor.StartConfirmed = ok;
if (ok)
{
Log.Warn($"avvio in Live confermato dall'operatore con la frase {PromptWindow.LivePhrase}");
}
return ok;
}
private bool EnsureCredentials() => EtoroKeyStore.Resolve(_config, out _) || PromptForCredentials();
private bool PromptForCredentials()
{
EtoroLoginWindow dialog = new(_config) { Owner = this };
bool ok = dialog.ShowDialog() == true;
RefreshSettings();
return ok;
}
// -----------------------------------------------------------------------
// IUiActions — everything the pages can ask the shell to do
// -----------------------------------------------------------------------
public async Task CloseBasketAsync(string basket)
{
if (string.IsNullOrWhiteSpace(basket))
{
return;
}
if (MessageBox.Show(this, $"Chiudere entrambe le gambe di {basket} al prezzo di mercato?", "Chiusura basket",
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes)
{
return;
}
Report(await _supervisor.CloseAsync(basket, CancellationToken.None).ConfigureAwait(true));
}
public async Task KillSwitchAsync()
{
if (MessageBox.Show(this,
"KILL-SWITCH: chiude tutte le gambe di tutti i basket a mercato e blocca le nuove entrate fino a un reset.\n\nContinuare?",
"Kill-switch", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) != MessageBoxResult.Yes)
{
return;
}
Log.Warn("KILL-SWITCH richiesto dalla finestra");
Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, string.Empty, "kill-switch dalla finestra"), CancellationToken.None).ConfigureAwait(true));
}
public async Task SetPresetAsync(string preset)
{
CommandResult result = await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.SetPreset, preset, "cambio preset dalla finestra"), CancellationToken.None).ConfigureAwait(true);
if (!result.Ok)
{
Log.Warn($"preset non cambiato: {result.Message}");
}
Refresh();
}
public async Task ResetEquityStopAsync()
{
PromptWindow prompt = new(
"Reset del blocco",
"Il bot ha chiuso tutto e si è bloccato (equity stop o kill-switch). Prima di ripartire scrivi perché ritieni di poterlo fare: la motivazione finisce nel ledger.",
"MOTIVAZIONE",
v => v.Length >= 10 ? null : "Scrivi almeno dieci caratteri.",
"Sblocca")
{ Owner = this };
if (prompt.ShowDialog() != true)
{
return;
}
Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.ResetEquityStop, string.Empty, prompt.Value), CancellationToken.None).ConfigureAwait(true));
}
private void Report(CommandResult result)
{
if (!result.Ok)
{
MessageBox.Show(this, result.Message, "Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
}
else
{
Log.Info(result.Message);
}
Refresh();
}
public void ShowLogin() => PromptForCredentials();
public void ForgetCredentials()
{
string environment = _config.Etoro.IsDemo ? "DEMO" : "REALE";
if (MessageBox.Show(this, $"Rimuovere le chiavi eToro salvate per l'ambiente {environment}?", "Rimozione chiavi",
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes)
{
return;
}
bool removed = EtoroKeyStore.Clear(_config.Etoro.IsDemo);
_config.Etoro.ApiKey = string.Empty;
_config.Etoro.UserKey = string.Empty;
Log.Info(removed ? "chiavi eToro salvate rimosse" : "non c'erano chiavi eToro salvate da rimuovere");
RefreshSettings();
}
/// <summary>Rewrites the configuration with the factory values, never while the engine runs.</summary>
public void RestoreDefaults()
{
if (_vm.IsRunning)
{
MessageBox.Show(this,
"Ferma il bot prima di ripristinare la configurazione.\n\n" +
"Il motore legge la configurazione all'avvio: riscriverla mentre opera " +
"lascerebbe in esecuzione qualcosa che non corrisponde più a nessun file.",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (MessageBox.Show(
this,
"Riscrivere l'intera configurazione con i valori di fabbrica?\n\n" +
"Vengono persi: i valori che hai cambiato e i commenti che hai scritto nel file.\n\n" +
"Il file attuale viene salvato con la data accanto all'originale, quindi è " +
"recuperabile. Le chiavi eToro e strategy.json non vengono toccati.",
"Ripristino dei valori predefiniti",
MessageBoxButton.YesNo,
MessageBoxImage.Warning,
MessageBoxResult.No) != MessageBoxResult.Yes)
{
return;
}
string? backup;
try
{
backup = ConfigDefaults.Restore(App.ConfigPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
MessageBox.Show(this,
$"Non sono riuscito a riscrivere la configurazione:\n\n{ex.Message}",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
Log.Info(backup is null
? "configurazione ripristinata ai valori predefiniti"
: $"configurazione ripristinata; la precedente è in {backup}");
MessageBox.Show(this,
"Configurazione ripristinata.\n\n" +
(backup is null ? string.Empty : $"La precedente è stata salvata in:\n{backup}\n\n") +
"Riavvia l'applicazione perché i nuovi valori vengano caricati.",
"Ripristino completato", MessageBoxButton.OK, MessageBoxImage.Information);
}
public void OpenConfigFile() => OpenInShell(App.ConfigPath);
public void OpenStrategyFile()
{
string path = _config.Run.StrategyPath;
if (!File.Exists(path))
{
App.SeedStrategyFile(path);
}
OpenInShell(path);
}
public void OpenDataFolder()
{
string directory = _config.Run.DataPath;
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"impossibile creare {directory}: {ex.Message}");
return;
}
OpenInShell(directory);
}
public void OpenLogFolder()
{
string directory = _config.Logging.ResolveDirectory();
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"impossibile creare {directory}: {ex.Message}");
return;
}
OpenInShell(directory);
}
public void OpenLogFile()
{
string? path = Log.FilePath ?? _config.Logging.ResolvePath(_config.Logging.File);
if (path is null || !File.Exists(path))
{
MessageBox.Show(this,
"Il file di log non esiste ancora.\n\nViene creato alla prima riga scritta su disco.",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
OpenInShell(path);
}
public void ChangeLogDirectory()
{
Microsoft.Win32.OpenFolderDialog dialog = new()
{
Title = "Dove salvare i log di Encelado",
InitialDirectory = SafeInitialDirectory(),
Multiselect = false,
};
if (dialog.ShowDialog(this) != true)
{
return;
}
string chosen = dialog.FolderName;
// Refuse before writing rather than after: a directory we cannot write to would
// leave the bot logging nowhere, and the logger fails quietly by design.
if (!IsWritable(chosen, out string problem))
{
MessageBox.Show(this,
$"Non posso scrivere in questa cartella:\n\n{chosen}\n\n{problem}",
"Cartella non utilizzabile", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
try
{
ConfigWriter.SetLogDirectory(App.ConfigPath, chosen);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or InvalidOperationException or FileNotFoundException)
{
MessageBox.Show(this,
$"Non sono riuscito a salvare la configurazione:\n\n{ex.Message}",
"Encelado", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
_config.Logging.Directory = chosen;
Log.Info($"cartella dei log impostata su {chosen} — attiva al prossimo avvio");
RefreshSettings();
MessageBox.Show(this,
$"I log verranno salvati in:\n\n{chosen}\n\n" +
"I file attualmente aperti restano dove sono fino al prossimo avvio dell'applicazione.",
"Impostazione salvata", MessageBoxButton.OK, MessageBoxImage.Information);
}
private string SafeInitialDirectory()
{
try
{
string current = _config.Logging.ResolveDirectory();
return Directory.Exists(current) ? current : AppContext.BaseDirectory;
}
catch (ArgumentException)
{
return AppContext.BaseDirectory;
}
}
private static bool IsWritable(string directory, out string problem)
{
problem = string.Empty;
try
{
Directory.CreateDirectory(directory);
string probe = Path.Combine(directory, $".encelado-{Guid.NewGuid():N}");
File.WriteAllText(probe, string.Empty);
File.Delete(probe);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException)
{
problem = ex.Message;
return false;
}
}
private static void OpenInShell(string path)
{
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
Log.Warn($"impossibile aprire {path}: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// Settings
// -----------------------------------------------------------------------
private void RefreshSettings()
{
string environment = _config.Etoro.IsDemo ? "DEMO" : "REALE";
bool found = EtoroKeyStore.Resolve(_config, out string origin);
string status = found
? $"Origine: {origin} — ambiente eToro {environment}, modalità {_config.Run.Mode}."
: $"Nessuna chiave eToro per l'ambiente {environment}. Il bot non può leggere le quotazioni finché non ne inserisci una coppia.";
string store = EtoroKeyStore.Exists
? $"Archivio: {EtoroKeyStore.FilePath}" + (EtoroKeyStore.IsEncrypted ? " (cifrato con DPAPI)" : " (in chiaro, permessi ristretti)")
: $"Nessun archivio salvato. Verrebbe creato in {EtoroKeyStore.FilePath}.";
string about =
"Encelado — Correlation Baskets su eToro (CFD forex).\n" +
$"Versione {Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?"}\n" +
$"Configurazione: {App.ConfigPath}\n" +
$"Strategia: {_config.Run.StrategyPath}\n" +
$"Dati: {_config.Run.DataPath}\n" +
$"Endpoint: {_config.Etoro.BaseUrl} ambiente: {environment} modalità: {_config.Run.Mode} fuso: {UiClock.ZoneName}";
_settings.Refresh(_config, status, store, about);
}
// -----------------------------------------------------------------------
// Shutdown
// -----------------------------------------------------------------------
/// <summary>
/// Fermare il motore è asincrono e la chiusura di una finestra non lo è: si annulla
/// la chiusura, si aspetta, e la si richiede quando lo spegnimento è finito davvero.
/// Ogni tentativo successivo va annullato: il primo possiede lo spegnimento e lo
/// porterà a termine.
/// </summary>
private async void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (_closing)
{
if (!_closed)
{
e.Cancel = true;
}
return;
}
if (_vm.IsRunning &&
MessageBox.Show(
this,
"Il bot è in esecuzione. Chiudere l'applicazione lo ferma.\n\n" +
(_config.Run.CloseOnShutdown
? "I basket aperti verranno chiusi."
: "I basket aperti RESTANO aperti sul conto, senza nessuno che applichi lo stop di basket o il take-profit. Gli stop nativi sul server restano attivi.") +
"\n\nContinuare?",
"Chiusura",
MessageBoxButton.YesNo,
MessageBoxImage.Warning,
MessageBoxResult.No) != MessageBoxResult.Yes)
{
e.Cancel = true;
return;
}
e.Cancel = true;
_closing = true;
_timer.Stop();
_supervisor.EventLogged -= _vm.Log.Enqueue;
try
{
await _supervisor.DisposeAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error("errore durante lo spegnimento", ex);
}
// Su un frame nuovo del dispatcher, non nella continuazione di OnClosing: se il
// Task si completa in modo sincrono la ripresa avviene ancora dentro il callback
// di chiusura, ed è lì che Close() solleva l'eccezione.
_ = Dispatcher.BeginInvoke(DispatcherPriority.Normal, ChiudiDavvero);
}
private void ChiudiDavvero()
{
if (_closed)
{
return;
}
_closed = true;
Close();
}
}
-108
View File
@@ -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,378 +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 bool _equityStopped;
private bool _killSwitched;
private string _preset = "—";
private string _strategyVersion = string.Empty;
private string _apiState = "fermo";
private string _apiLatency = "—";
private string _clock = "--:--:--";
private string _clockZone = UiClock.Label;
private double _clockSkew;
private string _uptime = "—";
private string _counters = string.Empty;
private string _nextEvent = "—";
private string _volForecast = "—";
private string _mlState = "—";
private string _banditProposal = "—";
private string _calendarState = "—";
private string _newsState = "—";
public ObservableCollection<EventRow> Events { get; } = [];
public ObservableCollection<BasketRow> Baskets { get; } = [];
public ObservableCollection<QuoteRow> Quotes { get; } = [];
public ObservableCollection<SentimentRow> Sentiment { get; } = [];
public ObservableCollection<CalendarRow> NextEvents { get; } = [];
public IReadOnlyList<string> Presets { get; } = ["CONSERVATIVE", "MODERATE", "AGGRESSIVE"];
/// <summary>How many activity lines the dashboard keeps.</summary>
public int StatusLines { get; init; } = 200;
public required LogViewModel Log { get; init; }
public string Mode { get => _mode; private set => Set(ref _mode, value); }
/// <summary><c>paper</c>, <c>demo</c> or <c>live</c>, for the badge colour.</summary>
public string ModeKind { get => _modeKind; private set => Set(ref _modeKind, value); }
public string ExecutionMode { get => _executionMode; private set => Set(ref _executionMode, value); }
public string StateText { get => _stateText; private set => Set(ref _stateText, value); }
public string StateKind { get => _stateKind; private set => Set(ref _stateKind, value); }
public bool IsRunning { get => _isRunning; private set => Set(ref _isRunning, value); }
/// <summary>
/// Whether the power button accepts a click. False while a start or stop is in
/// flight, so a double click cannot queue a second command behind the first.
/// </summary>
public bool CanToggle => !_isBusy && _stateKind is not ("starting" or "stopping");
public bool IsBusy
{
get => _isBusy;
set
{
if (_isBusy == value)
{
return;
}
_isBusy = value;
Raise(nameof(IsBusy));
Raise(nameof(CanToggle));
}
}
public string PowerText { get => _powerText; private set => Set(ref _powerText, value); }
public string Banner { get => _banner; private set => Set(ref _banner, value); }
public bool HasBanner { get => _hasBanner; private set => Set(ref _hasBanner, value); }
public bool BannerIsWarning { get => _bannerIsWarning; private set => Set(ref _bannerIsWarning, value); }
public double Equity { get => _equity; private set => Set(ref _equity, value); }
public double Balance { get => _balance; private set => Set(ref _balance, value); }
public double AvailableBalance { get => _availableBalance; private set => Set(ref _availableBalance, value); }
public double PeakEquity { get => _peakEquity; private set => Set(ref _peakEquity, value); }
public double DrawdownPct { get => _drawdownPct; private set => Set(ref _drawdownPct, value); }
public double EquityStopPct { get => _equityStopPct; private set => Set(ref _equityStopPct, value); }
public string DrawdownSub => _equityStopPct > 0
? string.Create(CultureInfo.CurrentCulture, $"picco {_peakEquity:N2} · stop a {_equityStopPct:P0}")
: string.Create(CultureInfo.CurrentCulture, $"picco {_peakEquity:N2}");
public double TodayPnl { get => _todayPnl; private set => Set(ref _todayPnl, value); }
public double TodayPnlPct { get => _todayPnlPct; private set => Set(ref _todayPnlPct, value); }
public double OpenPnl { get => _openPnl; private set => Set(ref _openPnl, value); }
public double OpenPnlPct { get => _openPnlPct; private set => Set(ref _openPnlPct, value); }
public int OpenBaskets { get => _openBaskets; private set => Set(ref _openBaskets, value); }
public int MaxBaskets { get => _maxBaskets; private set => Set(ref _maxBaskets, value); }
public string BasketsDisplay => _maxBaskets > 0 ? $"{_openBaskets} / {_maxBaskets}" : _openBaskets.ToString(CultureInfo.CurrentCulture);
public bool EquityStopped { get => _equityStopped; private set => Set(ref _equityStopped, value); }
public bool KillSwitched { get => _killSwitched; private set => Set(ref _killSwitched, value); }
/// <summary>The active preset label. Set by the engine; the page changes it through a command, never directly.</summary>
public string Preset { get => _preset; private set => Set(ref _preset, value); }
public string StrategyVersion { get => _strategyVersion; private set => Set(ref _strategyVersion, value); }
public string ApiState { get => _apiState; private set => Set(ref _apiState, value); }
public string ApiLatency { get => _apiLatency; private set => Set(ref _apiLatency, value); }
/// <summary>Wall clock in the window's time zone, refreshed by the window's timer.</summary>
public string Clock { get => _clock; set => Set(ref _clock, value); }
public string ClockZone { get => _clockZone; set => Set(ref _clockZone, value); }
public double ClockSkew { get => _clockSkew; private set => Set(ref _clockSkew, value); }
public string Uptime { get => _uptime; private set => Set(ref _uptime, value); }
public string Counters { get => _counters; private set => Set(ref _counters, value); }
/// <summary>The next high-impact event, one line, for the context strip.</summary>
public string NextEvent { get => _nextEvent; private set => Set(ref _nextEvent, value); }
public string VolForecast { get => _volForecast; private set => Set(ref _volForecast, value); }
public string MlState { get => _mlState; private set => Set(ref _mlState, value); }
public string BanditProposal { get => _banditProposal; private set => Set(ref _banditProposal, value); }
public string CalendarState { get => _calendarState; private set => Set(ref _calendarState, value); }
public string NewsState { get => _newsState; private set => Set(ref _newsState, value); }
public void Apply(BotSnapshot s)
{
ArgumentNullException.ThrowIfNull(s);
Mode = s.Mode;
ModeKind = s.EnvironmentKind;
ExecutionMode = s.ExecutionMode;
IsRunning = s.State is BotState.Running or BotState.Starting;
PowerText = IsRunning ? "FERMA" : "AVVIA";
// CanToggle derives from StateKind, so it has to be raised after it changes.
StateKind = s.State.ToString().ToLowerInvariant();
Raise(nameof(CanToggle));
StateText = s.State switch
{
BotState.Running => "in esecuzione",
BotState.Starting => "avvio…",
BotState.Stopping => "arresto…",
BotState.Faulted => "errore",
_ => "fermo",
};
ApplyBanner(s);
Equity = s.Equity;
Balance = s.Balance;
AvailableBalance = s.AvailableBalance;
PeakEquity = s.PeakEquity;
DrawdownPct = s.DrawdownPct;
EquityStopPct = s.EquityStopPct;
Raise(nameof(DrawdownSub));
TodayPnl = s.TodayPnl;
TodayPnlPct = s.TodayPnlPct;
OpenPnl = s.OpenPnl;
OpenPnlPct = s.OpenPnlPct;
OpenBaskets = s.OpenBaskets;
MaxBaskets = s.MaxBaskets;
Raise(nameof(BasketsDisplay));
EquityStopped = s.EquityStopped;
KillSwitched = s.KillSwitched;
Preset = s.Preset;
StrategyVersion = s.StrategyVersion;
ApiState = s.ApiState;
ApiLatency = double.IsFinite(s.ApiLatencyMs) ? s.ApiLatencyMs.ToString("0", CultureInfo.CurrentCulture) + " ms" : "—";
ClockSkew = s.ClockSkewSeconds;
Uptime = s.Uptime > TimeSpan.Zero ? FormatUptime(s.Uptime) : "—";
Counters = s.Counters;
if (s.Context is { } c)
{
VolForecast = c.VolForecast;
MlState = c.MlState;
BanditProposal = c.BanditProposal;
CalendarState = c.CalendarState;
NewsState = c.NewsState;
CalendarRow? next = c.NextEvents.FirstOrDefault(static e => e.TimeUtc >= DateTime.UtcNow);
NextEvent = next is null ? "nessun evento ad alto impatto in vista" : $"{next.Currency} {next.Title} · {next.TimeLocal} ({next.InMinutes})";
Sync(Sentiment, c.Sentiment, static (a, b) => a.Currency == b.Currency);
Sync(NextEvents, c.NextEvents, static (a, b) => a.TimeUtc == b.TimeUtc && a.Title == b.Title);
}
Sync(Baskets, s.Baskets, static (a, b) => a.Name == b.Name);
Sync(Quotes, s.Quotes, static (a, b) => a.Symbol == b.Symbol);
SyncEvents(s.Events);
}
/// <summary>Picks the one thing most worth saying at the top of the window.</summary>
private void ApplyBanner(BotSnapshot s)
{
if (s.EquityStopped)
{
Banner = $"EQUITY STOP — {s.HaltReason}. Serve un reset manuale con motivazione.";
HasBanner = true;
BannerIsWarning = false;
return;
}
if (s.Halted)
{
Banner = $"OPERATIVITÀ SOSPESA — {s.HaltReason}";
HasBanner = true;
BannerIsWarning = false;
return;
}
if (!string.IsNullOrEmpty(s.Error))
{
Banner = s.Error;
HasBanner = true;
BannerIsWarning = false;
return;
}
if (IsRunning && s.EntriesBlockedReason is { Length: > 0 } blocked)
{
Banner = $"Nuove entrate bloccate: {blocked}. Le uscite restano attive.";
HasBanner = true;
BannerIsWarning = true;
return;
}
if (IsRunning && s.ApiState is "caduta" or "disconnesso")
{
Banner = "Collegamento a eToro caduto: il motore prova a riconnettersi da solo.";
HasBanner = true;
BannerIsWarning = true;
return;
}
if (s.EnvironmentKind == "live" && IsRunning)
{
Banner = "Conto REALE: gli ordini impegnano denaro vero.";
HasBanner = true;
BannerIsWarning = true;
return;
}
HasBanner = false;
}
/// <summary>
/// Replaces the contents only where they actually differ. Clearing and refilling
/// would drop the user's selection and scroll position on every refresh.
/// </summary>
private static void Sync<T>(ObservableCollection<T> target, IReadOnlyList<T> source, Func<T, T, bool> sameKey)
{
for (int i = 0; i < source.Count; i++)
{
if (i < target.Count)
{
if (!sameKey(target[i], source[i]) || !Equals(target[i], source[i]))
{
target[i] = source[i];
}
}
else
{
target.Add(source[i]);
}
}
while (target.Count > source.Count)
{
target.RemoveAt(target.Count - 1);
}
}
/// <summary>The feed only ever grows at the tail, so append the new lines.</summary>
private void SyncEvents(IReadOnlyList<EventRow> source)
{
if (source.Count == Events.Count)
{
return;
}
if (source.Count < Events.Count)
{
Events.Clear();
}
for (int i = Events.Count; i < source.Count; i++)
{
Events.Add(source[i]);
}
while (Events.Count > StatusLines)
{
Events.RemoveAt(0);
}
}
private static string FormatUptime(TimeSpan t) =>
t.TotalHours >= 1 ? $"{(int)t.TotalHours}h {t.Minutes}m"
: t.TotalMinutes >= 1 ? $"{t.Minutes}m {t.Seconds}s"
: $"{t.Seconds}s";
public event PropertyChangedEventHandler? PropertyChanged;
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return;
}
field = value;
Raise(name);
}
private void Raise(string? name) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
@@ -1,298 +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 complessivo dei basket, netto dei costi già maturati, e in percentuale dell'equity.">
<StackPanel>
<TextBlock Text="P&amp;L aperto" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}"
Text="{Binding OpenPnl, StringFormat='{}{0:+#,##0.00;-#,##0.00;0.00}'}"
Foreground="{Binding OpenPnl, Converter={StaticResource PnlBrush}}"/>
<TextBlock Style="{StaticResource Sub}" Text="{Binding OpenPnlPct, StringFormat='{}{0:+0.00%;-0.00%;0.00%}'}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource Kpi}" ToolTip="Distanza dell'equity dal suo massimo storico. All'equity stop il bot chiude tutto e si blocca finché non lo sblocchi con una motivazione.">
<StackPanel>
<TextBlock Text="Drawdown" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}" Text="{Binding DrawdownPct, StringFormat='{}{0:0.00%}'}"/>
<TextBlock Style="{StaticResource Sub}" Text="{Binding DrawdownSub}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource Kpi}" ToolTip="Basket aperti sul massimo consentito dal preset in vigore.">
<StackPanel>
<TextBlock Text="Basket aperti" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Value}" Text="{Binding BasketsDisplay}"/>
<TextBlock Style="{StaticResource Sub}" Text="{Binding Preset, StringFormat='preset {0}'}"/>
</StackPanel>
</Border>
</UniformGrid>
<!-- ==================== basket ==================== -->
<Border Style="{StaticResource Card}" Padding="0" Margin="0,0,0,14">
<DataGrid ItemsSource="{Binding Baskets}" MinHeight="120" ColumnHeaderHeight="36" HorizontalScrollBarVisibility="Disabled">
<DataGrid.RowStyle>
<Style TargetType="DataGridRow" BasedOn="{StaticResource {x:Type DataGridRow}}">
<Setter Property="ToolTip" Value="{Binding Tooltip}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Enabled}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTemplateColumn Header="Basket" Width="190">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Cross, StringFormat='cross {0}'}" FontSize="10.5" Foreground="{StaticResource Faint}"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Stato" Width="110">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Style="{StaticResource Chip}" HorizontalAlignment="Left" Padding="9,2">
<TextBlock Text="{Binding StateLabel}" FontSize="11">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource Dim}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsOpen}" Value="True">
<Setter Property="Foreground" Value="{StaticResource Accent}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Error">
<Setter Property="Foreground" Value="{StaticResource Down}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="z" Binding="{Binding ZDisplay}" Width="72" ElementStyle="{StaticResource Cell}"/>
<DataGridTextColumn Header="ρ" Binding="{Binding RhoDisplay}" Width="66" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="HL" Binding="{Binding HalfLifeDisplay}" Width="54" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="Pips" Binding="{Binding PipsDisplay}" Width="70" ElementStyle="{StaticResource Cell}"/>
<DataGridTextColumn Header="TP" Binding="{Binding TpDisplay}" Width="50" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="P&amp;L $" Binding="{Binding PnlDisplay}" Width="104">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock" BasedOn="{StaticResource Cell}">
<Setter Property="Foreground" Value="{Binding PnlUsd, Converter={StaticResource PnlBrush}}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="Costo" Binding="{Binding CostDisplay}" Width="66" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="p ML" Binding="{Binding PMlDisplay}" Width="112" ElementStyle="{StaticResource CellDim}"/>
<DataGridTextColumn Header="Prossimo evento" Binding="{Binding NextEvent}" Width="230" ElementStyle="{StaticResource CellDim}"/>
<DataGridTemplateColumn Header="" Width="90">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Content="CHIUDI" Click="OnCloseBasket" Tag="{Binding Name}" Style="{StaticResource Danger}"
Padding="9,3" FontSize="11" FontWeight="SemiBold"
Visibility="{Binding IsOpen, Converter={StaticResource BoolVis}}"
ToolTip="Chiude entrambe le gambe a mercato, dopo conferma."/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Border>
<!-- ==================== contesto ==================== -->
<Grid Margin="0,0,0,14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource Card}" Margin="0,0,12,0" Padding="14,12"
ToolTip="Dal calendario settimanale FairEconomy. Blackout: nessuna entrata nei 45 minuti prima e nei 30 dopo un evento ad alto impatto sulle valute del basket.">
<StackPanel>
<TextBlock Text="Prossimo evento" Style="{StaticResource Label}"/>
<TextBlock Text="{Binding NextEvent}" Style="{StaticResource Small}" Margin="0,6,0,0" Foreground="{StaticResource Txt}"/>
<TextBlock Text="{Binding CalendarState}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}"/>
</StackPanel>
</Border>
<Border Grid.Column="1" Style="{StaticResource Card}" Margin="0,0,12,0" Padding="14,12"
ToolTip="Stato del collegamento a eToro Public API, latenza dell'ultima richiesta e uso delle quote (120 richieste al minuto per le quotazioni, 20 per gli ordini).">
<StackPanel>
<TextBlock Text="Collegamento eToro" Style="{StaticResource Label}"/>
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
<TextBlock Text="{Binding ApiState}" Style="{StaticResource Small}" Foreground="{StaticResource Txt}"/>
<TextBlock Text="{Binding ApiLatency}" Style="{StaticResource Small}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Uptime, StringFormat='· attivo da {0}'}" Style="{StaticResource Small}" Margin="8,0,0,0"/>
</StackPanel>
<TextBlock Text="{Binding Counters}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}"/>
</StackPanel>
</Border>
<Border Grid.Column="2" Style="{StaticResource Card}" Padding="14,12"
ToolTip="Meta-modello (regressione logistica online, in ombra finché non supera i cancelli di attivazione), volatilità prevista (EWMA contro HAR-RV) e proposta del bandit sul preset.">
<StackPanel>
<TextBlock Text="Apprendimento" Style="{StaticResource Label}"/>
<TextBlock Text="{Binding MlState}" Style="{StaticResource Small}" Margin="0,6,0,0" Foreground="{StaticResource Txt}" TextTrimming="CharacterEllipsis" MaxHeight="34"/>
<TextBlock Text="{Binding VolForecast}" Style="{StaticResource Small}" Margin="0,4,0,0" Foreground="{StaticResource Faint}" TextTrimming="CharacterEllipsis" MaxHeight="34"/>
</StackPanel>
</Border>
</Grid>
<!-- ==================== attività ==================== -->
<Border Style="{StaticResource Card}" Padding="14,12">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Attività" Style="{StaticResource Label}"/>
<TextBlock Style="{StaticResource Hint}" FontSize="12"
ToolTip="Le ultime righe del log. La scheda Log tiene tutta la cronologia, filtra per livello e apre il file su disco."/>
</StackPanel>
<ItemsControl ItemsSource="{Binding Events}" MaxHeight="220">
<ItemsControl.Template>
<ControlTemplate TargetType="ItemsControl">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsPresenter/>
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="70"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Time}" FontFamily="{StaticResource Mono}" FontSize="11" Foreground="{StaticResource Faint}"/>
<TextBlock Grid.Column="1" Text="{Binding Message}" FontSize="11.5" TextWrapping="Wrap"
Foreground="{Binding Level, Converter={StaticResource LevelBrush}}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -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 int OpenCount => _positions.Count;
/// <summary>Advances the clock and the quotes; charges overnight once per day at 21:00 UTC.</summary>
public void Advance(DateTime nowUtc, IReadOnlyList<QuoteSnapshot> quotes)
{
@@ -132,6 +130,11 @@ public sealed class BacktestBroker : IBroker
return Task.FromResult(new AccountSnapshot(_now, "USD", _balance, equity, Math.Max(0, equity - margin), margin, unrealized));
}
/// <summary>Cash a new order can use: equity minus the margin locked.</summary>
public double Available => GetAccountAsync(CancellationToken.None).Result.Available;
public double UsedMargin => GetAccountAsync(CancellationToken.None).Result.UsedMargin;
public double Equity
{
get
@@ -198,6 +201,11 @@ public sealed class BacktestBroker : IBroker
public Task<OrderOutcome?> LookupOrderAsync(string clientRef, CancellationToken ct) =>
Task.FromResult(_orders.TryGetValue(clientRef, out OrderOutcome? o) ? o : null);
public Task<OrderOutcome?> LookupOrderByIdAsync(long orderId, CancellationToken ct) =>
Task.FromResult(_orders.Values.FirstOrDefault(o => o.OrderId == orderId));
public Task<bool> CancelOrderAsync(long orderId, CancellationToken ct) => Task.FromResult(false);
public Task<CloseOutcome> CloseAsync(long positionId, long instrumentId, CancellationToken ct)
{
if (!_positions.Remove(positionId, out Position? p))
@@ -328,7 +328,7 @@ public static class BasketBacktest
p.BarsHeld++;
}
BasketContext ctx = Context(slot, t, broker, equityNow, peak, slots, isBarClose: true) with
BasketContext ctx = Context(slot, t, broker, equityNow, peak, slots, isBarClose: true, config.OrderLeverage) with
{
DailyLossHit = dailyLoss,
EquityStopped = equityStopped,
@@ -368,7 +368,7 @@ public static class BasketBacktest
{
if (slot.Position is { } p && slot.A.HasQuote && slot.B.HasQuote)
{
BasketContext ctx = Context(slot, broker.Now, broker, broker.Equity, peak, slots, isBarClose: false);
BasketContext ctx = Context(slot, broker.Now, broker, broker.Equity, peak, slots, isBarClose: false, config.OrderLeverage);
ExitOutcome x = executor.CloseAsync(ctx, p, "fine dei dati", CancellationToken.None).GetAwaiter().GetResult();
trades.Add(new BacktestTrade(slot.Name, p.OpenedUtc, broker.Now, p.BuyCross, p.EntryZ, slot.PendingZ, x.RealizedPnlUsd, x.PipsTotal, p.EntryCostPips, p.Adds, p.BarsHeld, "end_of_data", p.EquityAtEntry));
slot.Position = null;
@@ -448,7 +448,7 @@ public static class BasketBacktest
}
}
private static BasketContext Context(Slot slot, DateTime t, BacktestBroker broker, double equity, double peak, List<Slot> slots, bool isBarClose)
private static BasketContext Context(Slot slot, DateTime t, BacktestBroker broker, double equity, double peak, List<Slot> slots, bool isBarClose, int leverage = 10)
{
bool sameCrossOpen = slots.Any(o => o != slot && o.Cross.Symbol == slot.Cross.Symbol && (o.Position is not null || o.Pending?.Kind == DecisionKind.Enter));
int open = slots.Count(static o => o.Position is not null);
@@ -469,6 +469,10 @@ public static class BasketBacktest
PeakEquity = peak,
OpenBaskets = open,
SameCrossOpen = sameCrossOpen,
AvailableMargin = broker.Available,
UsedMargin = broker.UsedMargin,
LeverageA = BasketExecutor.ChooseLeverage(slot.A.Instrument, leverage),
LeverageB = BasketExecutor.ChooseLeverage(slot.B.Instrument, leverage),
IsBarClose = isBarClose,
PipValueUsdA = double.IsNaN(pipA) ? 0 : pipA,
PipValueUsdB = double.IsNaN(pipB) ? 0 : pipB,
@@ -90,6 +90,17 @@ public static class BasketTrials
{
ArgumentNullException.ThrowIfNull(c);
BasketStrategyConfig copy = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _);
copy.Risk = new RiskOptions
{
MaxMarginUsePct = c.Risk.MaxMarginUsePct,
MaxMarginPerBasketPct = c.Risk.MaxMarginPerBasketPct,
MarginBufferPct = c.Risk.MarginBufferPct,
CloseForeignOnKill = c.Risk.CloseForeignOnKill,
MarginCallBlockRatio = c.Risk.MarginCallBlockRatio,
MarginCallCloseRatio = c.Risk.MarginCallCloseRatio,
};
copy.Recovery = new RecoveryOptions { ThresholdMinutes = c.Recovery.ThresholdMinutes, WarmupMinutes = c.Recovery.WarmupMinutes, HeartbeatSeconds = c.Recovery.HeartbeatSeconds };
copy.Learning = new LearningOptions { Enabled = c.Learning.Enabled, WeeklyCycle = c.Learning.WeeklyCycle, Challenger = c.Learning.Challenger };
copy.Preset = c.Preset;
copy.SignalMode = c.SignalMode;
copy.ExitMode = c.ExitMode;
@@ -30,6 +30,17 @@ public sealed record BasketContext
public bool DailyLossHit { get; init; }
/// <summary>Cash the venue lets a new order use (NaN when the engine does not know: no margin cap is applied).</summary>
public double AvailableMargin { get; init; } = double.NaN;
/// <summary>Margin locked by every position on the account right now.</summary>
public double UsedMargin { get; init; }
/// <summary>The leverage each leg's order will carry (the venue locks notional / leverage).</summary>
public int LeverageA { get; init; } = 1;
public int LeverageB { get; init; } = 1;
public bool EquityStopped { get; init; }
public bool KillSwitched { get; init; }
@@ -167,7 +178,6 @@ public sealed record BasketDecision(
BasketEvaluation Evaluation,
CostGateResult? Cost)
{
public bool IsExit => Kind == DecisionKind.Exit;
}
/// <summary>
@@ -497,6 +507,14 @@ public sealed class BasketDecider
if (ctx.OpenBaskets >= _preset.MaxBaskets) { codes.Add("max_baskets"); }
if (ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Exclusive) { codes.Add("same_cross"); }
// Margin (§10 of the 5.0 plan): the guard against a margin call, then the room a
// new basket may take: per basket, in total, and against the cash available with
// the buffer. The smallest of the three is the cap the sizing gets.
double marginRatio = ctx.UsedMargin > 0 ? ctx.Equity / ctx.UsedMargin : double.PositiveInfinity;
if (marginRatio < _cfg.Risk.MarginCallBlockRatio) { codes.Add("margin_guard"); }
double maxMargin = MarginRoom(ctx);
if (maxMargin <= 0) { codes.Add("margin"); }
// Meta-model (8).
if (ctx.MlActive && double.IsFinite(ctx.PMl) && ctx.PMl < _cfg.MlMinProbability) { codes.Add("ml_gate"); }
@@ -520,14 +538,15 @@ public sealed class BasketDecider
return Skip(withCost, codes, Explain(codes, ctx, e, cost), cost);
}
// Sizing (§4.3).
// Sizing (§4.3), the smallest of risk and margin (§10).
double scale = ctx.MlActive && double.IsFinite(ctx.PMl) ? Math.Clamp((2 * ctx.PMl) - 1, 0.25, 1) : 1;
double costUsd = cost.CostPips * ctx.PipValueUsdA; // per unit of A; scaled below once units are known — first pass uses a small placeholder
SizingResult sizing = VolParitySizing.Compute(
ctx.Equity, _preset.RiskPerBasketPct, e.Z, _preset.ZStop, e.SigmaX, e.AtrPipsA, e.AtrPipsB,
ctx.PipValueUsdA, ctx.PipValueUsdB, e.PriceA, e.PriceB, ctx.UsdPerQuoteA, ctx.UsdPerQuoteB,
0, ctx.A.Instrument.MinExposure, ctx.A.Instrument.MaxUnitsPerOrder, ctx.B.Instrument.MaxUnitsPerOrder,
_cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale);
_cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale,
maxMargin, ctx.LeverageA, ctx.LeverageB);
if (sizing.Ok)
{
@@ -537,12 +556,13 @@ public sealed class BasketDecider
ctx.Equity, _preset.RiskPerBasketPct, e.Z, _preset.ZStop, e.SigmaX, e.AtrPipsA, e.AtrPipsB,
ctx.PipValueUsdA, ctx.PipValueUsdB, e.PriceA, e.PriceB, ctx.UsdPerQuoteA, ctx.UsdPerQuoteB,
costUsd, ctx.A.Instrument.MinExposure, ctx.A.Instrument.MaxUnitsPerOrder, ctx.B.Instrument.MaxUnitsPerOrder,
_cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale);
_cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale,
maxMargin, ctx.LeverageA, ctx.LeverageB);
}
if (!sizing.Ok)
{
return Skip(withCost, ["sizing"], $"size non calcolabile: {sizing.Reason}", cost);
return Skip(withCost, [sizing.Bound == "min_exposure" ? "min_exposure" : sizing.Bound == "margin" ? "margin" : "sizing"], $"size non calcolabile: {sizing.Reason}", cost);
}
if (_cfg.InvertSignal)
@@ -558,6 +578,25 @@ public sealed class BasketDecider
return new BasketDecision(DecisionKind.Enter, buyCross, sizing, ["enter"], why, withCost, cost);
}
/// <summary>
/// The margin a new basket may lock: the per-basket cap, what is left under the total
/// cap, and the cash available divided by the buffer. Infinity when the context does
/// not know the account (the backtest before 5.0, the unit tests).
/// </summary>
public double MarginRoom(BasketContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
if (!double.IsFinite(ctx.AvailableMargin))
{
return double.PositiveInfinity;
}
double perBasket = ctx.Equity * _cfg.Risk.MaxMarginPerBasketPct / 100.0;
double total = Math.Max(0, (ctx.Equity * _cfg.Risk.MaxMarginUsePct / 100.0) - Math.Max(0, ctx.UsedMargin));
double cash = Math.Max(0, ctx.AvailableMargin) / _cfg.Risk.BufferFactor;
return Math.Min(perBasket, Math.Min(total, cash));
}
private double TpPips(BasketEvaluation e) =>
_cfg.TpMode == TpMode.AtrMultiple && double.IsFinite(e.AtrPipsA) ? Math.Max(1, _cfg.TpAtrMultiple * e.AtrPipsA) : _preset.TpPips;
@@ -602,6 +641,8 @@ public sealed class BasketDecider
"session" => "fuori dalle sessioni consentite",
"max_baskets" => F($"{ctx.OpenBaskets} basket aperti su {_preset.MaxBaskets}"),
"same_cross" => $"un altro basket sullo stesso cross sintetico {ctx.Cross.Symbol} è aperto",
"margin_guard" => F($"equity / margine usato {ctx.Equity / Math.Max(1e-9, ctx.UsedMargin):0.00} sotto {_cfg.Risk.MarginCallBlockRatio:0.00}: rischio di margin call"),
"margin" => F($"nessun margine per un nuovo basket: usato {ctx.UsedMargin:F0} USD su un tetto di {ctx.Equity * _cfg.Risk.MaxMarginUsePct / 100.0:F0} ({_cfg.Risk.MaxMarginUsePct:0} % dell'equity), disponibile {ctx.AvailableMargin:F0} USD con buffer {_cfg.Risk.MarginBufferPct:0} %"),
"ml_gate" => F($"p_ML {ctx.PMl:0.00} sotto {_cfg.MlMinProbability:0.00}"),
"cost_gate" => cost.Reason,
_ => c,
@@ -3,6 +3,14 @@ using Encelado.Core.Broker;
namespace Encelado.Core.Baskets;
/// <summary>Which leg, if any, is still waiting for the venue's word after an entry attempt.</summary>
public enum PendingLeg
{
None = 0,
A,
B,
}
/// <summary>What opening (or adding to) a basket produced.</summary>
public sealed record EntryOutcome(
bool Ok,
@@ -11,7 +19,18 @@ public sealed record EntryOutcome(
string Error,
double SlippagePipsA,
double SlippagePipsB,
double LatencyMs);
double LatencyMs)
{
/// <summary>When a leg's outcome is unknown the basket is neither open nor flat: the order register follows it.</summary>
public PendingLeg PendingLeg { get; init; }
/// <summary>Everything needed to finish (or undo) the entry once the pending leg resolves.</summary>
public PendingEntry? Pending { get; init; }
public TrackedOrder? PendingOrder { get; init; }
public bool IsPending => PendingLeg != PendingLeg.None;
}
/// <summary>What closing a basket produced.</summary>
public sealed record ExitOutcome(
@@ -27,28 +46,48 @@ public sealed record ExitOutcome(
IReadOnlyList<long> StuckPositionIds);
/// <summary>
/// The two-leg execution protocol of §5.7, over any <see cref="IBroker"/>.
/// The two-leg execution protocol of §5.7, over any <see cref="IBroker"/>, with the
/// order register of the 5.0 plan.
/// <list type="number">
/// <item>Send leg A at market and wait for its fill.</item>
/// <item>Within two seconds send leg B. If B is rejected or unconfirmed within the leg
/// timeout, close A at once and report <c>leg_risk_unwind</c>.</item>
/// <item>Every order carries a unique client reference; before resending, the venue is
/// asked what became of the reference, so nothing is ever duplicated.</item>
/// <item>Every order is registered <b>before</b> it is sent, with a unique client reference.</item>
/// <item>Send leg A at market and wait for its fill: the venue's own answer, then the
/// lookup by <c>orderId</c>, then the position list. Past the leg timeout the basket
/// becomes <c>PendingA</c> and the register keeps asking; nothing is resent.</item>
/// <item>Leg B is sized on the units leg A really got, then sent. If B is rejected,
/// A is closed at once (<c>leg_risk_unwind</c>); if B is unknown past the timeout the
/// basket becomes <c>PendingB</c>.</item>
/// <item>On exit both legs are closed; a leg that fails is retried three times with
/// backoff and then reported as stuck.</item>
/// </list>
/// </summary>
public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config, Func<string, double?> mid, Action<string> log)
public sealed class BasketExecutor
{
private readonly IBroker _broker = broker ?? throw new ArgumentNullException(nameof(broker));
private readonly BasketStrategyConfig _cfg = config ?? throw new ArgumentNullException(nameof(config));
private readonly Func<string, double?> _mid = mid ?? throw new ArgumentNullException(nameof(mid));
private readonly Action<string> _log = log ?? (static _ => { });
private readonly IBroker _broker;
private readonly BasketStrategyConfig _cfg;
private readonly Func<string, double?> _mid;
private readonly Action<string> _log;
private readonly OrderTracker? _tracker;
private readonly string _mode;
public async Task<EntryOutcome> OpenAsync(BasketContext ctx, BasketDecision decision, BasketPreset preset, CancellationToken ct)
public BasketExecutor(IBroker broker, BasketStrategyConfig config, Func<string, double?> mid, Action<string> log, OrderTracker? tracker = null, string mode = "")
{
_broker = broker ?? throw new ArgumentNullException(nameof(broker));
_cfg = config ?? throw new ArgumentNullException(nameof(config));
_mid = mid ?? throw new ArgumentNullException(nameof(mid));
_log = log ?? (static _ => { });
_tracker = tracker;
_mode = mode ?? string.Empty;
}
public Task<EntryOutcome> OpenAsync(BasketContext ctx, BasketDecision decision, BasketPreset preset, CancellationToken ct) =>
OpenAsync(ctx, decision, preset, ctx?.BasketId ?? string.Empty, ct);
/// <summary>Opens a basket: leg A, then leg B. <paramref name="basketId"/> is the instance id written to the ledger.</summary>
public async Task<EntryOutcome> OpenAsync(BasketContext ctx, BasketDecision decision, BasketPreset preset, string basketId, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(decision);
ArgumentNullException.ThrowIfNull(preset);
if (decision.Kind != DecisionKind.Enter || decision.Sizing is not { Ok: true } sizing)
{
return new EntryOutcome(false, null, false, "nessuna decisione di ingresso", 0, 0, 0);
@@ -59,72 +98,214 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
double quoteA = buyA ? ctx.A.Quote.Ask : ctx.A.Quote.Bid;
double quoteB = buyB ? ctx.B.Quote.Ask : ctx.B.Quote.Bid;
OrderRequest reqA = Request(ctx.A, buyA, sizing.UnitsA, quoteA, decision.Motivazione);
OrderOutcome a = await SendAsync(reqA, ct).ConfigureAwait(false);
PendingEntry plan = new()
{
BasketId = basketId,
BuyCross = decision.BuyCross,
EntryZ = decision.Evaluation.Z,
UnitsA = sizing.UnitsA,
UnitsB = sizing.UnitsB,
TpPips = _cfg.TpMode == TpMode.AtrMultiple && double.IsFinite(decision.Evaluation.AtrPipsA) ? Math.Max(1, _cfg.TpAtrMultiple * decision.Evaluation.AtrPipsA) : preset.TpPips,
MaxLossUsd = ctx.Equity * _cfg.MaxLossPerBasketPct / 100.0,
EntryCostPips = decision.Cost?.CostPips ?? double.NaN,
EquityAtEntry = ctx.Equity,
Motivazione = decision.Motivazione,
DecidedUtc = ctx.TimeUtc,
QuoteA = quoteA,
QuoteB = quoteB,
};
OrderRequest reqA = Request(ctx.A, buyA, plan.UnitsA, quoteA, decision.Motivazione);
plan.ClientRefA = reqA.ClientRef;
TrackedOrder trackA = Track(reqA, ctx.Name, basketId, OrderLeg.A, quoteA, decision.Motivazione);
OrderOutcome a = await SendAsync(reqA, trackA, ct).ConfigureAwait(false);
if (a.Pending)
{
_log($"[{ctx.Name}] gamba A ({ctx.A.Symbol}) senza esito dopo {_cfg.LegTimeoutSec} s: resta nel registro degli ordini, il basket aspetta ({Describe(a)})");
return new EntryOutcome(false, null, false, $"gamba A ({ctx.A.Symbol}) in attesa di esito: {Describe(a)}", 0, 0, Environment.TickCount64 - t0)
{
PendingLeg = PendingLeg.A,
Pending = plan,
PendingOrder = trackA,
};
}
if (!a.Filled)
{
return new EntryOutcome(false, null, false, $"gamba A ({ctx.A.Symbol}) non eseguita: {Describe(a)}", 0, 0, Environment.TickCount64 - t0);
}
OrderRequest reqB = Request(ctx.B, buyB, sizing.UnitsB, quoteB, decision.Motivazione);
OrderOutcome b = await SendAsync(reqB, ct).ConfigureAwait(false);
plan.LegA = LegFrom(ctx.A, buyA, a, plan.UnitsA, quoteA, reqA.ClientRef, reqA.StopLossRate ?? 0);
return await SendLegBAsync(ctx, plan, t0, ct).ConfigureAwait(false);
}
/// <summary>Leg A, sent earlier and left pending, has been filled: carries on with leg B.</summary>
public Task<EntryOutcome> ResumeAfterAAsync(BasketContext ctx, PendingEntry plan, OrderOutcome legAOutcome, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(legAOutcome);
(bool buyA, _) = ctx.Cross.Legs(plan.BuyCross);
plan.LegA = LegFrom(ctx.A, buyA, legAOutcome, plan.UnitsA, plan.QuoteA, plan.ClientRefA, 0);
return SendLegBAsync(ctx, plan, Environment.TickCount64, ct);
}
/// <summary>Leg B, sent earlier and left pending, has resolved: completes the basket or undoes leg A.</summary>
public async Task<EntryOutcome> CompleteAfterBAsync(BasketContext ctx, PendingEntry plan, OrderOutcome legBOutcome, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(legBOutcome);
if (plan.LegA is not { } legA)
{
return new EntryOutcome(false, null, false, "gamba A non registrata nel piano: impossibile completare", 0, 0, 0);
}
(_, bool buyB) = ctx.Cross.Legs(plan.BuyCross);
if (!legBOutcome.Filled)
{
return await UnwindAAsync(ctx, plan, legA, $"gamba B non eseguita: {Describe(legBOutcome)}", 0, ct).ConfigureAwait(false);
}
return Complete(ctx, plan, legA, legBOutcome, buyB, plan.QuoteB, plan.ClientRefB, 0);
}
private async Task<EntryOutcome> SendLegBAsync(BasketContext ctx, PendingEntry plan, long t0, CancellationToken ct)
{
BasketLeg legA = plan.LegA!;
(_, bool buyB) = ctx.Cross.Legs(plan.BuyCross);
double quoteB = buyB ? ctx.B.Quote.Ask : ctx.B.Quote.Bid;
// B is sized on what A really got: the venue may have reduced A (observed on
// 2026-09-16), and a full-size B against a reduced A is not the basket that was decided.
double unitsB = plan.UnitsB;
if (plan.UnitsA > 0 && legA.Units > 0 && legA.Units < plan.UnitsA * 0.99)
{
unitsB = Math.Round(plan.UnitsB * legA.Units / plan.UnitsA, 2);
_log(string.Create(CultureInfo.InvariantCulture, $"[{ctx.Name}] la gamba A è stata eseguita per {legA.Units:0.##} unità su {plan.UnitsA:0.##} richieste: la gamba B scende a {unitsB:0.##}"));
}
// The cash available is re-read with leg A already on the account: if it no longer
// covers B's margin with the buffer, B does not go out and A comes back (§10).
double marginB = PipMath.NotionalUsd(ctx.B.Symbol, unitsB, quoteB, _mid) / Math.Max(1, ChooseLeverage(ctx.B.Instrument, _cfg.OrderLeverage));
if (double.IsFinite(marginB) && marginB > 0)
{
AccountSnapshot? account = null;
try
{
account = await _broker.GetAccountAsync(ct).ConfigureAwait(false);
}
catch (BrokerException ex)
{
_log($"[{ctx.Name}] conto non letto prima della gamba B ({ex.Message}): procedo con l'ultimo disponibile noto");
}
if (account is not null && account.Available < marginB * _cfg.Risk.BufferFactor)
{
string why = string.Create(CultureInfo.InvariantCulture, $"margine insufficiente per la gamba B: disponibile {account.Available:F0} USD, servono {marginB * _cfg.Risk.BufferFactor:F0} ({marginB:F0} × {_cfg.Risk.BufferFactor:0.00})");
_log($"[{ctx.Name}] {why}: chiudo subito la gamba A (leg_risk_unwind)");
return await UnwindAAsync(ctx, plan, legA, why, t0, ct).ConfigureAwait(false);
}
}
OrderRequest reqB = Request(ctx.B, buyB, unitsB, quoteB, plan.Motivazione);
plan.ClientRefB = reqB.ClientRef;
TrackedOrder trackB = Track(reqB, ctx.Name, plan.BasketId, OrderLeg.B, quoteB, plan.Motivazione);
OrderOutcome b = await SendAsync(reqB, trackB, ct).ConfigureAwait(false);
if (b.Pending)
{
_log($"[{ctx.Name}] gamba B ({ctx.B.Symbol}) senza esito dopo {_cfg.LegTimeoutSec} s: resta nel registro, il basket aspetta con la gamba A aperta ({Describe(b)})");
return new EntryOutcome(false, null, false, $"gamba B ({ctx.B.Symbol}) in attesa di esito: {Describe(b)}", SlipPips(ctx.A, legA.IsBuy, plan.QuoteA, legA.EntryPrice), 0, Environment.TickCount64 - t0)
{
PendingLeg = PendingLeg.B,
Pending = plan,
PendingOrder = trackB,
};
}
if (!b.Filled)
{
// Leg risk: A is alone in the market. Undo it now.
_log($"[{ctx.Name}] gamba B ({ctx.B.Symbol}) non eseguita ({Describe(b)}): chiudo subito la gamba A (leg_risk_unwind)");
CloseOutcome undo = await CloseLegAsync(a.PositionId, ctx.A.Instrument.Id, ct).ConfigureAwait(false);
string error = $"gamba B non eseguita: {Describe(b)}; gamba A {(undo.Closed ? "richiusa" : "NON richiusa: " + undo.Error)}";
return new EntryOutcome(false, null, undo.Closed, error, SlipPips(ctx.A, buyA, quoteA, a.FillRate), 0, Environment.TickCount64 - t0);
return await UnwindAAsync(ctx, plan, legA, $"gamba B non eseguita: {Describe(b)}", t0, ct).ConfigureAwait(false);
}
BasketLeg legA = new()
{
Symbol = ctx.A.Symbol,
InstrumentId = ctx.A.Instrument.Id,
IsBuy = buyA,
Units = a.Units > 0 ? a.Units : sizing.UnitsA,
EntryPrice = a.FillRate > 0 ? a.FillRate : quoteA,
PositionId = a.PositionId,
ClientRef = reqA.ClientRef,
OpenedUtc = a.TimeUtc,
EntryFeesUsd = a.Fees,
StopLossRate = reqA.StopLossRate ?? 0,
};
BasketLeg legB = new()
{
Symbol = ctx.B.Symbol,
InstrumentId = ctx.B.Instrument.Id,
IsBuy = buyB,
Units = b.Units > 0 ? b.Units : sizing.UnitsB,
EntryPrice = b.FillRate > 0 ? b.FillRate : quoteB,
PositionId = b.PositionId,
ClientRef = reqB.ClientRef,
OpenedUtc = b.TimeUtc,
EntryFeesUsd = b.Fees,
StopLossRate = reqB.StopLossRate ?? 0,
};
return Complete(ctx, plan, legA, b, buyB, quoteB, reqB.ClientRef, t0);
}
private async Task<EntryOutcome> UnwindAAsync(BasketContext ctx, PendingEntry plan, BasketLeg legA, string why, long t0, CancellationToken ct)
{
CloseOutcome undo = await CloseLegAsync(legA.PositionId, legA.InstrumentId, TrackClose(legA, ctx.Name, plan.BasketId, OrderLeg.Unwind, "leg_risk_unwind: " + why), ct).ConfigureAwait(false);
string error = $"{why}; gamba A {(undo.Closed ? "richiusa" : "NON richiusa: " + undo.Error)}";
return new EntryOutcome(false, null, undo.Closed, error, SlipPips(ctx.A, legA.IsBuy, plan.QuoteA, legA.EntryPrice), 0, Environment.TickCount64 - t0);
}
/// <summary>Closes a lone leg (a pending A whose signal decayed, an orphan): three attempts, verified on the position list.</summary>
public Task<CloseOutcome> UnwindLegAsync(BasketLeg leg, string basket, string basketId, string reason, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(leg);
return CloseLegAsync(leg.PositionId, leg.InstrumentId, TrackClose(leg, basket, basketId, OrderLeg.Unwind, reason), ct);
}
/// <summary>Closes one position that is not a leg of any basket (an orphan, or a residue at the kill-switch).</summary>
public Task<CloseOutcome> ClosePositionAsync(BrokerPosition position, string symbol, string basket, string reason, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(position);
TrackedOrder track = new()
{
ClientRef = Guid.NewGuid().ToString("D"),
Symbol = symbol,
InstrumentId = position.InstrumentId,
IsBuy = !position.IsBuy,
RequestedUnits = position.Units,
RequestedPrice = position.CurrentRate,
Basket = basket,
Leg = OrderLeg.Unwind,
SentUtc = DateTime.UtcNow,
Mode = _mode,
Motivazione = reason,
};
return CloseLegAsync(position.PositionId, position.InstrumentId, track, ct);
}
private EntryOutcome Complete(BasketContext ctx, PendingEntry plan, BasketLeg legA, OrderOutcome b, bool buyB, double quoteB, string clientRefB, long t0)
{
BasketLeg legB = LegFrom(ctx.B, buyB, b, plan.UnitsB, quoteB, clientRefB, 0);
BasketPosition position = new()
{
BasketId = ctx.BasketId,
BasketId = plan.BasketId,
Name = ctx.Name,
BuyCross = decision.BuyCross,
BuyCross = plan.BuyCross,
A = legA,
B = legB,
OpenedUtc = ctx.TimeUtc,
EntryZ = decision.Evaluation.Z,
LastAddZ = decision.Evaluation.Z,
EntryCostPips = decision.Cost?.CostPips ?? double.NaN,
TpPips = _cfg.TpMode == TpMode.AtrMultiple && double.IsFinite(decision.Evaluation.AtrPipsA) ? Math.Max(1, _cfg.TpAtrMultiple * decision.Evaluation.AtrPipsA) : preset.TpPips,
MaxLossUsd = ctx.Equity * _cfg.MaxLossPerBasketPct / 100.0,
EquityAtEntry = ctx.Equity,
EntryMotivazione = decision.Motivazione,
OpenedUtc = plan.DecidedUtc,
EntryZ = plan.EntryZ,
LastAddZ = plan.EntryZ,
EntryCostPips = plan.EntryCostPips,
TpPips = plan.TpPips,
MaxLossUsd = plan.MaxLossUsd,
EquityAtEntry = plan.EquityAtEntry,
EntryMotivazione = plan.Motivazione,
};
return new EntryOutcome(true, position, false, string.Empty,
SlipPips(ctx.A, buyA, quoteA, legA.EntryPrice), SlipPips(ctx.B, buyB, quoteB, legB.EntryPrice), Environment.TickCount64 - t0);
SlipPips(ctx.A, legA.IsBuy, plan.QuoteA, legA.EntryPrice), SlipPips(ctx.B, buyB, quoteB, legB.EntryPrice), Environment.TickCount64 - t0);
}
private static BasketLeg LegFrom(SymbolSeries s, bool isBuy, OrderOutcome o, double requestedUnits, double quote, string clientRef, double stop) => new()
{
Symbol = s.Symbol,
InstrumentId = s.Instrument.Id,
IsBuy = isBuy,
Units = o.Units > 0 ? o.Units : requestedUnits,
EntryPrice = o.FillRate > 0 ? o.FillRate : quote,
PositionId = o.PositionId,
ClientRef = clientRef,
OpenedUtc = o.TimeUtc == default ? DateTime.UtcNow : o.TimeUtc,
EntryFeesUsd = o.Fees,
StopLossRate = stop,
};
/// <summary>Adds to both legs of an open basket (a new position per leg on eToro).</summary>
public async Task<EntryOutcome> AddAsync(BasketContext ctx, BasketDecision decision, CancellationToken ct)
{
@@ -140,18 +321,34 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
double quoteB = p.B.IsBuy ? ctx.B.Quote.Ask : ctx.B.Quote.Bid;
OrderRequest reqA = Request(ctx.A, p.A.IsBuy, sizing.UnitsA, quoteA, decision.Motivazione);
OrderOutcome a = await SendAsync(reqA, ct).ConfigureAwait(false);
OrderOutcome a = await SendAsync(reqA, Track(reqA, ctx.Name, p.BasketId, OrderLeg.Add, quoteA, decision.Motivazione), ct).ConfigureAwait(false);
if (!a.Filled)
{
// A pending add stays in the register: if it fills later it is an orphan and the reconciliation closes it.
return new EntryOutcome(false, p, false, $"aggiunta su A non eseguita: {Describe(a)}", 0, 0, Environment.TickCount64 - t0);
}
OrderRequest reqB = Request(ctx.B, p.B.IsBuy, sizing.UnitsB, quoteB, decision.Motivazione);
OrderOutcome b = await SendAsync(reqB, ct).ConfigureAwait(false);
OrderOutcome b = await SendAsync(reqB, Track(reqB, ctx.Name, p.BasketId, OrderLeg.Add, quoteB, decision.Motivazione), ct).ConfigureAwait(false);
if (!b.Filled)
{
_log($"[{ctx.Name}] aggiunta su B non eseguita ({Describe(b)}): richiudo l'aggiunta su A (leg_risk_unwind)");
CloseOutcome undo = await CloseLegAsync(a.PositionId, ctx.A.Instrument.Id, ct).ConfigureAwait(false);
TrackedOrder undoTrack = new()
{
ClientRef = Guid.NewGuid().ToString("D"),
Symbol = ctx.A.Symbol,
InstrumentId = ctx.A.Instrument.Id,
IsBuy = !p.A.IsBuy,
RequestedUnits = a.Units > 0 ? a.Units : sizing.UnitsA,
RequestedPrice = quoteA,
Basket = ctx.Name,
BasketId = p.BasketId,
Leg = OrderLeg.Unwind,
SentUtc = DateTime.UtcNow,
Mode = _mode,
Motivazione = "leg_risk_unwind dell'aggiunta",
};
CloseOutcome undo = await CloseLegAsync(a.PositionId, ctx.A.Instrument.Id, undoTrack, ct).ConfigureAwait(false);
return new EntryOutcome(false, p, undo.Closed, $"aggiunta su B non eseguita: {Describe(b)}", 0, 0, Environment.TickCount64 - t0);
}
@@ -181,19 +378,17 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
double quoteA = p.A.IsBuy ? ctx.A.Quote.Bid : ctx.A.Quote.Ask;
double quoteB = p.B.IsBuy ? ctx.B.Quote.Bid : ctx.B.Quote.Ask;
List<long> stuck = [];
double pnl = 0;
double exitA = 0, exitB = 0;
DateTime closedUtc = DateTime.UtcNow;
(double priceA, double pnlA, bool okA) = await CloseLegAllAsync(p.A, ctx.A.Instrument.Id, stuck, ct).ConfigureAwait(false);
(double priceB, double pnlB, bool okB) = await CloseLegAllAsync(p.B, ctx.B.Instrument.Id, stuck, ct).ConfigureAwait(false);
(double priceA, double pnlA, bool okA) = await CloseLegAllAsync(p.A, ctx.A.Instrument.Id, ctx.Name, p.BasketId, reason, stuck, ct).ConfigureAwait(false);
(double priceB, double pnlB, bool okB) = await CloseLegAllAsync(p.B, ctx.B.Instrument.Id, ctx.Name, p.BasketId, reason, stuck, ct).ConfigureAwait(false);
exitA = priceA > 0 ? priceA : quoteA;
exitB = priceB > 0 ? priceB : quoteB;
double exitA = priceA > 0 ? priceA : quoteA;
double exitB = priceB > 0 ? priceB : quoteB;
// Realised P&L: the venue's number when it reports one, our own otherwise.
double own = p.NetPnlUsd(exitA, exitB, _mid);
pnl = okA && okB && (pnlA != 0 || pnlB != 0) ? pnlA + pnlB - p.AccruedFeesUsd : (double.IsNaN(own) ? 0 : own);
double pnl = okA && okB && (pnlA != 0 || pnlB != 0) ? pnlA + pnlB - p.AccruedFeesUsd : (double.IsNaN(own) ? 0 : own);
double pips = p.PipsTotal(exitA, exitB, ctx.A.Instrument.Pip, ctx.B.Instrument.Pip);
bool ok = okA && okB;
@@ -202,16 +397,17 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
ok ? string.Empty : $"gambe non chiuse: {string.Join(", ", stuck)}", closedUtc, stuck);
}
private async Task<(double Price, double Pnl, bool Ok)> CloseLegAllAsync(BasketLeg leg, long instrumentId, List<long> stuck, CancellationToken ct)
private async Task<(double Price, double Pnl, bool Ok)> CloseLegAllAsync(BasketLeg leg, long instrumentId, string basket, string basketId, string reason, List<long> stuck, CancellationToken ct)
{
double weighted = 0, units = 0, pnl = 0;
bool ok = true;
foreach (long id in leg.AllPositionIds.ToList())
{
CloseOutcome c = await CloseLegAsync(id, instrumentId, ct).ConfigureAwait(false);
double expected = id == leg.PositionId ? leg.Units : leg.Adds.FirstOrDefault(a => a.PositionId == id).Units;
CloseOutcome c = await CloseLegAsync(id, instrumentId, TrackClose(leg, basket, basketId, OrderLeg.Close, reason, id, expected), ct).ConfigureAwait(false);
if (c.Closed)
{
double u = c.Units > 0 ? c.Units : (id == leg.PositionId ? leg.Units : leg.Adds.FirstOrDefault(a => a.PositionId == id).Units);
double u = c.Units > 0 ? c.Units : expected;
weighted += c.CloseRate * u;
units += u;
pnl += c.RealizedPnl;
@@ -227,8 +423,13 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
}
/// <summary>Three attempts with backoff; a pending outcome is re-checked against the position list.</summary>
private async Task<CloseOutcome> CloseLegAsync(long positionId, long instrumentId, CancellationToken ct)
private async Task<CloseOutcome> CloseLegAsync(long positionId, long instrumentId, TrackedOrder? track, CancellationToken ct)
{
if (track is not null)
{
_tracker?.Register(track);
}
CloseOutcome last = new(false, false, 0, 0, 0, DateTime.UtcNow, 0, "non tentata");
for (int attempt = 0; attempt < 3; attempt++)
{
@@ -243,6 +444,7 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
if (last.Closed)
{
ApplyClose(track, last);
return last;
}
@@ -261,15 +463,45 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
if (positions.All(x => x.PositionId != positionId))
{
// Gone from the account: closed by the venue (our order, or a native stop).
return new CloseOutcome(true, false, last.OrderId, last.CloseRate, last.Units, DateTime.UtcNow, last.RealizedPnl, string.Empty);
last = new CloseOutcome(true, false, last.OrderId, last.CloseRate, last.Units, DateTime.UtcNow, last.RealizedPnl, string.Empty);
ApplyClose(track, last);
return last;
}
}
ApplyClose(track, last);
return last;
}
/// <summary>Sends one leg. On an unknown outcome the venue is asked by client reference before giving up.</summary>
private async Task<OrderOutcome> SendAsync(OrderRequest request, CancellationToken ct)
private void ApplyClose(TrackedOrder? track, CloseOutcome c)
{
if (track is null)
{
return;
}
OrderOutcome o = new(c.Closed, c.Rejected, c.OrderId, 0, c.CloseRate, c.Units, c.TimeUtc, 0, c.Closed ? "Closed" : c.Rejected ? "Rejected" : "Unknown", c.Error)
{
RequestedUnits = track.RequestedUnits,
Source = "venue",
};
if (_tracker is not null)
{
_tracker.Apply(track, o, DateTime.UtcNow);
}
else
{
track.Apply(o, DateTime.UtcNow);
}
}
/// <summary>
/// Sends one leg and waits for its outcome up to the leg timeout. The venue's own
/// answer first; then the lookup by <c>orderId</c> (by client reference only when the
/// submit's answer was lost). Never resends. Past the timeout the order is returned
/// pending: it stays in the register, which keeps asking.
/// </summary>
private async Task<OrderOutcome> SendAsync(OrderRequest request, TrackedOrder track, CancellationToken ct)
{
OrderOutcome outcome;
try
@@ -278,25 +510,32 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
}
catch (BrokerException ex)
{
outcome = new OrderOutcome(false, false, 0, 0, 0, request.Units, DateTime.UtcNow, 0, "Unknown", ex.Message);
// The request may or may not have reached the venue: unknown, not rejected.
outcome = OrderOutcome.Unknown(0, request.Units, ex.Message);
}
if (outcome.Filled || outcome.Rejected)
Apply(track, outcome);
if (!outcome.Pending)
{
return outcome;
return track.ToOutcome();
}
// Idempotency: never resend; ask what became of this reference until the leg timeout.
DateTime deadline = DateTime.UtcNow.AddSeconds(_cfg.LegTimeoutSec);
while (DateTime.UtcNow < deadline)
{
await Task.Delay(500, ct).ConfigureAwait(false);
await Task.Delay(700, ct).ConfigureAwait(false);
try
{
OrderOutcome? looked = await _broker.LookupOrderAsync(request.ClientRef, ct).ConfigureAwait(false);
if (looked is { Pending: false })
OrderOutcome? looked = track.OrderId > 0
? await _broker.LookupOrderByIdAsync(track.OrderId, ct).ConfigureAwait(false)
: await _broker.LookupOrderAsync(request.ClientRef, ct).ConfigureAwait(false);
if (looked is not null)
{
return looked;
Apply(track, looked);
if (!looked.Pending)
{
return track.ToOutcome();
}
}
}
catch (BrokerException)
@@ -305,15 +544,69 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
}
}
return outcome with { Error = outcome.Error.Length > 0 ? outcome.Error : $"esito sconosciuto dopo {_cfg.LegTimeoutSec} s" };
if (track.Error.Length == 0)
{
track.Error = $"esito sconosciuto dopo {_cfg.LegTimeoutSec} s";
}
return track.ToOutcome();
}
private void Apply(TrackedOrder track, OrderOutcome outcome)
{
if (_tracker is not null)
{
_tracker.Apply(track, outcome, DateTime.UtcNow);
}
else
{
track.Apply(outcome, DateTime.UtcNow);
}
}
private TrackedOrder Track(OrderRequest req, string basket, string basketId, OrderLeg leg, double quote, string reason)
{
TrackedOrder t = new()
{
ClientRef = req.ClientRef,
Symbol = req.Symbol,
InstrumentId = req.InstrumentId,
IsBuy = req.IsBuy,
RequestedUnits = req.Units,
RequestedPrice = quote,
Basket = basket,
BasketId = basketId,
Leg = leg,
SentUtc = DateTime.UtcNow,
Mode = _mode,
Motivazione = reason.Length > 160 ? reason[..160] : reason,
};
_tracker?.Register(t);
return t;
}
private TrackedOrder TrackClose(BasketLeg leg, string basket, string basketId, OrderLeg kind, string reason, long positionId = 0, double units = 0) => new()
{
ClientRef = Guid.NewGuid().ToString("D"),
Symbol = leg.Symbol,
InstrumentId = leg.InstrumentId,
IsBuy = !leg.IsBuy,
RequestedUnits = units > 0 ? units : leg.Units,
RequestedPrice = _mid(leg.Symbol) ?? 0,
Basket = basket,
BasketId = basketId,
Leg = kind,
SentUtc = DateTime.UtcNow,
Mode = _mode,
Motivazione = (positionId > 0 ? string.Create(CultureInfo.InvariantCulture, $"posizione {positionId}: ") : string.Empty) + (reason.Length > 160 ? reason[..160] : reason),
};
private OrderRequest Request(SymbolSeries s, bool isBuy, double units, double quote, string reason)
{
// The venue wants a native stop on every short and on every leveraged order: put it
// at the distance the basket's own max loss implies (as a fraction of margin, within
// the venue's bounds), which is far outside the basket stop the bot applies itself.
int leverage = ChooseLeverage(s.Instrument);
int leverage = ChooseLeverage(s.Instrument, _cfg.OrderLeverage);
double maxPct = s.Instrument.MaxStopLossPct > 0 ? s.Instrument.MaxStopLossPct : 50;
double minPct = s.Instrument.MinStopLossPct;
double pct = Math.Clamp(Math.Min(maxPct, Math.Max(minPct + 1, 5.0 * _cfg.MaxLossPerBasketPct)), minPct + 0.5, maxPct);
@@ -322,9 +615,10 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
return new OrderRequest(Guid.NewGuid().ToString("D"), s.Instrument.Id, s.Symbol, isBuy, Math.Round(units, 2), leverage, stop, null, reason.Length > 160 ? reason[..160] : reason);
}
private int ChooseLeverage(Instrument instrument)
/// <summary>The largest leverage the venue allows on the instrument that does not exceed the configured one.</summary>
public static int ChooseLeverage(Instrument instrument, int wanted)
{
int wanted = _cfg.OrderLeverage;
ArgumentNullException.ThrowIfNull(instrument);
if (instrument.AllowedLeverages.Length == 0)
{
return wanted;
@@ -355,5 +649,4 @@ public sealed class BasketExecutor(IBroker broker, BasketStrategyConfig config,
private static string Describe(OrderOutcome o) =>
o.Error.Length > 0 ? $"{o.Status} — {o.Error}" : o.Status;
public static string Money(double v) => v.ToString("0.00", CultureInfo.InvariantCulture);
}
@@ -124,26 +124,6 @@ public static class BasketMath
return halfLife >= n ? double.NaN : halfLife;
}
/// <summary>Wilder's ATR over the mid prices of the last <paramref name="period"/>+ bars, in price units.</summary>
public static double Atr(ReadOnlySpan<BidAskBar> bars, int period)
{
if (bars.Length < period + 1 || period < 1)
{
return double.NaN;
}
// Seed with the simple average of the first `period` true ranges, then smooth.
double atr = 0;
int start = bars.Length - period - 1;
for (int i = start + 1; i <= start + period; i++)
{
atr += TrueRange(bars[i], bars[i - 1]);
}
atr /= period;
return atr;
}
/// <summary>Wilder ATR with the full smoothing over a longer history (used when at least 3×period bars exist).</summary>
public static double AtrSmoothed(ReadOnlySpan<BidAskBar> bars, int period)
{
@@ -12,6 +12,12 @@ public enum BasketState
Exiting,
Closed,
Error,
/// <summary>Leg A was sent and the venue has not said what became of it; nothing else happens on this basket until it does.</summary>
PendingA,
/// <summary>Leg A is filled, leg B was sent and the venue has not said what became of it.</summary>
PendingB,
}
public static class BasketLifecycle
@@ -22,6 +28,14 @@ public static class BasketLifecycle
(BasketState.Entering, BasketState.Open) => true,
(BasketState.Entering, BasketState.Idle) => true, // leg-risk unwind, both legs flat again
(BasketState.Entering, BasketState.Error) => true,
(BasketState.Entering, BasketState.PendingA) => true, // leg A sent, outcome unknown past the leg timeout
(BasketState.Entering, BasketState.PendingB) => true, // leg A filled, leg B outcome unknown
(BasketState.PendingA, BasketState.Entering) => true, // leg A filled: sending leg B
(BasketState.PendingA, BasketState.Idle) => true, // leg A rejected, or filled and unwound because the signal decayed
(BasketState.PendingA, BasketState.Error) => true,
(BasketState.PendingB, BasketState.Open) => true, // leg B filled
(BasketState.PendingB, BasketState.Idle) => true, // leg B rejected, leg A unwound
(BasketState.PendingB, BasketState.Error) => true,
(BasketState.Open, BasketState.Adding) => true,
(BasketState.Adding, BasketState.Open) => true,
(BasketState.Adding, BasketState.Error) => true,
@@ -33,6 +47,9 @@ public static class BasketLifecycle
(BasketState.Error, BasketState.Exiting) => true,
_ => from == to,
};
/// <summary>A basket waiting for the venue: no evaluation, no new order, until the order register resolves it.</summary>
public static bool IsPending(this BasketState state) => state is BasketState.PendingA or BasketState.PendingB;
}
/// <summary>One leg of an open basket, as filled.</summary>
@@ -82,6 +99,63 @@ public sealed class BasketLeg
/// <summary>Signed pips from entry at the exit price of this leg (bid for a long, ask for a short).</summary>
public double Pips(double exitPrice, double pip) => (IsBuy ? exitPrice - EntryPrice : EntryPrice - exitPrice) / pip;
/// <summary>Writes the leg as a named JSON object (the shape of <c>baskets_state.json</c>).</summary>
public static void Write(System.Text.Json.Utf8JsonWriter w, string name, BasketLeg leg)
{
ArgumentNullException.ThrowIfNull(w);
ArgumentNullException.ThrowIfNull(leg);
w.WriteStartObject(name);
w.WriteString("symbol", leg.Symbol);
w.WriteNumber("instrumentId", leg.InstrumentId);
w.WriteBoolean("isBuy", leg.IsBuy);
w.WriteNumber("units", leg.Units);
w.WriteNumber("entryPrice", leg.EntryPrice);
w.WriteNumber("positionId", leg.PositionId);
w.WriteString("clientRef", leg.ClientRef);
w.WriteString("openedUtc", leg.OpenedUtc.ToString("O", CultureInfo.InvariantCulture));
w.WriteNumber("entryFeesUsd", leg.EntryFeesUsd);
w.WriteNumber("stopLossRate", leg.StopLossRate);
w.WriteStartArray("adds");
foreach ((long id, double units, double price, string clientRef) in leg.Adds)
{
w.WriteStartObject();
w.WriteNumber("positionId", id);
w.WriteNumber("units", units);
w.WriteNumber("price", price);
w.WriteString("clientRef", clientRef);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteEndObject();
}
public static BasketLeg Read(System.Text.Json.JsonElement e)
{
BasketLeg leg = new()
{
Symbol = e.GetProperty("symbol").GetString() ?? string.Empty,
InstrumentId = e.GetProperty("instrumentId").GetInt64(),
IsBuy = e.GetProperty("isBuy").GetBoolean(),
Units = e.GetProperty("units").GetDouble(),
EntryPrice = e.GetProperty("entryPrice").GetDouble(),
PositionId = e.GetProperty("positionId").GetInt64(),
ClientRef = e.GetProperty("clientRef").GetString() ?? string.Empty,
OpenedUtc = DateTime.Parse(e.GetProperty("openedUtc").GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
EntryFeesUsd = e.GetProperty("entryFeesUsd").GetDouble(),
StopLossRate = e.GetProperty("stopLossRate").GetDouble(),
};
if (e.TryGetProperty("adds", out System.Text.Json.JsonElement adds))
{
foreach (System.Text.Json.JsonElement a in adds.EnumerateArray())
{
leg.Adds.Add((a.GetProperty("positionId").GetInt64(), a.GetProperty("units").GetDouble(), a.GetProperty("price").GetDouble(), a.GetProperty("clientRef").GetString() ?? string.Empty));
}
}
return leg;
}
}
/// <summary>An open (or opening/closing) basket: both legs plus what the decision knew at entry.</summary>
@@ -51,6 +51,82 @@ public static class BasketPresets
}
}
/// <summary>
/// The margin rules of §10 of the 5.0 plan (<c>strategy.json</c> → <c>risk</c>). Every
/// number is a share of equity; the buffer is what the cash available must exceed the
/// margin by before an order goes out.
/// </summary>
public sealed class RiskOptions
{
/// <summary>Margin locked by every position after the new basket, at most this share of equity.</summary>
public double MaxMarginUsePct { get; set; } = 40;
/// <summary>Margin of the two legs of one basket, at most this share of equity.</summary>
public double MaxMarginPerBasketPct { get; set; } = 12;
/// <summary>Cash available must be at least (marginA + marginB) × (1 + buffer) before A, and marginB × (1 + buffer) before B.</summary>
public double MarginBufferPct { get; set; } = 25;
/// <summary>Whether the kill-switch also closes positions the bot did not open.</summary>
public bool CloseForeignOnKill { get; set; }
/// <summary>Below this equity / used-margin ratio no new entry is allowed.</summary>
public double MarginCallBlockRatio { get; set; } = 1.5;
/// <summary>Below this equity / used-margin ratio the basket with the worst P&amp;L is closed.</summary>
public double MarginCallCloseRatio { get; set; } = 1.2;
public double BufferFactor => 1 + (MarginBufferPct / 100.0);
public void Validate()
{
if (MaxMarginUsePct is <= 0 or > 100) { throw new InvalidOperationException("strategy.json: 'risk.maxMarginUsePct' deve essere fra 0 e 100."); }
if (MaxMarginPerBasketPct is <= 0 or > 100) { throw new InvalidOperationException("strategy.json: 'risk.maxMarginPerBasketPct' deve essere fra 0 e 100."); }
if (MaxMarginPerBasketPct > MaxMarginUsePct) { throw new InvalidOperationException("strategy.json: 'risk.maxMarginPerBasketPct' non può superare 'risk.maxMarginUsePct'."); }
if (MarginBufferPct is < 0 or > 200) { throw new InvalidOperationException("strategy.json: 'risk.marginBufferPct' deve essere fra 0 e 200."); }
if (MarginCallCloseRatio is < 1 or > 10) { throw new InvalidOperationException("strategy.json: 'risk.marginCallCloseRatio' deve essere fra 1 e 10."); }
if (MarginCallBlockRatio < MarginCallCloseRatio || MarginCallBlockRatio > 10) { throw new InvalidOperationException("strategy.json: 'risk.marginCallBlockRatio' deve essere fra marginCallCloseRatio e 10."); }
}
}
/// <summary>The recovery after an inactivity (§6 of the 5.0 plan, <c>strategy.json</c> → <c>recovery</c>).</summary>
public sealed class RecoveryOptions
{
/// <summary>More than this without a heartbeat or a tick: the recovery procedure runs before any entry.</summary>
public int ThresholdMinutes { get; set; } = 10;
/// <summary>Entries stay blocked this long after a recovery or a reset, while the quotes settle.</summary>
public int WarmupMinutes { get; set; } = 15;
/// <summary>How often <c>data/state/heartbeat.json</c> is written.</summary>
public int HeartbeatSeconds { get; set; } = 30;
public void Validate()
{
if (ThresholdMinutes is < 1 or > 1440) { throw new InvalidOperationException("strategy.json: 'recovery.thresholdMinutes' deve essere fra 1 e 1440."); }
if (WarmupMinutes is < 0 or > 1440) { throw new InvalidOperationException("strategy.json: 'recovery.warmupMinutes' deve essere fra 0 e 1440."); }
if (HeartbeatSeconds is < 5 or > 600) { throw new InvalidOperationException("strategy.json: 'recovery.heartbeatSeconds' deve essere fra 5 e 600."); }
}
}
/// <summary>
/// What of the learning stack runs at runtime (ADR-0006). Off by default: the shadow
/// logistic model keeps scoring and learning (its probability is a ledger column), the
/// weekly cycle, the MLP challenger and the bandit as an actuator do not run until the
/// operator turns them on after the reactivation criterion of <c>docs/ML_AND_LEARNING.md</c>.
/// </summary>
public sealed class LearningOptions
{
/// <summary>Master switch: false = shadow only, no weekly cycle, no challenger, the gate can never activate.</summary>
public bool Enabled { get; set; }
/// <summary>Whether the weekly cycle runs inside the bot (Sunday after 10 UTC) when learning is enabled.</summary>
public bool WeeklyCycle { get; set; }
/// <summary>Whether the MLP challenger is trained by the cycle when learning is enabled.</summary>
public bool Challenger { get; set; }
}
/// <summary>One basket: two pairs. The synthetic cross and the leg signs are derived, never configured.</summary>
public sealed class BasketDefinition
{
@@ -180,6 +256,15 @@ public sealed class BasketStrategyConfig
public int ClockSkewMaxSeconds { get; set; } = 5;
/// <summary>The margin rules (§10 of the 5.0 plan).</summary>
public RiskOptions Risk { get; set; } = new();
/// <summary>The recovery after an inactivity (§6 of the 5.0 plan).</summary>
public RecoveryOptions Recovery { get; set; } = new();
/// <summary>What of the learning stack runs at runtime (ADR-0006).</summary>
public LearningOptions Learning { get; set; } = new();
// ---- overrides of the preset (NaN / 0 = take the preset's value) ----
public double ZInOverride { get; set; } = double.NaN;
@@ -259,6 +344,8 @@ public sealed class BasketStrategyConfig
if (DailyLossPct is <= 0 or > 50) { throw Bad("dailyLossPct", "fra 0 e 50"); }
if (LotMultiplier is < 1 or > 1.5) { throw Bad("lotMultiplier", "fra 1,0 e 1,5"); }
if (Baskets.Count == 0) { throw Bad("baskets", "almeno un basket"); }
Risk.Validate();
Recovery.Validate();
foreach (BasketDefinition b in Baskets)
{
@@ -303,6 +390,8 @@ public sealed class BasketStrategyConfig
sb.Append(CultureInfo.InvariantCulture, $"zOut={ZOut};dIn={DIn};anchor={AnchorBars};grid={GridStepZ};lotMul={LotMultiplier};maxLoss={MaxLossPerBasketPct};rhoBreak={RhoBreak}x{RhoBreakBars};hold={MaxHoldingBars};");
sb.Append(CultureInfo.InvariantCulture, $"cost={CostMultiple};spreadMed={SpreadMedianMultiple};slip={SlippagePipsPerLeg};on={OvernightPipsPerDay};blackout={BlackoutBeforeMin}/{BlackoutAfterMin};fri={FridayCutoffUtcHour};open={OpenDelayMinutes};");
sb.Append(CultureInfo.InvariantCulture, $"lev={MaxEffectiveLeverage}/{OrderLeverage};vol={VolScaleMin}-{VolScaleMax}/{VolAverageDays};pMin={MlMinProbability};eqStop={EquityStopPct};daily={DailyLossPct};");
sb.Append(CultureInfo.InvariantCulture, $"margin={Risk.MaxMarginUsePct}/{Risk.MaxMarginPerBasketPct}/{Risk.MarginBufferPct};mcall={Risk.MarginCallBlockRatio}/{Risk.MarginCallCloseRatio};");
sb.Append(CultureInfo.InvariantCulture, $"recovery={Recovery.ThresholdMinutes}/{Recovery.WarmupMinutes};learning={(Learning.Enabled ? 1 : 0)}/{(Learning.WeeklyCycle ? 1 : 0)}/{(Learning.Challenger ? 1 : 0)};");
foreach (BasketDefinition b in Baskets)
{
sb.Append(CultureInfo.InvariantCulture, $"{b.A}/{b.B}={(b.Enabled ? 1 : 0)};");
@@ -404,6 +493,9 @@ public sealed class BasketStrategyConfig
case "maxadds": c.MaxAddsOverride = Int(p); break;
case "zstop": c.ZStopOverride = Num(p); break;
case "baskets": c.Baskets = ReadBaskets(p.Value, warnings); break;
case "risk": c.Risk = ReadRisk(p.Value, warnings); break;
case "recovery": c.Recovery = ReadRecovery(p.Value, warnings); break;
case "learning": c.Learning = ReadLearning(p.Value, warnings); break;
default: warnings.Add($"chiave sconosciuta '{p.Name}' in strategy.json"); break;
}
}
@@ -456,6 +548,93 @@ public sealed class BasketStrategyConfig
return list;
}
private static RiskOptions ReadRisk(JsonElement e, List<string> warnings)
{
RiskOptions r = new();
if (e.ValueKind != JsonValueKind.Object)
{
warnings.Add("'risk' deve essere un oggetto: uso i valori di fabbrica");
return r;
}
foreach (JsonProperty p in e.EnumerateObject())
{
if (p.Name.StartsWith('_'))
{
continue;
}
switch (p.Name.ToLowerInvariant())
{
case "maxmarginusepct": r.MaxMarginUsePct = Num(p); break;
case "maxmarginperbasketpct": r.MaxMarginPerBasketPct = Num(p); break;
case "marginbufferpct": r.MarginBufferPct = Num(p); break;
case "closeforeignonkill": r.CloseForeignOnKill = Bool(p); break;
case "margincallblockratio": r.MarginCallBlockRatio = Num(p); break;
case "margincallcloseratio": r.MarginCallCloseRatio = Num(p); break;
default: warnings.Add($"chiave sconosciuta 'risk.{p.Name}' in strategy.json"); break;
}
}
return r;
}
private static LearningOptions ReadLearning(JsonElement e, List<string> warnings)
{
LearningOptions l = new();
if (e.ValueKind != JsonValueKind.Object)
{
warnings.Add("'learning' deve essere un oggetto: uso i valori di fabbrica (tutto spento)");
return l;
}
foreach (JsonProperty p in e.EnumerateObject())
{
if (p.Name.StartsWith('_'))
{
continue;
}
switch (p.Name.ToLowerInvariant())
{
case "enabled": l.Enabled = Bool(p); break;
case "weeklycycle": l.WeeklyCycle = Bool(p); break;
case "challenger": l.Challenger = Bool(p); break;
default: warnings.Add($"chiave sconosciuta 'learning.{p.Name}' in strategy.json"); break;
}
}
return l;
}
private static RecoveryOptions ReadRecovery(JsonElement e, List<string> warnings)
{
RecoveryOptions r = new();
if (e.ValueKind != JsonValueKind.Object)
{
warnings.Add("'recovery' deve essere un oggetto: uso i valori di fabbrica");
return r;
}
foreach (JsonProperty p in e.EnumerateObject())
{
if (p.Name.StartsWith('_'))
{
continue;
}
switch (p.Name.ToLowerInvariant())
{
case "thresholdminutes": r.ThresholdMinutes = Int(p); break;
case "warmupminutes": r.WarmupMinutes = Int(p); break;
case "heartbeatseconds": r.HeartbeatSeconds = Int(p); break;
default: warnings.Add($"chiave sconosciuta 'recovery.{p.Name}' in strategy.json"); break;
}
}
return r;
}
private static List<(int, int)> ReadSessions(JsonElement e, List<string> warnings)
{
List<(int, int)> list = [];
@@ -569,12 +748,36 @@ public sealed class BasketStrategyConfig
"volAverageDays": 30,
"mlMinProbability": 0.55,
"_sicurezza": "equityStopPct: perdita dal picco di equity oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.",
"_sicurezza": "equityStopPct: perdita dal picco di equity (al netto dei movimenti di cassa) oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.",
"equityStopPct": 9,
"dailyLossPct": 3,
"legTimeoutSec": 5,
"clockSkewMaxSeconds": 5,
"_risk": "Margine (5.0, §10). La size di un basket è il minimo fra la size a rischio e quella a margine. maxMarginUsePct: margine totale impegnato dopo l'apertura, in % dell'equity. maxMarginPerBasketPct: margine delle due gambe di un basket, in % dell'equity. marginBufferPct: il disponibile deve superare il margine richiesto di questa percentuale prima di inviare A e, ricontrollato, prima di inviare B (altrimenti A viene richiusa). closeForeignOnKill: il kill-switch chiude anche le posizioni non aperte dal bot. marginCallBlockRatio / marginCallCloseRatio: sotto equity/margine usato = 1,5 niente entrate; sotto 1,2 si chiude il basket con il P&L peggiore.",
"risk": {
"maxMarginUsePct": 40,
"maxMarginPerBasketPct": 12,
"marginBufferPct": 25,
"closeForeignOnKill": false,
"marginCallBlockRatio": 1.5,
"marginCallCloseRatio": 1.2
},
"_recovery": "Recupero dopo inattività (5.0, §6). Il bot scrive data/state/heartbeat.json ogni heartbeatSeconds; se all'avvio o fra due cicli passano più di thresholdMinutes (riavvio, sospensione del PC, aggiornamento, container fermo) blocca le entrate, risolve gli ordini senza esito, riconcilia, riscalda le serie con le barre perse e rivaluta ogni basket aperto come a una chiusura di barra ordinaria (chiudi o tieni; le orfane si chiudono, le esterne si riportano); scrive reports/recupero_<run_id>.csv e riapre le entrate dopo warmupMinutes di quotazioni. A mercato chiuso aspetta la riapertura.",
"recovery": {
"thresholdMinutes": 10,
"warmupMinutes": 15,
"heartbeatSeconds": 30
},
"_learning": "Apprendimento (ADR-0006). Di fabbrica tutto spento: restano il ledger, le tabelle di calibrazione, la previsione di volatilità e la logistica in ombra (p_ML nel ledger, mai un cancello). enabled = true riaccende il ciclo settimanale (weeklyCycle) e il challenger MLP (challenger) solo dopo il criterio di riattivazione di docs/ML_AND_LEARNING.md: almeno 300 basket chiusi in Demo e P&L netto forward >= 0 sulla pre-registrazione. Il bandit propone soltanto, non applica mai il preset.",
"learning": {
"enabled": false,
"weeklyCycle": false,
"challenger": false
},
"_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.",
"baskets": [
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
@@ -0,0 +1,89 @@
using System.Globalization;
namespace Encelado.Core.Baskets;
/// <summary>A deposit, a withdrawal or a virtual credit: cash that moved without a trade.</summary>
public sealed record CashMovement(DateTime TimeUtc, double Amount, double BalanceBefore, double BalanceAfter, double ClosedNetInBetween, string Motivazione);
/// <summary>
/// The equity the risk rules look at, kept clean of cash movements (§5.7 of the 5.0
/// plan). A deposit raises the balance without any trade explaining it; a withdrawal
/// lowers it. Neither is a profit or a loss, so neither may move the peak the equity
/// stop is measured from, nor the day's starting point of the daily-loss rule.
/// <para>
/// Detection: at every account refresh the change of the cash balance is compared with
/// the realised result of the positions closed in between. A residual beyond the
/// tolerance is a cash movement. The tolerance leaves room for overnight fees the venue
/// debits without a close; a 30 000 USD credit is unmistakable.
/// </para>
/// </summary>
public sealed class EquityTracker
{
/// <summary>Residuals below this are noise (fees, rounding), never a cash movement.</summary>
public double MinimumUsd { get; init; } = 10;
/// <summary>Residuals below this share of the balance are noise.</summary>
public double TolerancePct { get; init; } = 0.0025;
/// <summary>Highest net equity seen since the last reset.</summary>
public double PeakNetEquity { get; private set; }
/// <summary>Sum of every cash movement seen since the tracker was created or restored.</summary>
public double CumulativeCashFlow { get; private set; }
/// <summary>The cash balance at the last observation, NaN before the first.</summary>
public double LastBalance { get; private set; } = double.NaN;
public DateTime LastObservedUtc { get; private set; }
/// <summary>Equity without the cash that moved in or out: the number the drawdown is measured on.</summary>
public double NetEquity(double equity) => equity - CumulativeCashFlow;
/// <summary>The peak expressed in today's account terms (net peak plus the cash that came in since).</summary>
public double PeakEquity => PeakNetEquity + CumulativeCashFlow;
public double Drawdown(double equity) => PeakNetEquity > 0 ? Math.Max(0, (PeakNetEquity - NetEquity(equity)) / PeakNetEquity) : 0;
/// <summary>
/// Records an account reading. <paramref name="closedNetSinceLast"/> is the realised
/// net result (profit minus fees) of the positions closed since the previous reading,
/// which is the only legitimate reason for the cash balance to move.
/// </summary>
public CashMovement? Observe(DateTime now, double balance, double equity, double closedNetSinceLast)
{
CashMovement? movement = null;
if (double.IsFinite(LastBalance))
{
double residual = balance - LastBalance - closedNetSinceLast;
double tolerance = Math.Max(MinimumUsd, Math.Abs(balance) * TolerancePct);
if (Math.Abs(residual) > tolerance)
{
CumulativeCashFlow += residual;
movement = new CashMovement(now, residual, LastBalance, balance, closedNetSinceLast, string.Create(CultureInfo.InvariantCulture,
$"{(residual > 0 ? "accredito" : "prelievo")} di {Math.Abs(residual):F2} USD: saldo da {LastBalance:F2} a {balance:F2} con {closedNetSinceLast:+0.00;-0.00} USD di chiusure nel frattempo; picco e drawdown non ne tengono conto"));
}
}
LastBalance = balance;
LastObservedUtc = now;
double net = NetEquity(equity);
if (net > PeakNetEquity)
{
PeakNetEquity = net;
}
return movement;
}
/// <summary>After a reset the peak restarts from the current equity.</summary>
public void ResetPeak(double equity) => PeakNetEquity = NetEquity(equity);
/// <summary>Restores the persisted state; a peak saved by a version that knew no cash flows is taken as a net peak.</summary>
public void Restore(double peakNetEquity, double cumulativeCashFlow, double lastBalance, DateTime lastObservedUtc)
{
PeakNetEquity = Math.Max(0, peakNetEquity);
CumulativeCashFlow = double.IsFinite(cumulativeCashFlow) ? cumulativeCashFlow : 0;
LastBalance = lastBalance;
LastObservedUtc = lastObservedUtc;
}
}
@@ -0,0 +1,210 @@
using System.Globalization;
using Encelado.Core.Broker;
namespace Encelado.Core.Baskets;
/// <summary>A position the kill-switch must close, with what it is for the report.</summary>
public sealed record FlattenTarget(long PositionId, long InstrumentId, string Symbol, bool IsBuy, double Units, string Basket, string Kind);
/// <summary>What the kill-switch did and what is left.</summary>
public sealed record FlattenReport(
IReadOnlyList<long> CancelledOrders,
IReadOnlyList<long> OrdersFilledMeanwhile,
IReadOnlyList<long> Closed,
IReadOnlyList<long> Residue,
double RealizedPnlUsd,
bool Flat,
string Summary)
{
/// <summary>The engine's state after the procedure: <c>Halted</c> when flat, <c>Halted-Residuo</c> otherwise.</summary>
public string State => Flat ? "Halted" : "Halted-Residuo";
}
/// <summary>
/// The kill-switch of §9 of the 5.0 plan, as three steps any engine can compose and a
/// test can drive over a fake venue:
/// <list type="number">
/// <item><see cref="CancelPendingAsync"/>: every order in the register without an outcome
/// is cancelled (when the venue has a cancel route) and then followed until it resolves or
/// the wait runs out; one that filled meanwhile becomes a position to close.</item>
/// <item><see cref="CloseTargetsAsync"/>: every target is closed through the caller's closer
/// (three attempts and a check on the position list live in the executor).</item>
/// <item><see cref="VerifyFlatAsync"/>: the position list is re-read until none of the ids
/// that must be gone is there, or the timeout passes. Nothing is declared closed without
/// this reading; what remains is the residue and the state is <c>Halted-Residuo</c>.</item>
/// </list>
/// </summary>
public sealed class FlattenProcedure(IBroker broker, OrderTracker tracker, Action<string> log)
{
private readonly IBroker _broker = broker ?? throw new ArgumentNullException(nameof(broker));
private readonly OrderTracker _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker));
private readonly Action<string> _log = log ?? (static _ => { });
/// <summary>How long to wait for a cancelled order to resolve.</summary>
public TimeSpan CancelWait { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>How long the position list may still show a target before it is a residue.</summary>
public TimeSpan FlatnessTimeout { get; init; } = TimeSpan.FromSeconds(120);
/// <summary>How often the position list is re-read while waiting.</summary>
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Step 1. Returns the orders cancelled and the positions of the orders that filled anyway.</summary>
public async Task<(List<long> Cancelled, List<BrokerPosition> FilledMeanwhile)> CancelPendingAsync(CancellationToken ct)
{
List<long> cancelled = [];
List<BrokerPosition> filled = [];
IReadOnlyList<TrackedOrder> pending = _tracker.Pending;
if (pending.Count == 0)
{
return (cancelled, filled);
}
foreach (TrackedOrder o in pending)
{
if (o.OrderId <= 0)
{
continue;
}
try
{
if (await _broker.CancelOrderAsync(o.OrderId, ct).ConfigureAwait(false))
{
_log($"kill-switch: richiesto l'annullamento dell'ordine {o.OrderId} ({o.Describe()})");
}
}
catch (BrokerException ex)
{
_log($"kill-switch: annullamento dell'ordine {o.OrderId} non riuscito ({ex.Message}): attendo la risoluzione");
}
}
DateTime deadline = DateTime.UtcNow + CancelWait;
while (_tracker.PendingCount > 0 && DateTime.UtcNow < deadline)
{
foreach (TrackedOrder o in _tracker.Pending)
{
o.LastCheckUtc = default;
}
List<(TrackedOrder Order, OrderOutcome Outcome)> resolved = await _tracker.ResolveAsync(_broker, DateTime.UtcNow, null, ct).ConfigureAwait(false);
foreach ((TrackedOrder order, OrderOutcome outcome) in resolved)
{
if (outcome.Filled && outcome.PositionId > 0)
{
_log($"kill-switch: l'ordine {order.OrderId} è stato eseguito nel frattempo (posizione {outcome.PositionId}): la chiudo");
filled.Add(new BrokerPosition(outcome.PositionId, order.InstrumentId, order.IsBuy, outcome.Units, outcome.FillRate, outcome.TimeUtc, 0, 0, 1, 0, 0, 0, outcome.FillRate));
}
else
{
cancelled.Add(order.OrderId);
}
}
if (_tracker.PendingCount > 0)
{
await Task.Delay(PollInterval, ct).ConfigureAwait(false);
}
}
foreach (TrackedOrder o in _tracker.Pending)
{
_log($"kill-switch: {o.Describe()} ancora senza esito dopo {CancelWait.TotalSeconds:0} s: resta nel registro e verrà chiuso quando comparirà");
}
return (cancelled, filled);
}
/// <summary>Step 2. Closes every target through <paramref name="closer"/>; returns the ids closed, the ids that refused, and the realised result.</summary>
public static async Task<(List<long> Closed, List<long> Failed, double Realized)> CloseTargetsAsync(
IReadOnlyList<FlattenTarget> targets, Func<FlattenTarget, CancellationToken, Task<CloseOutcome>> closer, Action<string> log, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(targets);
ArgumentNullException.ThrowIfNull(closer);
ArgumentNullException.ThrowIfNull(log);
List<long> closed = [];
List<long> failed = [];
double realized = 0;
foreach (FlattenTarget t in targets)
{
CloseOutcome c;
try
{
c = await closer(t, ct).ConfigureAwait(false);
}
catch (BrokerException ex)
{
c = new CloseOutcome(false, false, 0, 0, 0, DateTime.UtcNow, 0, ex.Message);
}
if (c.Closed)
{
closed.Add(t.PositionId);
realized += c.RealizedPnl;
log(string.Create(CultureInfo.InvariantCulture, $"kill-switch: chiusa {t.Kind} {t.PositionId} {t.Symbol} {(t.IsBuy ? "long" : "short")} {t.Units:0.##} ({c.RealizedPnl:+0.00;-0.00} USD)"));
}
else
{
failed.Add(t.PositionId);
log($"kill-switch: {t.Kind} {t.PositionId} {t.Symbol} NON chiusa: {c.Error}");
}
}
return (closed, failed, realized);
}
/// <summary>Step 3. Re-reads the positions until none of <paramref name="mustBeGone"/> is there, or the timeout passes. Returns what is left.</summary>
public async Task<List<long>> VerifyFlatAsync(IReadOnlyCollection<long> mustBeGone, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(mustBeGone);
DateTime deadline = DateTime.UtcNow + FlatnessTimeout;
List<long> residue = [.. mustBeGone];
while (true)
{
try
{
IReadOnlyList<BrokerPosition> positions = await _broker.GetPositionsAsync(ct).ConfigureAwait(false);
HashSet<long> onVenue = [.. positions.Select(static p => p.PositionId)];
residue = [.. mustBeGone.Where(onVenue.Contains)];
}
catch (BrokerException ex)
{
_log($"kill-switch: verifica di piattezza non riuscita ({ex.Message}): riprovo");
}
if (residue.Count == 0 || DateTime.UtcNow >= deadline)
{
return residue;
}
await Task.Delay(PollInterval, ct).ConfigureAwait(false);
}
}
/// <summary>The whole procedure over a set of targets: cancel, close, verify.</summary>
public async Task<FlattenReport> RunAsync(IReadOnlyList<FlattenTarget> targets, Func<FlattenTarget, CancellationToken, Task<CloseOutcome>> closer, Func<long, string>? symbolOf, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(targets);
ArgumentNullException.ThrowIfNull(closer);
(List<long> cancelled, List<BrokerPosition> filledMeanwhile) = await CancelPendingAsync(ct).ConfigureAwait(false);
List<FlattenTarget> all = [.. targets];
foreach (BrokerPosition p in filledMeanwhile)
{
if (all.All(t => t.PositionId != p.PositionId))
{
all.Add(new FlattenTarget(p.PositionId, p.InstrumentId, symbolOf?.Invoke(p.InstrumentId) ?? p.InstrumentId.ToString(CultureInfo.InvariantCulture), p.IsBuy, p.Units, string.Empty, "orfana-bot"));
}
}
(List<long> closed, List<long> failed, double realized) = await CloseTargetsAsync(all, closer, _log, ct).ConfigureAwait(false);
List<long> residue = await VerifyFlatAsync([.. all.Select(static t => t.PositionId)], ct).ConfigureAwait(false);
bool flat = residue.Count == 0;
string summary = string.Create(CultureInfo.InvariantCulture,
$"{closed.Count} posizioni chiuse su {all.Count} ({realized:+0.00;-0.00} USD), {cancelled.Count} ordini annullati, {filledMeanwhile.Count} eseguiti nel frattempo, {_tracker.PendingCount} ancora senza esito; {(flat ? "conto piatto per le posizioni del bot" : $"RESIDUO: {string.Join(", ", residue)}")}");
return new FlattenReport(cancelled, [.. filledMeanwhile.Select(static p => p.PositionId)], closed, residue, realized, flat, summary);
}
/// <summary>The reset's first gate: a written reason of at least ten characters.</summary>
public static bool IsValidResetReason(string? reason) => (reason ?? string.Empty).Trim().Length >= 10;
}
@@ -0,0 +1,73 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Core.Baskets;
/// <summary>What the bot writes every few seconds to say it is alive (§6 of the 5.0 plan).</summary>
public sealed record Heartbeat(DateTime Utc, string RunId, string Mode, int OpenBaskets, int PendingBaskets, int Pid, string Note = "")
{
/// <summary>The idle time since this heartbeat: what a restart, a suspend or a frozen container left uncovered.</summary>
public TimeSpan Inactivity(DateTime nowUtc) => nowUtc - Utc;
}
/// <summary><c>data/state/heartbeat.json</c>: written atomically, read once at startup.</summary>
public static class HeartbeatFile
{
public static void Write(string path, Heartbeat hb)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(hb);
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
{
w.WriteStartObject();
w.WriteString("utc", hb.Utc.ToString("O", CultureInfo.InvariantCulture));
w.WriteString("run_id", hb.RunId);
w.WriteString("mode", hb.Mode);
w.WriteNumber("openBaskets", hb.OpenBaskets);
w.WriteNumber("pendingBaskets", hb.PendingBaskets);
w.WriteNumber("pid", hb.Pid);
w.WriteString("note", hb.Note);
w.WriteEndObject();
}
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllBytes(path + ".tmp", ms.ToArray());
File.Move(path + ".tmp", path, overwrite: true);
}
public static Heartbeat? Read(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
try
{
using JsonDocument doc = JsonDocument.Parse(File.ReadAllBytes(path));
JsonElement r = doc.RootElement;
if (!r.TryGetProperty("utc", out JsonElement u) || !DateTime.TryParse(u.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime utc))
{
return null;
}
return new Heartbeat(utc,
r.TryGetProperty("run_id", out JsonElement id) ? id.GetString() ?? string.Empty : string.Empty,
r.TryGetProperty("mode", out JsonElement m) ? m.GetString() ?? string.Empty : string.Empty,
r.TryGetProperty("openBaskets", out JsonElement ob) ? ob.GetInt32() : 0,
r.TryGetProperty("pendingBaskets", out JsonElement pb) ? pb.GetInt32() : 0,
r.TryGetProperty("pid", out JsonElement pid) ? pid.GetInt32() : 0,
r.TryGetProperty("note", out JsonElement n) ? n.GetString() ?? string.Empty : string.Empty);
}
catch (Exception ex) when (ex is IOException or JsonException or FormatException)
{
return null;
}
}
}
@@ -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;
}
}
@@ -0,0 +1,127 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace Encelado.Core.Baskets.History;
/// <summary>
/// One line of <c>data/ledger/orders.jsonl</c>: an order as sent, and every change of
/// its state afterwards (one line per change, append-only). The last line for a
/// <c>client_ref</c> is the order's current state; the first is what was asked.
/// </summary>
public sealed record OrderRecord(
DateTime Ts,
string RunId,
string Mode,
string Basket,
string BasketId,
string Symbol,
long InstrumentId,
bool IsBuy,
OrderLeg Leg,
double RequestedUnits,
double ExecutedUnits,
double RequestedPrice,
double FillRate,
double SlippagePips,
string Status,
int StatusId,
OrderResolution Resolution,
long OrderId,
long PositionId,
string ClientRef,
double Fees,
string Evento,
string Motivazione)
{
/// <summary>The line for an order's current state. <paramref name="evento"/>: <c>inviato</c>, <c>stato</c>, <c>risolto</c>.</summary>
public static OrderRecord From(TrackedOrder o, string runId, string evento, DateTime? ts = null)
{
ArgumentNullException.ThrowIfNull(o);
double pip = o.Symbol.Length >= 6 ? PipMath.Pip(o.Symbol) : 0.0001;
double slippage = o.RequestedPrice > 0 && o.FillRate > 0 ? (o.IsBuy ? o.FillRate - o.RequestedPrice : o.RequestedPrice - o.FillRate) / pip : double.NaN;
return new OrderRecord(ts ?? DateTime.UtcNow, runId, o.Mode, o.Basket, o.BasketId, o.Symbol, o.InstrumentId, o.IsBuy, o.Leg,
o.RequestedUnits, o.ExecutedUnits, o.RequestedPrice, o.FillRate, slippage, o.LastStatus, o.StatusId, o.Resolution, o.OrderId, o.PositionId,
o.ClientRef, o.Fees, evento, o.Motivazione.Length > 0 ? o.Motivazione : o.Error);
}
public string ToJson()
{
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms))
{
w.WriteStartObject();
w.WriteString("ts", Ts.ToString("O", CultureInfo.InvariantCulture));
w.WriteString("run_id", RunId);
w.WriteString("mode", Mode);
w.WriteString("basket", Basket);
w.WriteString("basket_id", BasketId);
w.WriteString("strumento", Symbol);
w.WriteNumber("instrument_id", InstrumentId);
w.WriteString("verso", IsBuy ? "long" : "short");
w.WriteString("leg", Leg.ToString());
w.WriteNumber("unita_richieste", Math.Round(RequestedUnits, 6));
w.WriteNumber("unita_eseguite", Math.Round(ExecutedUnits, 6));
Num(w, "prezzo_richiesto", RequestedPrice);
Num(w, "prezzo_eseguito", FillRate);
Num(w, "slippage_pip", SlippagePips);
w.WriteString("stato", Status);
w.WriteNumber("stato_id", StatusId);
w.WriteString("esito", Resolution.ToString());
w.WriteNumber("order_id", OrderId);
w.WriteNumber("position_id", PositionId);
w.WriteString("client_ref", ClientRef);
Num(w, "fee", Fees);
w.WriteString("evento", Evento);
w.WriteString("motivazione", Motivazione);
w.WriteEndObject();
}
return Encoding.UTF8.GetString(ms.ToArray());
static void Num(Utf8JsonWriter w, string name, double v)
{
if (double.IsFinite(v) && v != 0)
{
w.WriteNumber(name, Math.Round(v, 8));
}
else if (double.IsFinite(v))
{
w.WriteNumber(name, 0);
}
else
{
w.WriteNull(name);
}
}
}
public static OrderRecord? Parse(string line)
{
if (string.IsNullOrWhiteSpace(line))
{
return null;
}
try
{
using JsonDocument doc = JsonDocument.Parse(line);
JsonElement r = doc.RootElement;
return new OrderRecord(
Time(r, "ts"), S(r, "run_id"), S(r, "mode"), S(r, "basket"), S(r, "basket_id"), S(r, "strumento"), L(r, "instrument_id"),
S(r, "verso") == "long", Enum.TryParse(S(r, "leg"), out OrderLeg leg) ? leg : OrderLeg.A,
D(r, "unita_richieste"), D(r, "unita_eseguite"), D(r, "prezzo_richiesto"), D(r, "prezzo_eseguito"), D(r, "slippage_pip"),
S(r, "stato"), (int)L(r, "stato_id"), Enum.TryParse(S(r, "esito"), out OrderResolution res) ? res : OrderResolution.Pending,
L(r, "order_id"), L(r, "position_id"), S(r, "client_ref"), D(r, "fee"), S(r, "evento"), S(r, "motivazione"));
}
catch (JsonException)
{
return null;
}
static string S(JsonElement e, string n) => e.TryGetProperty(n, out JsonElement v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty;
static double D(JsonElement e, string n) => e.TryGetProperty(n, out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : double.NaN;
static long L(JsonElement e, string n) => e.TryGetProperty(n, out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64() : 0;
static DateTime Time(JsonElement e, string n) => DateTime.TryParse(S(e, n), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t) ? t : default;
}
}
@@ -0,0 +1,90 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace Encelado.Core.Baskets;
/// <summary>
/// One bot per data folder (§12 of the 5.0 plan, and a known issue since 4.0.0). The
/// lock is the file itself, held open with no sharing for the life of the engine: a
/// second instance cannot open it and stops with the first one's pid and start time; a
/// crash releases it with the process, so there is never a stale lock to delete by hand.
/// </summary>
public sealed class InstanceLock : IDisposable
{
private readonly FileStream _stream;
private InstanceLock(FileStream stream, string path)
{
_stream = stream;
Path = path;
}
public string Path { get; }
/// <summary>Takes the lock or throws <see cref="InvalidOperationException"/> naming the holder.</summary>
public static InstanceLock Acquire(string path, string runId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string? dir = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
FileStream stream;
try
{
stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
}
catch (IOException ex)
{
throw new InvalidOperationException($"un'altra istanza di Encelado sta usando questa cartella dati ({Describe(path)}): fermala prima di avviarne un'altra", ex);
}
try
{
stream.SetLength(0);
byte[] body = Encoding.UTF8.GetBytes(string.Create(CultureInfo.InvariantCulture,
$"{{\"pid\":{Environment.ProcessId},\"runId\":\"{runId}\",\"sinceUtc\":\"{DateTime.UtcNow:O}\",\"machine\":\"{Environment.MachineName}\"}}"));
stream.Write(body, 0, body.Length);
stream.Flush(flushToDisk: true);
}
catch (IOException)
{
stream.Dispose();
throw;
}
return new InstanceLock(stream, path);
}
/// <summary>What the lock file says about its holder, for the message of a refused start.</summary>
private static string Describe(string path)
{
try
{
using FileStream s = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using StreamReader r = new(s, Encoding.UTF8);
string text = r.ReadToEnd();
return text.Length > 0 ? text : "contenuto non leggibile";
}
catch (IOException)
{
return "file bloccato";
}
}
public void Dispose()
{
try
{
_stream.Dispose();
File.Delete(Path);
}
catch (IOException)
{
// The next start overwrites it.
}
}
}
@@ -39,8 +39,6 @@ public sealed class RollingStandardizer
_alpha = 1 - Math.Pow(0.5, 1.0 / Math.Max(1, halfLifeRows));
}
public int Size => _mean.Length;
public long Count => _n;
/// <summary>Standardises a row with the statistics seen so far, then updates them. NaN inputs become 0 (the mean).</summary>
@@ -185,10 +183,6 @@ public sealed class OnlineLogistic : IModel
public int Seen => _seen;
public double[] Weights => (double[])_w.Clone();
public double Bias => _b;
/// <summary>Learning rate with a slow decay: <c>lr₀ / (1 + n/1000)</c>.</summary>
private double LearningRate => _lr0 / (1 + (_seen / 1000.0));

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