17 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
Alby96andClaude Fable 5.1 4c26fd3209 Passa ai Correlation Baskets su eToro e rimuove i motori precedenti (4.0.0)
Perché: l'utente ha chiesto un bot che operi cinque basket di coppie forex
correlate su eToro, autonomo, con ledger, feed gratuiti e apprendimento
costruito da zero, e ha deciso di eliminare tutto ciò che restava delle
gestioni precedenti (Binance, cTrader/proba, ricerca con SQLite, GBDT, RL,
TA-Lib) e di non avere approvazioni manuali sui singoli ordini.

Cosa cambia:
- nuovo Core dei basket (cross sintetici, decisore, cost gate, sizing,
  esecutore leg-risk, backtest con PSR/DSR/PBO, livelli 0-3 di apprendimento),
  adattatore eToro Public API, motore autonomo con equity stop, kill-switch,
  riconciliazione, ledger append-only, calendario e notizie con sentiment;
- modalità Paper / Demo / Live (Live con flag e frase CONFERMO LIVE);
- interfaccia rifatta: barra in alto con tre schede, dashboard con i soli
  numeri principali, fuso orario selezionabile, test di rendering in PNG;
- corretto il parser dei costi eToro (campo "value"): markup e overnight
  non venivano letti;
- strumento di ricerca ridotto a ticks / baskets / falsify con due scenari di
  costo; risultati in results/ e reports/: nessuna configurazione è
  profittevole al netto dei costi (docs/STRATEGY.md lo dice con i numeri);
- documentazione completa (STRATEGY, ML_AND_LEARNING, RUNBOOK, GLOSSARY,
  KNOWN_ISSUES, ADR-0004, ADR-0005) e catena di rilascio aggiornata.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:01:47 +02:00
Alby96andClaude Opus 5 9453e20cfc Corregge il crash in chiusura e rende recuperabile una strategia rimossa
Chiusura. Il gestore annullava la chiusura, aspettava lo spegnimento del motore
e la richiedeva alla fine. Un secondo clic sulla X durante l'attesa usciva pero'
SENZA annullare: la finestra entrava nella propria sequenza di chiusura e la
Close() del primo tentativo ci finiva dentro, sollevando "non e' possibile
chiamare Close durante la chiusura di un oggetto Window". Ora ogni tentativo
successivo viene annullato e la chiusura vera si rimanda a un frame nuovo del
dispatcher, cosi' non puo' mai eseguire dentro il gestore.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:18:39 +02:00
296 changed files with 37622 additions and 22646 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
+6
View File
@@ -16,3 +16,9 @@ logs/
# build/gitea.example.json; questo file contiene una credenziale e non entra
# mai nel repository.
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
}
]
+51 -15
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": "Rigioca una serie storica di prezzi: taratura contro verifica, con il confronto sul comprare e tenere.",
"detail": "Ricerca sui basket: ticks (tick MT5 → barre), baskets (griglia, PSR/DSR, PBO, walk-forward), falsify (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,15 +164,15 @@
{
"id": "dati",
"type": "promptString",
"description": "File CSV con la serie storica dei prezzi",
"default": "C:\\Users\\alber\\Downloads\\BTC\\btcusd_bitstamp_1min_2012-2025.csv"
"description": "Cartella dei dati: data/market (barre M15) per baskets e falsify, la cartella dei tick MT5 per ticks, la cartella data del bot per learn",
"default": "C:\\Users\\alber\\Documents\\Encelado\\data\\market"
},
{
"id": "comando",
"type": "pickString",
"description": "Cosa misurare",
"options": ["split", "walk", "frequency", "costs", "sweep", "explore"],
"default": "split"
"description": "Comando del backtest",
"options": ["baskets", "falsify", "ticks", "learn"],
"default": "baskets"
}
]
}
+89
View File
@@ -0,0 +1,89 @@
# Cronologia
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.
- **Modalità** ridotte a `Paper`, `Demo` (default), `Live`: nessuna approvazione manuale dei singoli ordini (decisione dell'utente, ADR-0005). I nomi precedenti vengono letti con un avviso.
- **Interfaccia** rifatta: barra in alto con tre schede, stato, ambiente, ora e AVVIA; dashboard con equity, P&L di oggi, P&L aperto, drawdown, basket aperti, tabella dei basket, contesto e attività. Tema nuovo. Test di rendering in PNG.
- **Fuso orario** della finestra selezionabile (`ui.timeZone`); il log porta l'offset, il ledger resta UTC.
- **Corretto** il parser dei costi di eToro (campo `value`): markup e overnight non erano letti.
- **Apprendimento** collegato al motore: modello in ombra, challenger, bandit, previsione di volatilità, ciclo settimanale, `knowledge/`. Standardizzatore dell'MLP adattato all'insieme di addestramento.
- **Backtest**: test di falsificazione 5 (segnale invertito), scenario di costi `api`, `docs/STRATEGY.md` con il verdetto negativo e i numeri.
- Feed: dopo due errori consecutivi una fonte logga solo a debug e ritenta con attese crescenti.
- Documenti nuovi: `STRATEGY.md`, `ML_AND_LEARNING.md`, `RUNBOOK.md`, `GLOSSARY.md`, `KNOWN_ISSUES.md`, ADR-0004, ADR-0005. Catena di rilascio aggiornata ai tre comandi dello strumento.
- Versione 4.0.0.
## 2026-09-16 (mattina) — Correlation Baskets su eToro, Fasi 0-7
- Ricognizione del repository; verifica dell'API eToro (rotte, quote, schemi, limiti), degli strumenti, della valuta del conto, dei feed, del formato dei tick.
- Broker eToro (`Encelado.Etoro`), `PaperBroker`, chiavi DPAPI, `--headless`; cross sintetici, indicatori, decisore, cost gate, sizing, esecutore con protocollo leg-risk, backtest event-driven e griglia con PSR/DSR/PBO/walk-forward; calendario, RSS, sentiment, ledger; livelli di apprendimento 0-3 nel Core.
- Documenti: `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/QUESTIONS.md`, `docs/STATE.md`, `DATA_SOURCES.md`, `LEDGER_SCHEMA.md`, `RISK_RULES.md`, ADR-0001 (eToro), ADR-0002 (storage su file), ADR-0003 (motore cTrader mantenuto selezionabile; superata da ADR-0004).
+90
View File
@@ -0,0 +1,90 @@
# Encelado — guida per chi lavora sul repository (umano o AI)
**Leggi prima `docs/STATE.md`.** È la memoria di lavoro fra una sessione e l'altra: dice a che fase siamo, cosa è stato fatto per ultimo e cosa manca.
## Scopo
Bot di trading in C# (.NET 10) 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, 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, comando `learn` |
| `docs/DATA_SOURCES.md` | ogni fonte (URL, formato, limiti), schema dei file in `data/` |
| `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 (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 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**: 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
```powershell
dotnet build Encelado.slnx # compilazione
dotnet test tests/Encelado.Tests --no-restore # test (xunit)
dotnet msbuild build/Release.proj -t:Verifica # compilazione + test nella cartella di verifica
dotnet run --project src/Encelado.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 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` (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, 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 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 -1
View File
@@ -13,7 +13,12 @@
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<Product>Encelado</Product>
<Company>Encelado</Company>
<Version>3.2.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"]
+3 -2
View File
@@ -1,8 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Encelado.Alpaca/Encelado.Alpaca.csproj" />
<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>
<Folder Name="/tests/">
<Project Path="tests/Encelado.Tests/Encelado.Tests.csproj" />
+2 -4
View File
@@ -1,4 +1,2 @@
Cose da fare:
- Non caricare i sorgenti del programma nella release. Deve solo essere presente la versione portable e la versione installabile.
- Il nome del setup deve essere Encelado_Versione.exe (ad esempio Encelado_4.14.0.exe)
Ottimo! Ora effettua le seguenti modifiche:
-
+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 "Bot di trading automatico su Alpaca"
[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 credenziali Alpaca 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 credenziali Alpaca 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;
+64 -49
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 | serie storiche di prezzi |
| 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,34 +27,53 @@ posto in `Release.proj`:
| Attività | Cosa fa |
|---|---|
| `verifica` | Compila e lancia i test. |
| `backtest` | Rigioca una serie storica di prezzi. |
| `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"
# Il backtest vuole un file di dati
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="C:\dati\btcusd.csv"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=frequency
# La ricerca sui basket: barre M15 in data/market (da `ticks`), tabelle in results/ e reports/
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data\market"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=falsify -p:Extra="--costs api"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="A:\Download\Trading" -p:Comando=ticks
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` | — | Il CSV da rigiocare. Obbligatorio per `Backtest`. |
| `Comando` | `split` | `split`, `walk`, `frequency`, `costs`, `sweep`, `explore`. |
| `Barre` | `1d` | Ampiezza delle barre nel backtest. |
| `Dati` | — | La cartella dei dati. Obbligatoria per `Backtest`. |
| `Comando` | `baskets` | `ticks`, `baskets`, `falsify`, `learn`. |
| `Extra` | vuoto | Altre opzioni passate allo strumento così come sono, es. `--costs api --quick`. |
## Cosa succede in `Rilascia`
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
@@ -63,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
@@ -81,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
+156 -144
View File
@@ -7,15 +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 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
@@ -26,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
@@ -97,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>
@@ -242,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.
@@ -279,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"
@@ -291,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" />
@@ -324,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>
@@ -342,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>
@@ -357,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) + "\"," +
@@ -405,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)" />
@@ -414,59 +386,60 @@
<Message Importance="High" Text=" tutto a posto" />
</Target>
<!-- ═════════════════════ Rigiocata sui prezzi ═════════════════════ -->
<!-- ═════════════════════ Ricerca sui basket ═════════════════════ -->
<!--
Il corrispettivo del backtest sui dossier di AutoBidder. Qui non si rigiocano
aste ma serie storiche di prezzi, con lo strumento in tools/Encelado.Backtest.
Lo strumento in tools/Encelado.Backtest: `ticks` converte i tick MT5 in barre
M15 bid/ask, `baskets` rigioca la strategia (baseline, griglia, PSR/DSR, PBO,
walk-forward), `falsify` esegue i test di falsificazione, `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.
I parametri li legge da config/encelado.json, non li ridichiara: il banco e
il bot non possono divergere senza che qualcuno se ne accorga.
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data\market"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=falsify -p:Extra="‐‐costs api"
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="A:\Download\Trading" -p:Comando=ticks
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data" -p:Comando=learn
-->
<Target Name="Backtest">
<PropertyGroup>
<Comando Condition="'$(Comando)' == ''">split</Comando>
<Barre Condition="'$(Barre)' == ''">1d</Barre>
<Comando Condition="'$(Comando)' == ''">baskets</Comando>
<BacktestExe>$(Radice)\tools\Encelado.Backtest\bin\Release\net10.0\backtest.exe</BacktestExe>
</PropertyGroup>
<Error Condition="'$(Dati)' == ''"
Text="Serve un file di dati: -p:Dati=&quot;C:\percorso\btcusd.csv&quot;.%0AComandi disponibili in -p:Comando= : split, sweep, walk, frequency, costs, explore." />
Text="Serve la cartella dei dati: -p:Dati=&quot;%USERPROFILE%\Documents\Encelado\data\market&quot; (barre candles_SYMBOL_M15.csv) per baskets e falsify, 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="File di dati non trovato: $(Dati)" />
<Error Condition="!Exists('$(Dati)')" Text="Cartella dati non trovata: $(Dati)" />
<Message Importance="High" Text="== Rigiocata sui prezzi ==" />
<Message Importance="High" Text="== Ricerca sui basket ==" />
<Message Importance="High" Text=" dati : $(Dati)" />
<Message Importance="High" Text=" comando : $(Comando) su barre da $(Barre)" />
<Message Importance="High" Text=" comando : $(Comando) $(Extra)" />
<Exec WorkingDirectory="$(Radice)" EnvironmentVariables="$(AmbientePulito)"
Command="dotnet build &quot;$(BacktestProj)&quot; -c Release --nologo -v q" />
<Exec WorkingDirectory="$(Radice)"
Command="&quot;$(BacktestExe)&quot; $(Comando) --file &quot;$(Dati)&quot; --tf $(Barre) $(Extra)" />
Command="&quot;$(BacktestExe)&quot; $(Comando) --data &quot;$(Dati)&quot; $(Extra)" />
</Target>
<!-- ═════════════════════ Eseguibile ═════════════════════ -->
<!-- ═════════════════════ 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
@@ -476,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>
@@ -506,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 ═════════════════════ -->
<!--
@@ -515,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 ═════════════════════ -->
@@ -598,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>
@@ -616,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)" />
@@ -626,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
@@ -655,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;" />
@@ -673,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;" />
@@ -689,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"
@@ -709,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>
+50 -130
View File
@@ -1,150 +1,70 @@
{
"_comment": "Encelado — configurazione unica. Ogni numero qui sotto è stato verificato su due dataset indipendenti: 4.756 barre giornaliere Bitstamp (2012-2025, ripiegate da 6,8 milioni di barre da un minuto) e 3.260 barre Binance (2017-2026). Vedi il README.",
"_commento": "Configurazione di Encelado — Correlation Baskets su eToro (CFD forex). Le chiavi con il prefisso _ sono documentazione e vengono ignorate. I parametri della strategia (basket, preset, soglie, rischio) stanno in strategy.json accanto a questo file. Le chiavi API non stanno qui: si inseriscono dalla finestra e vivono cifrate in %LOCALAPPDATA%\\Encelado\\etoro.dat, oppure nelle variabili d'ambiente ETORO_API_KEY e ETORO_USER_KEY.",
"alpaca": {
"paper": true,
"dataFeed": "iex",
"requestsPerMinute": 180,
"httpTimeoutSeconds": 15,
"maxRetries": 4
"etoro": {
"_note": "eToro Public API. environment = demo oppure real: chiavi e rotte sono diverse, e l'ambiente attivo è sempre visibile nella finestra.",
"environment": "demo",
"baseUrl": "https://public-api.etoro.com",
"requestTimeoutSeconds": 20,
"_fillTimeoutSeconds": "Quanto attendere l'esito di un ordine (eToro lo lavora in modo asincrono) prima di trattarlo come non confermato e riconciliare. È anche il timeout della seconda gamba (leg-risk).",
"fillTimeoutSeconds": 5
},
"engine": {
"assetClass": "crypto",
"_timeFrame": "Giornaliero. Le stesse regole su barre orarie perdono il 99% del capitale: con ~50 bps di costo per giro completo la frequenza uccide prima della direzione.",
"timeFrame": "1Day",
"_warmup": "La media è a 100 giorni. 220 barre danno margine.",
"warmupBars": 220,
"tradeOnlyRegularHours": false,
"flattenBeforeCloseMinutes": 0,
"_crypto": "Alpaca sulle crypto vuole quantità frazionarie e non supporta i bracket order: lo stop lo tiene l'engine e lo verifica a ogni quotazione.",
"allowFractionalShares": true,
"useBracketOrders": false,
"entryOrderType": "limit",
"limitOffsetBps": 8,
"dryRun": false,
"_reconcile": "Ogni quanto il bot ricontrolla conto, posizioni e ordini contro il broker, e chiede le barre già chiuse. È anche il momento in cui si accorge che una barra nuova è disponibile da valutare, quindi abbassarlo lo rende più reattivo all'apertura di una barra.",
"reconcileSeconds": 30,
"_status": "Riepilogo periodico nel log: contatori, latenze, stato delle connessioni.",
"run": {
"_executionMode": "Paper = simulatore locale sopra le quotazioni reali (nessun ordine sul conto). Demo = conto demo di eToro: ordini veri, denaro virtuale, il bot apre e chiude da solo. Live = conto reale: richiede allowLive = true e la frase CONFERMO LIVE a ogni avvio. Nessuna modalità chiede l'approvazione dei singoli ordini (decisione D-20).",
"executionMode": "Demo",
"allowLive": false,
"_pollSeconds": "Secondi fra due letture delle quotazioni (una richiesta per tutti gli strumenti). 3 s = 20 richieste al minuto su una quota di 120: resta spazio per candele e costi.",
"pollSeconds": 3,
"_statusSeconds": "Ogni quanti secondi il bot scrive una riga di stato nel log (e sulla console in headless).",
"statusSeconds": 60,
"_explain": "Ogni quanto il bot rilegge cosa farebbe al prezzo attuale e lo scrive nel log, se è cambiato rispetto a prima. Su barre giornaliere il bot è legittimamente silenzioso per settimane, e da fuori il silenzio è indistinguibile da un blocco: questa riga lo trasforma in una frase.",
"explainSeconds": 5,
"maxQuoteAgeSeconds": 120,
"closeOnShutdown": false
"_closeOnShutdown": "true = fermare il bot chiude i basket aperti a mercato. false = restano sul conto con gli stop nativi sul server, senza nessuno che applichi il take-profit o lo stop di basket finché il bot non riparte.",
"closeOnShutdown": false,
"strategyFile": "strategy.json",
"_cartelle": "Relative alla cartella di questo file: data (mercato, calendario, notizie, ledger, modelli), knowledge (calibrazione, proposte, registri), reports.",
"dataDirectory": "data",
"knowledgeDirectory": "knowledge",
"reportsDirectory": "reports",
"_paper": "Solo per executionMode = Paper: saldo iniziale del simulatore e slippage per gamba oltre lo spread reale del momento.",
"paperStartingBalance": 10000,
"paperSlippagePips": 0.3
},
"risk": {
"_sizing": "Questa strategia compete con il comprare e tenere, quindi quando è dentro deve esserci per intero: qualunque frazione inferiore perde la gara in partenza. stakePct 1.0 impegna tutto il saldo disponibile; il risk engine si ferma comunque al 98% per lasciare spazio alle commissioni.",
"stakePct": 1.0,
"stakeAmount": 0,
"_risk": "Non usato finché stakePct è impostato, ma deve restare valido: è il criterio di riserva se un giorno azzeri stakePct.",
"maxRiskPerTradePct": 0.05,
"_caps": "A 1.0 perché con un solo asset e stake pieno la posizione È il portafoglio. Abbassarli qui significa restare parzialmente liquidi e perdere rendimento senza guadagnare protezione: la protezione la dà l'uscita sotto la media.",
"maxPositionNotionalPct": 1.0,
"maxGrossExposurePct": 1.0,
"_openPositions": "0 = nessun limite. Nota però che con un solo simbolo il numero di posizioni contemporanee resta 1 comunque: il risk engine rifiuta un secondo ingresso sullo stesso strumento con 'already in position'. E con stakePct 1.0 la prima posizione impegna tutto il saldo, quindi una seconda non avrebbe con cosa aprirsi. Questo limite torna a contare quando aggiungi simboli.",
"maxOpenPositions": 0,
"_frequency": "0 = nessun limite. Il bot può aprire quante posizioni vuole e fare quante operazioni vuole: a fermarlo è la strategia, non un contatore. Attenzione: erano una rete contro un bug (un ciclo che riapre la stessa posizione mille volte costa mille commissioni). Con 0 quella rete non c'è più.",
"maxTradesPerDay": 0,
"maxTradesPerSymbolPerDay": 0,
"minSecondsBetweenEntries": 0,
"_dailyLoss": "Kill switch giornaliero. Al 25% perché su BTC un -20% in un giorno è successo più volte e non è una ragione per smettere: la strategia esce quando cede la media, non quando fa male. Troppo stretto qui significa liquidare sul minimo.",
"maxDailyLossPct": 0.25,
"maxDailyProfitPct": 0,
"maxRelativeSpread": 0.0015,
"minPrice": 0.01,
"maxPrice": 10000000,
"minOrderNotional": 25,
"maxOrderNotional": 0,
"_shorting": "Alpaca non consente lo short sulle crypto. La strategia è long/flat.",
"allowShorting": false,
"_stop": "Rete di sicurezza per un gap, non il controllo del rischio. Quello vero è l'uscita sotto la media: uno stop stretto venderebbe e poi aspetterebbe un nuovo incrocio per rientrare, che è esattamente come il modello precedente trasformava le oscillazioni in perdite realizzate.",
"defaultStopPct": 0.35,
"maxStopDistancePct": 0.60
"ui": {
"_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": {
"_level": "trace | debug | info | warn | error | none. 'debug' registra anche ogni segnale scartato e ogni rifiuto del risk engine: utile per capire perché il bot NON ha fatto qualcosa.",
"level": "debug",
"_directory": "Dove salvare tutti gli output. Relativa all'eseguibile, oppure un percorso assoluto tipo D:\\encelado-logs. Si cambia anche da Impostazioni → Log, che verifica di potervi scrivere prima di salvare.",
"directory": "logs",
"_level": "trace, debug, info, warn, error, none. 'info' basta: ogni rifiuto che impedisce un ordine viene scritto a questo livello o sopra, con il basket e il motivo esatto.",
"level": "info",
"console": false,
"_directory": "Cartella dei log, relativa a questo file se non è assoluta.",
"directory": "logs",
"file": "encelado.log",
"_rotation": "Ruota encelado.log in encelado.1.log e così via, tenendo gli ultimi 10.",
"_rotazione": "Superata maxFileSizeMb il file viene ruotato (encelado.1.log, encelado.2.log…) e ne restano maxFiles.",
"maxFileSizeMb": 32,
"maxFiles": 10,
"_analysis": "decisions.csv ha una riga per ogni barra valutata con tutti gli indicatori; executions.csv ha una riga per ogni segnale arrivato agli ordini, con il verdetto del risk engine. Si uniscono su decisionId. Sono il materiale per migliorare il modello.",
"tradeJournal": "trades.jsonl",
"decisionLog": "decisions.csv",
"executionLog": "executions.csv",
"_verbose": "Con logMarketData attivo e level=trace registra ogni singola quotazione e ogni print. File enormi: serve solo per diagnosticare il flusso dati.",
"logMarketData": false,
"_everyBar": "Scrive una riga per ogni barra da un minuto che arriva dallo stream, non solo per quelle che chiudono una barra della strategia. Su barre giornaliere 1439 minuti su 1440 vengono assorbiti in silenzio: senza questo il log non mostra nulla per ventiquattr'ore e il bot sembra fermo.",
"logEveryBar": true,
"_inApp": "Quante righe tiene la striscia ATTIVITÀ nella pagina Stato e quante ne tiene la scheda Log. La seconda è il tetto di memoria del log in-app. Il file su disco resta completo comunque.",
"_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
},
"ui": {
"url": "http://localhost:5088",
"autoStartBot": false,
"openBrowser": false
},
"_symbols": "Solo BTC/USD. ETH è stato tolto: la strategia è tarata e verificata su BTC, e con stakePct 1.0 un secondo asset dimezzerebbe l'esposizione al primo senza che nessun backtest lo giustifichi.",
"symbols": [
{
"symbol": "BTC/USD",
"strategy": "trend-filter",
"enabled": true,
"parameters": {
"_period": "Media a 100 giorni. È l'unico valore che batte il comprare e tenere su ENTRAMBI i dataset: 120 rende di più su Bitstamp ma perde su Binance, 200 perde su tutti e due. La riga dei 100 giorni vince su entrambi a qualunque banda.",
"period": 100,
"_band": "Isteresi, non un filtro: si entra il 2% sopra la media e si esce il 2% sotto, così un prezzo appoggiato alla media non genera un'operazione ogni due giorni. Dimezza gli scambi lasciando il rendimento dov'era.",
"band": 0.02,
"_stop": "Rete per un gap. La vera uscita è la media.",
"stopPct": 0.35,
"_cvd": "Gate di order flow, disattivato. Misurato su Binance con il volume taker: alzandolo il Calmar scende da 0,71 a 0,66 a 0,64. Serviva al modello precedente, che operava di rado e poteva permettersi di aspettare conferma; qui ogni barra passata ad aspettare è una barra che non compone. Il valore resta calcolato e registrato nei log.",
"cvdThreshold": 0,
"cvdPeriod": 10,
"cvdNormPeriod": 60,
"_diagnostics": "Solo per il pannello e i log, non entrano in nessuna decisione.",
"volPeriod": 30,
"barsPerYear": 365,
"atrPeriod": 14,
"allowShort": 0
}
"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
}
]
}
}
@@ -1,12 +0,0 @@
{
"_comment": "Copy this file to encelado.local.json (gitignored) next to encelado.json. It is merged on top of the main config, so it only needs the keys you want to override. Environment variables still win over both.",
"alpaca": {
"keyId": "PK...........",
"secretKey": "................................"
},
"engine": {
"dryRun": true
}
}
+104
View File
@@ -0,0 +1,104 @@
{
"_comment": "Encelado — strategia Correlation Baskets su eToro. Cinque basket di due coppie forex correlate: si entra quando il cross sintetico diverge (z-score), si esce quando converge o al take-profit di basket in pip; lo stop di basket è obbligatorio. Ogni chiave con '_' davanti è documentazione.",
"_preset": "Conservative | Moderate | Aggressive. Fissa zIn, riskPerBasketPct, maxBaskets, tpPips, maxAdds, zStop; si cambia a caldo dalla finestra senza toccare i basket aperti. Le chiavi omonime qui sotto, se presenti, sovrascrivono il preset.",
"preset": "Moderate",
"_signalMode": "ZScoreSynthetic (default, |z| >= zIn sul cross sintetico) oppure PipDivergence (fedele all'interfaccia Titany: divergenza in pip dall'ancora, dIn).",
"signalMode": "ZScoreSynthetic",
"_exitMode": "First = la prima fra TP in pip e rientro dello z; FixedPips = solo TP in pip lordi; ZReturn = solo |z| <= zOut.",
"exitMode": "First",
"_averagingMode": "Off | AddOnce | Grid. Off in live; AddOnce in paper. Moltiplicatore di lotto sempre 1,0 (niente martingala).",
"averagingMode": "Off",
"tpMode": "Pips",
"_sameCrossPolicy": "I basket 4 e 5 sono entrambi EURCAD: Exclusive = uno solo aperto per volta; Half = entrambi a metà size.",
"sameCrossPolicy": "Exclusive",
"preferDirectCross": false,
"_indicatori": "Correlazione di Pearson rolling dei rendimenti M15 su window (ρ_W) e windowShort (ρ_20); z-score del cross sintetico su window; half-life OLS ricalcolata ogni halfLifeRecalcHours.",
"window": 100,
"windowShort": 20,
"rhoMin": 0.60,
"rhoShortMin": 0.40,
"halfLifeMinBars": 4,
"halfLifeMaxBars": 96,
"halfLifeRecalcHours": 4,
"atrPeriod": 14,
"ewmaSpan": 100,
"trendPeriod": 14,
"zOut": 0.25,
"dIn": 15,
"anchorBars": 32,
"gridStepZ": 0.75,
"lotMultiplier": 1.0,
"_uscite": "Stop di basket: |z| >= zStop, oppure perdita netta >= maxLossPerBasketPct dell'equity, oppure |ρ_20| < rhoBreak per rhoBreakBars barre, oppure maxHoldingBars barre (96 = 24 h).",
"maxLossPerBasketPct": 1.5,
"rhoBreak": 0.20,
"rhoBreakBars": 8,
"maxHoldingBars": 96,
"tpAtrMultiple": 1.0,
"_costGate": "Costo = spread_A + spread_B (in pip-equivalenti di A) + markup e commissioni dell'API + overnight stimato per maxHoldingBars. Entrata solo se TP >= costMultiple × costo e ogni spread <= spreadMedianMultiple × la sua mediana delle ultime 24 h; spread oltre spreadAnomalyMultiple × mediana = chiusura forzata.",
"costMultiple": 3,
"spreadMedianMultiple": 2,
"spreadAnomalyMultiple": 3,
"slippagePipsPerLeg": 0.3,
"overnightPipsPerDay": 0.3,
"_calendario": "Nessuna entrata nei blackoutBeforeMin minuti prima e blackoutAfterMin dopo un evento ad alto impatto sulle valute del basket; niente entrate dal venerdì fridayCutoffUtcHour UTC alla riapertura né nei primi openDelayMinutes dopo l'apertura settimanale; sessions = fasce orarie UTC ammesse (vuoto = sempre).",
"blackoutBeforeMin": 45,
"blackoutAfterMin": 30,
"fridayCutoffUtcHour": 20,
"openDelayMinutes": 30,
"sessions": [],
"_sizing": "Lotto B = lotto A × (ATR_A × pipValue_A) / (ATR_B × pipValue_B); lotto A tale che la perdita allo stop valga riskPerBasketPct dell'equity; leva effettiva <= maxEffectiveLeverage sul nozionale complessivo; orderLeverage è la leva dichiarata a eToro per ogni gamba (1, 2, 5, 10, 20, 30).",
"maxEffectiveLeverage": 10,
"orderLeverage": 10,
"_volScale": "zIn effettivo = zIn × clamp(σ_prevista / σ_media_30g, volScaleMin, volScaleMax).",
"volScaleMin": 0.8,
"volScaleMax": 1.5,
"volAverageDays": 30,
"mlMinProbability": 0.55,
"_sicurezza": "equityStopPct: perdita dal picco di equity (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" },
{ "a": "AUDUSD", "b": "USDCAD", "enabled": true, "note": "cross sintetico AUDCAD" },
{ "a": "NZDUSD", "b": "EURNZD", "enabled": true, "note": "cross sintetico EURUSD: replica EURUSD pagando due spread" },
{ "a": "USDCAD", "b": "EURUSD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 5)" },
{ "a": "EURAUD", "b": "AUDCAD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 4)" }
]
}
+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
+128
View File
@@ -0,0 +1,128 @@
# Architettura di Encelado
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. Progetti
```
Encelado.slnx
├── 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` 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`.
## 2. Flusso dati (live)
```mermaid
flowchart LR
E[eToro Public API] -->|rates ogni 3 s| Q[Quote poller]
Q --> B[Bar builder M15]
E -->|candles| B
B --> S[Strategy loop<br/>un solo thread]
C[Calendario + RSS] --> F[Feature contesto]
F --> S
M[Meta-modello in ombra<br/>vol forecast] --> S
S -->|decisione| X[Executor<br/>leg-risk + OrderTracker]
X --> E
S --> L[(Ledger jsonl/csv)]
X --> L
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, tutti dall'interfaccia web, dalla console o da Telegram.
## 3. Macchina a stati del basket
```
Idle ──(segnale + cancelli)──► Entering ──(A e B eseguite)──► Open ──(add)──► Adding ──► Open
▲ ▲ │ (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)
Error (blocco nuove entrate finché non risolto)
```
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.
## 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` (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`).
## 5. API web
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?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).
+97
View File
@@ -0,0 +1,97 @@
# Fonti dei dati
Aggiornato: 2026-09-16. Ogni fonte è stata verificata alla data indicata; se un feed cambia o sparisce, la voce va aggiornata e la decisione annotata in `docs/QUESTIONS.md`.
## 1. Mercato
| Fonte | Cosa | Formato | Frequenza | Limiti | Fallback |
|---|---|---|---|---|---|
| **eToro Public API**`GET /api/v2/market-data/rates?instrumentIds=…` | bid/ask di tutti gli strumenti in una chiamata | JSON `{results:[{instrumentId,bid,ask,date,quoteType}]}` | polling ogni `run.pollSeconds` (3 s) | quota condivisa 120/min con le altre rotte di market data; il bot ne usa ~20/min | nessuno: senza quote il bot non decide |
| **eToro Public API**`GET /api/v1/market-data/instruments/{id}/history/candles/asc/FifteenMinutes/1000` | ultime 1000 candele M15 (mid, senza spread) | JSON | all'avvio, per il riscaldamento e il delta | **non pagina**: niente data di partenza, al massimo ~10 giorni | le barre locali salvate a ogni chiusura |
| **eToro Public API**`GET /api/v2/market-data/instruments?symbols=…`, `POST /trading/info/{demo/}eligibility` | id, nome, esposizione minima, leve, limiti di stop | JSON | all'avvio, scritti in `instruments.json` | 120/min e 20/min dedicate | valori prudenti incorporati (esposizione minima 1000 USD, leve 1-20) |
| **eToro Public API**`POST /trading/info/{demo/}costs` | markup, spread di mercato, commissione, overnight e weekend per un ordine ipotetico | JSON `{costs:[{costType, currency, value}]}` — il campo è **`value`** (verificato il 2026-09-16: EURUSD 10 000 unità → markup 0, marketSpread 0,1 USD, overnightFee 0,91 USD/giorno) | ogni 15 minuti per strumento | 20/min dedicate | markup 0 e overnight da `strategy.json` |
| **Tick MetaTrader 5**`A:\Download\Trading\<SYMBOL>_<da>_<a>.csv` | tick bid/ask 2018-12-12 → 2026-09-15, **UTC** (verificato sui fine settimana: chiusura venerdì 20:53 estate / 21:57 inverno, riapertura domenica 21:05 / 22:05) | tab-separato `<DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>`; flag 2 = solo bid, 4 = solo ask, 6 = entrambi | una tantum, `backtest ticks` | EURAUD copre solo parte del 2018, del 2021 e del 2026 (39 615 barre contro ~192 000 delle altre): il basket EURAUD/AUDCAD è misurabile solo su quei tratti | — |
| Barre M15 derivate — `Documenti\Encelado\data\market\candles_<SYMBOL>_M15.csv` | OHLC bid e ask, spread medio, numero di tick, provenienza | CSV `;` (schema in `docs/LEDGER_SCHEMA.md`) | scritte dallo strumento e aggiornate dal bot a ogni barra chiusa | — | — |
Qualità (`data/market/data_quality.csv`, generato da `backtest ticks`, e `reports/data_quality.csv` dal bot): buchi > 1 h nei giorni feriali, salti > 2 % fra barre, duplicati. Una barra sospetta sospende le decisioni sul basket coinvolto per quella barra.
### 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 |
|---|---|---|---|---|
| Forex Factory via FairEconomy | `https://nfs.faireconomy.media/ff_calendar_thisweek.json` | JSON `[{title,country,date,impact,forecast,previous,actual}]`, `date` con offset (ora di New York) | il feed cambia più volte al giorno; il bot lo rilegge ogni 10 minuti, mai più di una richiesta al minuto | `country` è già il codice valuta (`USD, EUR, GBP, JPY, AUD, NZD, CAD, CHF, CNY`, `All`); `impact` ∈ {High, Medium, Low, Holiday} |
| variante XML | `https://nfs.faireconomy.media/ff_calendar_thisweek.xml` | `<weeklyevents><event>` con `date` MM-DD-YYYY e `time` 8:15am **in UTC** (verificato contro il JSON: "10:30pm" del 09-13 = "18:30-04:00") | idem | usata solo come riserva |
Archivio: `data/calendar/events.jsonl` (append-only, una riga per evento, dedup per `title+date+country`; un `actual` che arriva dopo la pubblicazione è una riga nuova). Feature derivate per ogni valuta: `minutesToNextHigh`, `minutesSinceLastHigh`, `surpriseLast = (actual forecast)/|forecast|`.
Limite: il feed copre **la settimana corrente**. Non esiste uno storico gratuito: il backtest non applica il blackout né le feature di calendario, e lo dice (`docs/STRATEGY.md`).
## 3. Notizie (RSS)
Tutte lette con `User-Agent: Encelado/4.0 (+correlation baskets; contact: operator)`, al massimo una richiesta al minuto per fonte, con backoff esponenziale sugli errori e rispetto di `robots.txt` (gruppo `User-agent: *`). Verifica del 2026-09-16:
| Fonte | URL | Formato | Esito |
|---|---|---|---|
| FXStreet | `https://www.fxstreet.com/rss/news` | RSS 2.0 | 200 |
| ForexLive | `https://www.forexlive.com/feed/` | RSS 2.0 | 200 |
| Federal Reserve | `https://www.federalreserve.gov/feeds/press_all.xml` | RSS 2.0 | 200 con lo User-Agent del bot; con uno User-Agent minimale risponde con una pagina HTML "not found" |
| BCE | `https://www.ecb.europa.eu/rss/press.html` | RSS 2.0 | 200 |
| Bank of England | `https://www.bankofengland.co.uk/rss/news` | RSS 2.0 | 200 |
| RBA | `https://www.rba.gov.au/rss/rss-cb-media-releases.xml` | RSS 1.0 (RDF) | 200 alla prima verifica, poi "Access Denied" (Akamai) a richieste successive: tenuta con backoff, coperta anche da Google News `"Reserve Bank of Australia"` |
| Bank of Canada | `https://www.bankofcanada.ca/content_type/press-releases/feed/` | RSS 1.0 (RDF) | 200 |
| SNB | `https://www.snb.ch/en/rss/press-releases` | — | **404**: omessa (D-08); coperta da Google News `"Swiss National Bank"` |
| RBNZ | `https://www.rbnz.govt.nz/rss/news` | — | **403** "website unavailable": omessa (D-08); coperta da Google News `RBNZ` |
| Google News | `https://news.google.com/rss/search?q=<query>&hl=en-US&gl=US&ceid=US:en` per `EURUSD`, `"Swiss National Bank"`, `RBNZ`, `forex dollar` | RSS 2.0 | 200 |
Archivio: `data/news/news_YYYYMM.jsonl` (append-only, una riga per item, dedup per `hash(link)`), con `published, source, title, summary, link, currencies, scores{net, hawkish, riskOff}`.
Sentiment senza librerie (`Encelado.Core/News/SentimentLexicon.cs`, `SentimentEngine.cs`): lessico incorporato in tre dimensioni (tono positivo/negativo ~180 termini ciascuno, hawkish/dovish ~80, risk-on/risk-off ~50), negazione a finestra di tre parole, attribuzione alle valute per entità (`Fed, Powell, FOMC → USD; ECB, Lagarde → EUR; BoJ → JPY; RBA → AUD; RBNZ → NZD; BoC → CAD; SNB → CHF; BoE → GBP`), parole-paese e nomi di coppia. Per ogni valuta e finestra (1 h, 4 h, 24 h): `netSentiment`, `hawkishScore`, `riskOff` (globale), `newsCount`, con decadimento esponenziale a emivita 2 h. Le feature di un basket sono le differenze fra le sue due valute non comuni.
Copie dei feed usate dai test: `tests/fixtures/` (scaricate il 2026-09-16).
## 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`)
```
/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}
data/news/news_YYYYMM.jsonl {hash,published,source,title,summary,link,currencies[],scores{net,hawkish,riskOff}}
data/cache/<fonte>.xml|json ultimo corpo buono di ogni feed
data/ledger/decisions.jsonl vedi docs/LEDGER_SCHEMA.md (rotazione mensile in decisions_YYYYMM.jsonl)
data/ledger/baskets.csv vedi docs/LEDGER_SCHEMA.md
data/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, 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.
+51
View File
@@ -0,0 +1,51 @@
# Glossario
| Termine | Significato in Encelado |
|---|---|
| **Basket** | Due posizioni (una per coppia forex) aperte insieme e chiuse insieme, trattate come una sola scommessa sul cross sintetico. |
| **Cross sintetico** | La coppia implicita nelle due gambe: `X = ln A + s·ln B`, con `s = +1` se la valuta comune ha ruoli opposti (EURUSD/USDCHF → EURCHF) e `1` se uguali. Tutti e cinque i basket della specifica hanno `s = +1`. |
| **Gamba** | Una delle due posizioni del basket. |
| **z-score** | `(X media_W) / σ_W` del cross sintetico su una finestra di W barre M15. Ingresso a `|z| ≥ z_in`, uscita a `|z| ≤ z_out` o allo stop `|z| ≥ z_stop`. |
| **ρ_W, ρ_20** | Correlazione rolling dei rendimenti delle due gambe su W e su 20 barre. Attesa negativa per i basket della specifica (`rho_min` 0,6). |
| **Half-life (HL)** | Semiperiodo di mean reversion del cross, in barre, da un OLS di Δx su x(t1). Ammesso fra `halfLifeMinBars` e `halfLifeMaxBars`. |
| **Preset** | Conservative / Moderate / Aggressive: z_in, rischio per basket, numero massimo di basket, TP in pip, aggiunte, z_stop. |
| **TP di basket** | Take-profit in pip, somma dei pip delle due gambe (come nell'interfaccia di riferimento). |
| **Cost gate** | Il rifiuto di un ingresso se il TP non copre almeno `costMultiple` volte il costo stimato (spread reale + markup + commissioni + overnight atteso), o se lo spread è più del doppio della mediana delle ultime 24 ore. |
| **Break-even** | Il costo in pip oltre il quale il P&L medio lordo di un basket diventa negativo: se è vicino a zero, il segnale non ha contenuto. |
| **Vol-parity sizing** | Le unità di ogni gamba sono inversamente proporzionali alla sua volatilità (ATR), così le due gambe contribuiscono allo stesso rischio; il rischio totale è `riskPerBasketPct` dell'equity alla distanza dello stop. |
| **Leg-risk** | Il rischio di restare con una sola gamba: se la seconda 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). |
| **Ledger** | `decisions.jsonl` (ogni decisione con le sue feature) e `baskets.csv` (ogni basket chiuso). Append-only: le correzioni sono righe nuove. |
| **Meta-modello** | La regressione logistica (livello 1) che stima la probabilità che un basket finisca in utile. In ombra finché non supera i cancelli di attivazione. |
| **Challenger** | L'MLP (livello 2) valutato accanto al campione. |
| **Bandit** | Il campionamento di Thompson (livello 3) che propone il preset per terzile di volatilità. |
| **Walk-forward** | Valutazione in cui ogni previsione usa solo dati precedenti; per la griglia del backtest: scegli il migliore dei 6 mesi passati, applicalo al mese successivo. |
| **PSR / DSR** | Probabilistic e Deflated Sharpe Ratio: la probabilità che lo Sharpe osservato sia sopra zero, tenendo conto di asimmetria, curtosi, lunghezza e (DSR) del numero di configurazioni provate. |
| **PBO** | Probabilità di overfitting del backtest (CSCV, 16 blocchi): quante volte la configurazione migliore in-sample finisce sotto la mediana out-of-sample. |
| **Falsificazione** | I test di §9.2: ZScore contro PipDivergence, averaging on/off, con e senza stop, cost gate a 2×/3×/4×, segnale invertito. Servono a rompere il risultato, non a confermarlo. |
| **Forward test** | Il periodo in Demo con metrica, soglia e durata scritte prima (`knowledge/preregistrazione.csv`). |
| **Blackout** | Niente entrate 45 minuti prima e 30 dopo un evento ad alto impatto sulle valute del basket. |
| **PSI** | Population Stability Index: misura la deriva della distribuzione di una feature rispetto all'addestramento. |
| **HAR-RV** | Modello eterogeneo autoregressivo della varianza realizzata (medie a 1, 5, 22 giorni). |
| **Run id** | Identificatore della sessione del bot, scritto in ogni riga del ledger con l'hash di `strategy.json`. |
| **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. |
+48
View File
@@ -0,0 +1,48 @@
# Problemi noti e limiti
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
- 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 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
- **Google News** vieta `/rss/search` nel `robots.txt`: le cinque query (EURUSD, SNB, RBNZ, RBA, forex) non vengono scaricate e restano vuote. SNB e RBNZ non hanno quindi nessuna fonte; RBA solo il feed ufficiale, che risponde 403 a intermittenza (Akamai). Il sentiment su CHF, NZD e in parte AUD è di fatto zero.
- Il feed della Fed risponde 404 a tratti (osservato alle 15:16 UTC+2 del 2026-09-16): la cache copre i buchi.
- Il calendario FairEconomy è settimanale: la settimana successiva compare solo da domenica.
## Bot
- 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).
- 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` è 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.
+123
View File
@@ -0,0 +1,123 @@
# Schema del ledger e delle tabelle
Regole comuni: UTC ovunque, `CultureInfo.InvariantCulture` per numeri e date, CSV con separatore `;` e ultima colonna `motivazione`, JSONL append-only. **Nessuna riga viene mai modificata**: le correzioni sono righe nuove con `evento = correzione`. Ogni riga porta `run_id` e, dove ha senso, `config_hash` (SHA-256 abbreviato di `strategy.json` canonicalizzato).
## `data/ledger/decisions.jsonl`
Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15 (ingresso, skip, aggiunta, posizione, uscita) più le uscite decise su una quotazione intermedia e gli esiti di esecuzione. Le feature sono quelle disponibili **al momento della decisione**: è la regola anti look-ahead, e il dataset di addestramento è questo file, non una ricostruzione.
| Campo | Tipo | Significato |
|---|---|---|
| `ts` | ISO 8601 UTC | istante della valutazione |
| `run_id` | testo | `yyyyMMdd-HHmmss-xxxxxx` della sessione del bot |
| `config_hash` | testo | hash di `strategy.json` in vigore |
| `basket` | testo | `A/B`, es. `EURUSD/USDCHF` |
| `basket_id` | testo | id del basket aperto (`B<yyyyMMddHHmmss>-<AB>`), vuoto se piatto; collega a `baskets.csv` |
| `cross` | testo | cross sintetico (`EURCHF`) |
| `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` |
| `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` |
| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; 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) |
| `D_pips` | numero | divergenza in pip dall'ancora (solo `PipDivergence`) |
| `rho_W`, `rho_20` | numero | correlazione rolling dei rendimenti su `window` e `windowShort` |
| `halfLife` | numero | emivita OLS in barre (null se λ ≥ 0) |
| `atrA`, `atrB` | numero | ATR(14) in pip |
| `sigmaX`, `ewmaVolX` | numero | deviazione standard del livello del cross sulla finestra; vol EWMA dei rendimenti del cross |
| `sigmaForecast`, `sigmaAverage30d` | numero | vol prevista 1-4 h e media 30 g (null finché il livello 8.5 non è attivo) |
| `costPips`, `breakEvenWinRate` | numero | costo stimato in pip-equivalenti di A; win rate di pareggio |
| `spreadA`, `spreadB`, `markupA`, `markupB` | numero | spread correnti in pip; markup dell'API in pip |
| `hourSin`, `hourCos`, `dow` | numero | ora sul cerchio; giorno della settimana (0 = domenica) |
| `minutesToNextHigh`, `minutesSinceLastHigh`, `surpriseLast` | numero/null | calendario per le valute del basket |
| `netSentDiff_1h/4h/24h`, `hawkishDiff`, `riskOff`, `newsCount` | numero | sentiment (valuta lunga valuta corta del cross) |
| `regimeTrend` | numero | forza di trend (ADX-like) del cross |
| `lastNOutcomes` | numero | media degli ultimi esiti (null finché non c'è storia) |
| `p_ML`, `mlActive` | numero, bool | probabilità del meta-modello e se è gate o ombra |
| `equity`, `openBaskets` | numero | equity e basket aperti al momento |
| `priceA`, `priceB`, `pipsOpen`, `pnlOpenUsd`, `barsHeld` | numero | stato della posizione (se aperta) |
| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage`, `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`
Una riga per basket chiuso. `label = 1` se `pnl_net_usd > 0`, altrimenti 0: è l'etichetta dei livelli 1-3.
```
basket_id;run_id;basket;mode;preset;opened_utc;closed_utc;buy_cross;entry_z;exit_z;pnl_gross_usd;pnl_net_usd;pips_gross;cost_pips;cost_usd;slippage_pips;adds;bars_held;exit_reason;equity_at_entry;p_ml_at_entry;label;durata_min;motivazione
```
`pips_gross` è la somma dei pip delle due gambe ai prezzi di esecuzione (la colonna "Pips" della UI), `cost_pips` il costo stimato all'ingresso, `slippage_pips` la differenza fra quotazione vista e prezzo eseguito sommata sulle gambe, `exit_reason` uno dei codici sopra più `manual`, `closed_by_broker`, `leg_closed_by_broker`, `end_of_data`, 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`
Una riga per configurazione provata nel backtest; N del Sharpe deflazionato = numero di righe.
```
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
```
`break_even_cost` = media per basket di (pip eseguiti + costo stimato), cioè i pip "mid-to-mid" catturati: il costo di giro che azzera il risultato. `p1_pnl`, `p5_pnl` = percentili 1 % e 5 % del P&L per basket (la coda che il win rate nasconde).
## `reports/falsificazione.csv`
```
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
```
## `knowledge/calibration.csv`
Win rate e P&L netto medio per bucket: `dimensione;bucket;n;win_rate;pnl_medio;pnl_totale;motivazione`, con dimensioni `|z|`, `rho_W`, `ora`, `giorno`, `minuti_evento`, `sentiment`, `preset`, `basket`.
## `knowledge/preregistrazione.csv`
Una riga per forward test: `data;config_hash;modalita;durata_minima;n_minimo_basket;sharpe_atteso;win_rate_atteso;dd_stop;stop_basket_consecutivi;esito;motivazione`.
## `knowledge/proposals.csv`
`data;origine;parametro;valore_attuale;valore_proposto;evidenza;stato;motivazione` — le proposte del ciclo settimanale; `stato` ∈ {proposta, in forward, accettata, respinta}. Nessuna proposta cambia i parametri live da sola.
## `knowledge/models_registry.csv`, `knowledge/forward_registry.csv`
`versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione` (stato ∈ shadow, challenger, champion, ritirato) e `data;config_hash;modalita;basket;pnl_net;sharpe;dd;stato;motivazione`.
+117
View File
@@ -0,0 +1,117 @@
# Apprendimento: livelli 0-3
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 **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
Una riga per basket **aperto**: le 28 feature scritte nel ledger nel momento della decisione (`decisions.jsonl`, evento `ingresso`), unite per `basket_id` all'esito scritto alla chiusura (`baskets.csv`: `label` = 1 se il P&L netto è positivo, `pnl_net`). Le feature non vengono mai ricostruite a posteriori: il dataset è il ledger (`LearningState.BuildDataset`).
| # | Feature | Origine |
|---|---|---|
| 0-3 | `z`, `abs_z`, `z_in_eff`, `d_pips` | z-score del cross sintetico, soglia effettiva dopo la scala di volatilità, divergenza in pip |
| 4-6 | `rho_w`, `rho_20`, `half_life` | correlazione rolling (finestra W e 20 barre), semiperiodo OLS |
| 7-10 | `atr_a`, `atr_b`, `sigma_x`, `ewma_vol_x` | volatilità delle gambe e del cross |
| 11 | `trend_strength` | forza di trend (ADX-like) del cross |
| 12-15 | `spread_a`, `spread_b`, `cost_pips`, `break_even_win_rate` | costi del momento |
| 16-18 | `hour_sin`, `hour_cos`, `day_of_week` | ora UTC ciclica, giorno |
| 19-20 | `minutes_to_high`, `minutes_since_high` | calendario (troncati a 24 h) |
| 21-23 | `sent_1h`, `sent_4h`, `hawkish_diff` | sentiment (valuta lunga valuta corta del cross) |
| 24 | `risk_off` | sentiment risk-off |
| 25 | `vol_ratio` | σ prevista / σ media 30 giorni |
| 26 | `last_outcomes` | media degli ultimi 10 esiti |
| 27 | `buy_cross` | direzione |
I nomi sono in `LearningFeatures.Names`; il test `LeakTests` verifica che nessun nome contenga l'esito e che un'etichetta presa dal futuro non sia apprendibile (AUC ≈ 0,5).
## Livello 0 — Calibrazione
`CalibrationTables.Build` raggruppa i basket chiusi per basket, preset, terzile di volatilità, ora del giorno, bucket di |z| e di costo, e scrive win rate e P&L medio per bucket in `knowledge/calibration.csv` (colonna `motivazione` con il conteggio). Serve a leggere dove la strategia paga e dove no, e a niente altro: non cambia soglie.
## Livello 1 — Logistica online (il campione)
`OnlineLogistic`: pesi su 28 feature standardizzate con statistiche rolling (`RollingStandardizer`, emivita 200 righe), SGD con L2 = 10⁻³ e tasso 0,01/√(1+n/100). Predice a ogni chiusura di barra (`p_ML` nella dashboard, "in ombra") e impara a ogni chiusura di basket. Stato in `data/models/logreg_current.json`; versioni datate `logreg_vN.json` con `trained_on_until` e hash del dataset.
**Valutazione walk-forward** (`ModelEvaluator.EvaluateLogistic`): sequenziale, predici-poi-aggiorna, con i primi 30 basket di burn-in esclusi dalle metriche. Metriche: AUC con intervallo bootstrap (1000 ricampionamenti), Brier, log-loss, curva di calibrazione in 10 bin, P&L di tutti i basket contro P&L dei soli basket con p ≥ `mlMinProbability`, Sharpe e DSR del filtrato.
**Attivazione** (§8.3 della specifica), tutte insieme:
1. almeno **300** basket chiusi;
2. AUC walk-forward ≥ **0,55** con l'intervallo bootstrap che esclude 0,50;
3. P&L filtrato migliore del P&L non filtrato **e** DSR del filtrato ≥ **0,95**.
Quando è attivo, un ingresso con p < `mlMinProbability` (0,55) viene rifiutato (`ml_gate` nel ledger). **Disattivazione**: se l'AUC mobile sugli ultimi 100 basket scende sotto **0,52** il modello torna in ombra e lo scrive in `models_registry.csv`.
Stato del 2026-09-16: **0 basket chiusi nel ledger** → il modello è in ombra e non è valutabile. Nessuna cifra qui è un risultato.
## Livello 2 — MLP challenger
`SmallMlp`: 28 → 16 ReLU → 1 sigmoide, inizializzazione Glorot con seme fisso, Adam (β 0,9/0,999), L2 = 10⁻⁴, mini-batch 8-64. Addestrato dal ciclo settimanale in **5 fold cronologici con purga ed embargo di 24 ore** attorno al fold di test, **5 semi** mediati, early stopping sull'ultimo 20 % (cronologico) dei dati di addestramento con pazienza 20 epoche. Lo standardizzatore viene adattato all'intero insieme di addestramento prima del fit (le statistiche rolling partono da zero e distorcono le prime righe: scoperto e corretto con il test sul cerchio, vedi `ModelTests`).
Il **gradient check** (`SmallMlp.GradientCheck`, test `TheMlpGradientMatchesTheNumericalOne`) confronta il gradiente analitico di ogni peso vivo del primo strato con la differenza centrale numerica: scarto relativo < 10⁻⁴.
Promozione a campione: solo se batte la logistica di almeno 0,01 di AUC walk-forward **e** supera gli stessi cancelli di attivazione, e comunque solo dopo il forward test. Fino ad allora è registrato come `challenger` in `models_registry.csv`.
## Livello 3 — Bandit sui preset
`ThompsonBandit`: una Beta(α, β) per braccio = preset × terzile di volatilità prevista (3 × 3). A ogni chiusura il braccio usato riceve 1 se il basket è positivo. La proposta campiona dalle posteriori con un **tetto del 10 %** alle scelte esplorative (`ExplorationCap`; test `TheBanditKeepsExplorationUnderTheCap`). 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
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 e comando `learn`
`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);
3. scrive `knowledge/calibration.csv`, `knowledge/insights_YYYYWW.md` (cosa ha funzionato, calibrazione, meta-modello, bandit, parametri suggeriti), `knowledge/models_registry.csv`, `knowledge/proposals.csv` (una riga per proposta, con evidenza e stato `proposta`);
4. salva i modelli con versione.
**Le proposte non toccano niente.** Il percorso per cambiare un parametro live è: proposta → `knowledge/preregistrazione.csv` (metrica, soglia, periodo, N minimo, scritti prima) → forward test in Paper/Demo → `forward_registry.csv` → decisione umana.
## Cosa è stato escluso, e perché
- **LSTM / Transformer / RL profondo**: con qualche centinaio di basket l'anno per cinque coppie, un modello con migliaia di parametri impara il rumore del campione; la regressione logistica e un MLP minuscolo sono già al limite di ciò che il dataset può sostenere. Il costo (settimane di lavoro e di calcolo) non è giustificato da nessun indizio che un modello più ricco troverebbe struttura dove i test di falsificazione non ne trovano.
- **Feature ricostruite a posteriori**: il ledger scrive ciò che il bot sapeva; ricostruire feature dopo è il modo più facile di introdurre look-ahead.
- **Ottimizzazione automatica dei parametri**: la griglia del backtest serve a *sapere*, non a *scegliere*; ogni prova conta nel DSR.
## File
| File | Contenuto |
|---|---|
| `data/models/logreg_current.json`, `mlp_current.json`, `bandit.json` | stato corrente (ripreso all'avvio) |
| `data/models/logreg_vN.json`, `mlp_vN.json` | versioni del ciclo settimanale con `trained_on_until`, righe, hash del dataset, nomi delle feature |
| `data/models/learning_state.json` | attivo/ombra, campione, versione, ultimi 200 (p, esito) per l'AUC mobile, feature dei basket aperti |
| `knowledge/calibration.csv`, `insights_YYYYWW.md`, `models_registry.csv`, `proposals.csv`, `forward_registry.csv`, `preregistrazione.csv` | vedi `docs/LEDGER_SCHEMA.md` |
Test: `tests/Encelado.Tests/LearningTests.cs` (i: gradient check, apprendimento walk-forward, MLP contro logistica su una regola non lineare, bandit, volatilità; j: leak; l: blocchi).
+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.**
+62
View File
@@ -0,0 +1,62 @@
# Domande e risposte
Ogni domanda è numerata per fase. Quando l'utente non ha risposto, è stato applicato il default più prudente e la scelta è segnata come **default applicato**: resta aperta finché non arriva una risposta.
## Fase 0 — 2026-09-16
| # | Domanda | Default proposto | Stato / risposta |
|---|---|---|---|
| D-01 | Il bot è già in C#? Quale target framework? | quello del repo | **Risposto dal repo**: C#, `net10.0` (Bot e test `net10.0-windows`), SDK 10.0.301. Nessuna proposta di cambio. |
| D-02 | UI attuale: console, WinForms o WPF? Posso aggiungere un progetto WPF? | nuovo progetto WPF + headless | **Risposto dal repo**: è già WPF (`Encelado.Bot`, tema scuro proprio). Non si aggiunge un progetto: si aggiungono pagine alla shell esistente e la modalità `--headless` nello stesso eseguibile. |
| D-03 | Valuta del conto eToro e disponibilità di chiavi demo? | USD, demo | **Verificato via API** (collegamento MCP dell'utente, sola lettura): conto in **USD**; `demoCid` e `realCid` esistono. Le chiavi long-lived (`x-api-key` + `x-user-key`) non sono ancora state fornite al bot: la finestra di accesso le chiede e le salva cifrate (DPAPI). **Default applicato: USD, demo.** |
| D-04 | Regola di approvazione: automatismo consentito già in demo? | `DemoApprove` | **Default applicato: `DemoApprove`**. `DemoAuto` richiede `etoro.allowDemoAuto = true` in `encelado.json` e la conferma all'avvio (finestra, o `--confirm-demo-auto` in headless). Le modalità Live richiedono `etoro.allowLive = true` e la frase `CONFERMO LIVE`. L'utente ha chiesto una lunga sessione di test "sperando di piazzare trade": senza risposta il test lungo gira in `Paper` (simulatore locale sopra le quote reali) o in `DemoApprove` con approvazione manuale. |
| D-05 | Gli 8 strumenti sono disponibili sul conto? Spread tipici? | verifica via API | **Verificato via API il 2026-09-16 07:23 UTC**: tutti e 8 disponibili (id: EURUSD 1, USDCHF 6, AUDUSD 7, USDCAD 4, NZDUSD 3, EURNZD 49, EURAUD 12, AUDCAD 47; anche EURCHF 9 ed EURCAD 13 per `PreferDirectCross`). Spread di mercato osservati senza markup: 0,1 pip sulle majors, 0,3-0,7 pip sui cross. Il markup di eToro si legge dall'endpoint dei costi e viene sommato nel cost gate. Esposizione minima 1000 USD per posizione, leva fino a 30 (majors) / 20 (minors). **Attenzione**: il conto reale vale 193,18 USD; con `RiskPerBasket` 0,5 % e esposizione minima 1000 USD il reale non è operabile senza leva alta: il passaggio a `LiveApprove` resta comunque subordinato ai cancelli di §9.4. |
| D-06 | Dove gira il bot (PC locale Windows, VPS Windows)? | PC locale + headless pronto per VPS | **Default applicato**: Windows 11 locale (questa macchina); `--headless` disponibile per un VPS Windows. |
| D-07 | Esiste già uno storage/log da riusare? | nuovi file in `data/` | **Default applicato con una precisazione**: il log applicativo (`Log`, file `;`) e `CsvTable` vengono riusati; il ledger, i dati di mercato, i modelli e la base di conoscenza vanno in file (`data/`, `knowledge/`, `reports/`, `results/`) sotto `Documenti\Encelado\`, come richiesto. Il database SQLite esistente resta per il motore `proba` e non viene usato dal modulo basket. |
| D-08 | Se una fonte news/calendario risulta irraggiungibile: sostituire o omettere? | omettere e annotare | **Verificato il 2026-09-16**: calendario FairEconomy (JSON e XML), FXStreet, ForexLive, Fed, ECB (`https://www.ecb.europa.eu/rss/press.html`), BoE (`https://www.bankofengland.co.uk/rss/news`), RBA (`https://www.rba.gov.au/rss/rss-cb-media-releases.xml`), BoC (`https://www.bankofcanada.ca/content_type/press-releases/feed/`) e Google News rispondono 200. **SNB** (`/en/rss/press-releases` → 404) e **RBNZ** (403 "website unavailable") no: **default applicato: omesse**, coperte da Google News con query mirate (`SNB`, `RBNZ`). Annotato in `docs/DATA_SOURCES.md`. |
| D-09 | Che fare del motore cTrader/ProbaBot trovato a metà e non committato? | mantenerlo selezionabile | **Default applicato**: resta nel repo, rimesso in compilazione (riferimento di progetto e piccoli fix) e selezionabile con `engine.strategy = "proba"`; il predefinito diventa `"baskets"`. Nessun comportamento esistente viene cambiato. Se l'utente preferisce eliminarlo, basta rimuovere `src/Encelado.CTrader` e `Engine/ProbaEngine.cs`. |
| D-10 | Dove stanno i parametri della strategia: in `encelado.json` o in un file separato? | `config/strategy.json` come da specifica | **Default applicato**: `strategy.json` separato (copia di fabbrica in `config/`, copia dell'utente in `Documenti\Encelado\`), letto con `JsonDocument`; `instruments.json` scritto dal bot all'avvio nella stessa cartella. `encelado.json` riceve solo le sezioni `etoro` e `engine.strategy`. |
| D-11 | Fuso orario dei tick MT5 in `A:\Download\Trading`? | verificare sul fine settimana | **Verificato**: la chiusura del venerdì cade alle 20:53-20:57 in estate e alle 21:53-21:57 in inverno, la riapertura alle 21:05 (estate) / 22:05 (inverno) della domenica: è **UTC**. Nessuna conversione. Formato: tab-separato `<DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>`; le righe con solo bid o solo ask (flag 2/4) aggiornano un solo lato. |
| D-12 | Lo storico M15 via API eToro si può scaricare paginando? | sì, 1000 barre per richiesta | **Verificato: no.** L'endpoint delle candele accetta solo `count ≤ 1000` e la direzione, senza data di partenza: fornisce al massimo ~10 giorni di M15. Il backtest usa i tick forniti dall'utente; l'API serve per riscaldamento (ultime 1000 barre) e riconciliazione. |
| D-13 | Il TP di basket "in pip" con lotti diversi fra le gambe: pip lordi sommati come Titany, o P&L netto? | come da specifica | **Default applicato**: `Pips` di basket = somma dei pip delle due gambe (UI e `ExitMode = FixedPips`); ogni decisione di stop usa il P&L netto in USD; entrambi finiscono nel ledger. |
| D-14 | Le credenziali eToro per il bot: quando? | attendere | L'utente ha scritto: «Aspetta l'input per le credenziali per la prima volta e poi potrai aprirlo in autonomia quando memorizzerò la password». La finestra di accesso chiede `x-api-key` e `x-user-key` e li salva in `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI). Finché non ci sono, il bot in headless resta in sola lettura e lo dice. |
## Fase 1 — 2026-09-16
| # | Domanda | Default proposto | Stato / risposta |
|---|---|---|---|
| D-15 | Leva da usare su ogni gamba (l'API la richiede per ordine)? | 10 | **Default applicato**: `orderLeverage = 10` (ammessa su tutte le 8 coppie); l'esposizione complessiva resta comunque ≤ 10:1 sul nozionale (`MaxEffectiveLeverage`) e lo stop nativo di eToro viene messo alla distanza coerente con `MaxLossPerBasket%`, dentro i limiti di eligibility. |
| D-16 | Overnight: usare il valore dell'endpoint dei costi o una tabella? | endpoint | **Default applicato**: l'endpoint dei costi (`overnightFee`, `overWeekendFee`) quando disponibile; in backtest una tabella configurabile per coppia (`overnightPipsPerDay`, default 0,3 pip/gamba/giorno, ×3 nel fine settimana). |
## Fasi 2-7 — 2026-09-16
| # | Domanda | Default proposto | Stato / risposta |
|---|---|---|---|
| D-17 | Lo spread anomalo (> 3 × mediana) deve chiudere il basket alla prima barra o dopo una persistenza? | persistenza | **Default applicato**: chiusura forzata solo dopo **3 barre chiuse consecutive** sopra la soglia. Nel backtest la chiusura immediata scattava sui picchi di spread e perdeva sistematicamente (`BasketPosition.BarsWithSpreadAnomaly`). |
| D-18 | Nel backtest l'equity stop blocca tutto per sempre o riparte? | riparte | **Default applicato**: dopo lo stop il picco riparte dall'equity corrente e il numero di stop viene contato (`EquityStops` nel riepilogo); altrimenti il primo stop del 2019 avrebbe fermato sette anni di prova. Dal vivo lo stop richiede il reset manuale. |
| D-19 | Fed risponde 404 e RBA "Access Denied" con lo User-Agent minimale: cambiare UA? | UA esplicito del bot | **Applicato**: `Encelado/4.0 (+correlation baskets; contact: operator)`. La Fed risponde; RBA (Akamai) a intermittenza. Dopo due errori consecutivi il feed logga solo a debug e ritenta con attese crescenti. |
| D-20 | Per il test lungo in demo: `DemoAuto` (bot autonomo) o `DemoApprove`? | DemoAuto | **Risposta dell'utente (2026-09-16 15:00)**: «tutti gli Approve devono sparire, almeno per il momento. Il bot deve girare in completa autonomia aprendo e chiudendo le posizioni senza il mio consenso». Modalità ridotte a `Paper`, `Demo`, `Live`; coda delle approvazioni rimossa (ADR-0005). Il Live conserva flag e frase `CONFERMO LIVE`. |
| D-21 | La pulizia delle «vecchie gestioni» deve includere anche cTrader/proba e la pipeline di ricerca? | sì, tutto | **Risposta dell'utente**: «Tutto: resta solo eToro + basket». Rimossi `Encelado.CTrader`, `Encelado.Storage`, ricerca, indicatori, RL, TA-Lib e i test relativi (ADR-0004). |
| D-22 | Versione del rilascio su Gitea? | 4.0.0 | **Risposta dell'utente**: 4.0.0 (nuovo broker, nuova strategia, configurazione incompatibile). |
| D-23 | Fuso orario della finestra: quello del computer o selezionabile? | computer, selezionabile | **Applicato**: `ui.timeZone` = `computer` di fabbrica; elenco dei fusi di Windows in Impostazioni; `ENCELADO_TIME_ZONE` da ambiente. Solo la finestra cambia: il log porta l'offset, il ledger è UTC. |
| D-24 | L'endpoint dei costi restituiva markup e overnight a zero: era davvero zero? | verificare | **Verificato via API il 2026-09-16 12:45 UTC**: il campo si chiama `value`, non `amount`. EURUSD 10 000 unità leva 10: markup 0,0, spread di mercato 0,1 USD (0,1 pip), overnight 0,91 USD/giorno (≈ 0,9 pip/gamba/giorno). Parser corretto; aggiunto lo scenario di costi `api` al backtest. |
| D-25 | Google News vieta `/rss/search` nel robots.txt: forzare, sostituire o omettere? | omettere | **Default applicato: omettere** (il bot rispetta il robots.txt). SNB e RBNZ restano senza fonte; documentato in `KNOWN_ISSUES.md`. |
## 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. |
+49
View File
@@ -0,0 +1,49 @@
# Regole di sicurezza e approvazione
Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e chi può cambiarlo. "Operatore" è chi modifica i file in `Documenti\Encelado` o usa la finestra; "codice" vuol dire che non esiste una chiave di configurazione.
| Regola | Default | Dove | Chi la cambia |
|---|---|---|---|
| Modalità di esecuzione | `Demo` | `encelado.json``run.executionMode` (`Paper`, `Demo`, `Live`) | operatore; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` scritta all'avvio (o `--confirm-live "CONFERMO LIVE"` in headless) |
| Approvazione dei singoli ordini | nessuna, in nessuna modalità (D-20, ADR-0005) | codice | nessuno. Il bot apre, aggiunge e chiude da solo; i gate umani sono l'avvio del reale, il kill-switch, il reset dopo un equity stop e il cambio di preset |
| Equity stop | 9 % dal picco di equity | `strategy.json``equityStopPct` | operatore; scatta → chiude tutto, blocca, richiede reset con motivazione scritta (finestra o `reset <motivo>` in headless), che finisce nel ledger; il picco riparte dall'equity del reset |
| Perdita giornaliera massima | 3 % dell'equity di inizio giornata (UTC) | `strategy.json``dailyLossPct` | operatore; blocca le nuove entrate fino al giorno dopo, non chiude |
| Rischio per basket | 0,25 / 0,50 / 1,00 % (preset) | `strategy.json` → preset o `riskPerBasketPct` | operatore; il cambio di preset a caldo non tocca i basket aperti |
| Perdita massima per basket | 1,5 % dell'equity all'ingresso | `strategy.json``maxLossPerBasketPct` | operatore; mai disattivabile |
| Stop di basket su z | 3,0 / 3,5 / 4,0 (preset) | `strategy.json` → preset o `zStop` | operatore; mai disattivabile (solo il backtest lo spegne, nel test di falsificazione 3) |
| Basket aperti | 2 / 3 / 5 (preset) | `strategy.json` → preset o `maxBaskets` | operatore |
| Un solo basket per cross sintetico | `Exclusive` | `strategy.json``sameCrossPolicy` | operatore (`Half` dimezza la size di entrambi) |
| Leva effettiva massima | 10:1 sul nozionale complessivo | `strategy.json``maxEffectiveLeverage` | operatore, tetto 30 |
| 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 |
| Spread anomalo | > 3 × mediana per 3 barre chiuse consecutive → chiusura forzata | `strategy.json``spreadAnomalyMultiple` (persistenza: codice) | operatore (moltiplicatore) |
| Blackout eventi | 45 min prima, 30 dopo, eventi High sulle valute del basket | `strategy.json``blackoutBeforeMin`, `blackoutAfterMin` | operatore |
| Fine settimana | niente entrate dal venerdì 20:00 UTC alla riapertura, né nei primi 30 min | `strategy.json``fridayCutoffUtcHour`, `openDelayMinutes` | operatore |
| Scarto orologio | > 5 s → banner e niente nuove entrate | `strategy.json``clockSkewMaxSeconds` | operatore; misurato sull'header `Date` di ogni risposta |
| API in errore | 5 letture consecutive fallite → niente nuove entrate finché non risponde | codice | nessuno |
| Quotazione vecchia | > 15 s → niente nuove entrate | codice (`BasketEngine.MaxQuoteAgeSeconds`) | nessuno |
| Qualità dati | buco > 2 h feriale o salto > 8 σ → decisioni sospese su quella barra | codice | nessuno |
| Leg-risk | seconda gamba **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, `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. 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 |
+153
View File
@@ -0,0 +1,153 @@
# Runbook
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 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**.
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à
| Modalità | Ordini | Conferma all'avvio |
|---|---|---|
| `Paper` | simulatore locale sopra le quotazioni reali (`data/state/paper_state.json`) | nessuna |
| `Demo` (default) | conto demo eToro, denaro virtuale | nessuna |
| `Live` | conto reale | `run.allowLive = true` **e** la frase `CONFERMO LIVE` (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 chip in basso a sinistra del rail dice sempre in che ambiente sei (`PAPER` blu, `DEMO` giallo, `LIVE` rosso).
## Console e comandi
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`.
**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** 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
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**:
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.
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**.
## 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 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 ▸ 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 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 (container) |
|---|---|
| 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)
Tutte vere, altrimenti no:
- [ ] il forward test in Demo ha almeno 60 basket chiusi e 90 giorni;
- [ ] il P&L netto del forward test è positivo con PSR ≥ 0,95 sulla metrica pre-registrata in `knowledge/preregistrazione.csv`;
- [ ] nessun test di falsificazione contraddice il risultato (oggi `reports/falsificazione.csv` dice il contrario: vedi `docs/STRATEGY.md`);
- [ ] `run.allowLive = true`, ambiente `real`, chiavi del reale inserite e verificate;
- [ ] conto reale capiente rispetto a `riskPerBasketPct` e all'esposizione minima di 1000 USD per gamba (con 224,90 USD non lo è);
- [ ] la frase `CONFERMO LIVE` scritta all'avvio (dialogo o `ENCELADO_CONFIRM_LIVE`).
## Aggiornare
`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.
+32
View File
@@ -0,0 +1,32 @@
# Stato del lavoro
Aggiornato: 2026-09-23 (sessione 5.0, Fasi 6-9 concluse: il piano 5.0 è completo).
## Fase in corso
**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-23, Fasi 6-9)
- **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. **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
- 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.
+130
View File
@@ -0,0 +1,130 @@
# Strategia: Correlation Baskets
Aggiornato: 2026-09-16. Questo documento dice come funziona la strategia e, con i numeri, **se regge**. La risposta sui dati disponibili è **no**: nessuna configurazione è profittevole al netto dei costi di eToro. Il modulo resta uno strumento di forward test in Demo; non c'è nessun risultato che giustifichi il reale.
## 1. Logica
Cinque basket di due coppie forex con una valuta in comune:
| Basket | Comune | Cross sintetico | Gambe |
|---|---|---|---|
| EURUSD / USDCHF | USD | EURCHF | stesso verso |
| AUDUSD / USDCAD | USD | AUDCAD | stesso verso |
| NZDUSD / EURNZD | NZD | EURUSD | stesso verso |
| USDCAD / EURUSD | USD | EURCAD | stesso verso |
| EURAUD / AUDCAD | AUD | EURCAD | stesso verso |
In tutti e cinque la valuta comune ha ruoli opposti nelle due coppie, quindi `X = ln A + ln B` è il logaritmo del cross e le due gambe si comprano (o si vendono) insieme; la correlazione attesa dei rendimenti è negativa.
**Segnale** (`ZScoreSynthetic`): `z = (X media_W) / σ_W` su W = 100 barre M15. Ingresso quando `|z| ≥ z_in` (2,0 nel preset Moderate), venduto il cross se z > 0, comprato se z < 0. Modalità alternativa `PipDivergence`: divergenza in pip fra le due gambe dall'ultimo punto di allineamento.
**Cancelli all'ingresso** (§5.3): correlazione rolling `ρ_W ≤ 0,6`, semiperiodo fra 5 e 120 barre, forza di trend sotto soglia, blackout del calendario, fine settimana, cost gate (`TP ≥ 3 × costo`, spread ≤ 2 × mediana 24 h), massimo di basket aperti, un solo basket per cross sintetico, quote fresche, orologio allineato, nessun blocco attivo.
**Sizing** (vol-parity): unità inversamente proporzionali all'ATR di ogni gamba, rischio totale alla distanza dello stop = `riskPerBasketPct` dell'equity (0,5 % Moderate), esposizione minima di eToro 1000 USD per gamba, leva effettiva ≤ 10. **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.
**Averaging** (§5.5): spento di fabbrica; `AddOnce` e `Grid` esistono solo per il test di falsificazione 2.
I preset (`strategy.json`):
| Preset | z_in | rischio/basket | basket max | TP pip | aggiunte | z_stop |
|---|---|---|---|---|---|---|
| Conservative | 2,5 | 0,25 % | 2 | 8 | 0 | 3,0 |
| Moderate | 2,0 | 0,50 % | 3 | 10 | 1 | 3,5 |
| Aggressive | 1,5 | 1,00 % | 5 | 12 | 2 | 4,0 |
## 2. Dati e costi del backtest
- Tick MetaTrader 5 (UTC) dal 2018-12-12 al 2026-09-15, convertiti in barre M15 bid/ask (`backtest ticks`); ~192 000 barre per coppia, EURAUD solo 39 700 (parti del 2018, 2021, 2026).
- Decisione alla chiusura della barra, esecuzione all'apertura della successiva sul lato giusto del book più 0,3 pip di slippage per gamba.
- Due scenari di costo, entrambi assunzioni:
- **etoro**: spread minimo per coppia = spread tipico pubblicato da eToro (EURUSD 1,0, USDCHF 1,5, AUDUSD 1,0, USDCAD 1,5, NZDUSD 2,5, EURNZD 5,0, EURAUD 3,0, AUDCAD 3,0 pip), overnight 0,3 pip/gamba/giorno;
- **api**: spread dei tick senza pavimento (0,1-0,7 pip, come mostra l'API demo), overnight **0,9 pip/gamba/giorno** (0,91 USD/giorno per 10 000 EURUSD letti dall'endpoint dei costi il 2026-09-16).
- Capitale iniziale 10 000 USD; equity stop al 9 % con ripartenza del picco (D-18), contando gli stop.
- Niente calendario né notizie nel backtest: blackout e sentiment agiscono solo dal vivo.
## 3. Risultati
### 3.1 Baseline (strategy.json di fabbrica), costi etoro
| Preset | Basket | Win rate | Netto | Sharpe | Max DD | Costo medio | Break-even | Equity stop |
|---|---|---|---|---|---|---|---|---|
| Conservative | 0 | — | 0 | — | — | — | — | 0 |
| Moderate | 2 224 | 50 % | **9 608 USD** | 3,38 | 96 % | 3,1 pip | 0,4 pip | 37 |
| Aggressive | 2 219 | 53 % | **9 802 USD** | 3,61 | 98 % | 3,4 pip | 0,2 pip | — |
Il Conservative non apre mai: con TP 8 pip il cost gate a 3× non passa mai (3 × 3,1 > 8). Il Moderate perde quasi tutto il capitale in 7,75 anni: 2 224 basket × ~4 USD di costo = il conto. Il break-even (il costo per basket che azzererebbe il P&L medio) è **0,4 pip**: il segnale non produce nemmeno un pip lordo per basket.
### 3.2 Griglia (§9.1): 57 configurazioni, costi etoro
3 preset × W ∈ {60, 100, 150} × ρ_min ∈ {0,5, 0,6, 0,7} × z_out ∈ {0,25, 0,5}, più le tre baseline. Risultato in `results/trials.csv` e `results/riepilogo_baskets.csv`:
- **nessuna configurazione con P&L netto positivo**;
- la "migliore" per Sharpe è quella che non apre nulla (Sharpe 0);
- PBO (CSCV, 16 blocchi) = 0,000 solo perché la selezione in-sample sceglie sempre la configurazione vuota: un numero degenere, non una prova di robustezza;
- walk-forward "scegli il migliore degli ultimi 6 mesi, applicalo un mese" su 88 mesi: Sharpe 0,75, drawdown 13 %, PSR 0,005;
- DSR di ogni prova: 0.
Motivi di non ingresso, in ordine: `no_signal`, `cost_gate`, `rho_low`, `half_life`. Il cancello ρ0,6 è raro sulle barre M15: dal vivo il 2026-09-16 ρ_W è rimasta fra 0,13 e 0,42 per tutta la sessione.
### 3.3 Falsificazione (§9.2), entrambi gli scenari
`reports/falsificazione.csv` (etoro) e `reports/falsificazione_costi_api.csv` (api), preset Moderate:
| Test | Variante | Basket (etoro / api) | Win rate | Netto etoro | Netto api | Break-even etoro / api |
|---|---|---|---|---|---|---|
| 1 segnale | ZScoreSynthetic | 2 224 / 2 076 | 50 % / 48 % | 9 608 | 9 802 | 0,4 / 0,5 pip |
| 1 segnale | PipDivergence | 3 124 / 2 877 | 42 % / 43 % | 9 406 | 9 784 | 0,6 / 0,4 pip |
| 2 averaging | AddOnce ×1,0 | 2 123 / 1 821 | 51 % / 48 % | 9 689 | 9 800 | +0,3 / +0,4 pip |
| 2 averaging | AddOnce ×1,5 | 1 980 / 1 673 | 51 % / 47 % | 9 741 | 9 802 | +0,2 / +0,3 pip |
| 3 stop | senza stop | 1 844 / 1 949 | 55 % / 54 % | 9 228 | 9 796 | 0,2 / 0,4 pip |
| 4 cost gate | 2× | 2 135 / 2 052 | 48 % / 48 % | 9 800 | 9 800 | 0,7 / 0,6 pip |
| 4 cost gate | 4× | 0 / 3 | — / 33 % | 0 | 81 | — / +3,8 pip |
| 5 inverso | segnale invertito | 1 824 / 1 537 | 43 % / 39 % | 9 735 | 9 801 | **2,5 / 2,9 pip** |
Letture:
- **Il segnale ha un contenuto, ma piccolo.** Invertirlo peggiora il break-even di circa 2-2,5 pip per basket (da 0,4 a 2,5). Quindi il verso del segnale vale ~2 pip; il costo medio di un basket è 3,1-3,2 pip. Non basta, in nessuno dei due scenari.
- **L'averaging alza il break-even di ~0,7 pip** (compra i rientri) ma allunga la coda: il percentile 1 % delle perdite passa da 98 a 115 USD e il drawdown sale. Non cambia il segno del risultato.
- **Senza stop** il win rate sale al 55 % e il netto migliora di 380 USD nello scenario etoro, ma la coda (1 %: 136 USD) e il drawdown restano quelli di un sistema che tiene le perdite aperte. Lo stop resta obbligatorio.
- **Il cost gate non salva la strategia**: a 4× non apre quasi nulla, a 2× apre di più e perde di più. Il costo è il problema, ma non è l'unico: anche con lo spread a 0,1 pip (scenario api) l'overnight riporta il costo a 3,2 pip.
- **PipDivergence** apre di più e perde di più (win rate 42 %).
### 3.4 Griglia con costi api
`results/trials_costi_api.csv` e `results/riepilogo_baskets_costi_api.csv` (57 prove):
- baseline Moderate: 2 076 basket, win rate 48 %, netto **9 802 USD**, Sharpe 3,88, costo medio 3,2 pip;
- **nessuna prova con P&L netto positivo**; la migliore per Sharpe (T013: Conservative, W 150, ρ_min 0,5) apre 8 basket in 7,75 anni e perde 45 USD (Sharpe 0,10, DSR 0);
- PBO 0,001, di nuovo degenere (la selezione in-sample sceglie configurazioni quasi vuote);
- walk-forward 6 m / 1 m: Sharpe 0,91, drawdown 13,5 %, PSR 0,000.
Lo scenario api sposta il costo dallo spread all'overnight senza cambiarne l'ordine di grandezza, perché un basket resta aperto in media più di un giorno (0,9 pip/gamba/giorno × 2 gambe × ~1,5 giorni ≈ 2,7 pip).
## 4. Verdetto
**Negativo.** Sui 7,75 anni disponibili la strategia perde in ogni configurazione provata, con entrambi i modelli di costo, e i test di falsificazione non trovano una variante che inverta il segno. Il segnale contiene circa 2 pip di informazione per basket contro 3 pip di costo.
Cosa ne segue:
1. Il bot **non va sul reale**. `run.allowLive` resta `false`; i cancelli di §9.4 non sono raggiungibili con questi numeri.
2. Il Demo serve a **misurare** (spread reale in esecuzione, slippage, overnight effettivo, quanto spesso i cancelli si aprono), non a guadagnare. Ogni basket chiuso finisce nel ledger e nel dataset del meta-modello.
3. Se qualcuno vuole cambiare un parametro, lo fa attraverso `knowledge/proposals.csv` e un forward test pre-registrato (`knowledge/preregistrazione.csv`), non ritoccando `strategy.json` dopo aver guardato i risultati: ogni prova in più abbassa il DSR di tutte le altre.
## 5. Cosa non è stato misurato
- L'effetto del blackout del calendario e del sentiment (assenti nel backtest).
- Lo spread effettivo di esecuzione su eToro: l'API demo mostra 0,1 pip di mercato e markup 0; il ledger del Demo dirà se le esecuzioni lo confermano (slippage per gamba scritto a ogni ingresso).
- EURAUD/AUDCAD su tutto il periodo (dati parziali).
- Timeframe diversi da M15 e finestre oltre 150 barre.
## 6. Come rieseguire
```powershell
backtest ticks --data "A:\Download\Trading" --out "%USERPROFILE%\Documents\Encelado\data\market"
backtest baskets --data "%USERPROFILE%\Documents\Encelado\data\market" --out results [--costs api]
backtest falsify --data "%USERPROFILE%\Documents\Encelado\data\market" --out reports [--costs api]
```
Ogni esecuzione riscrive le tabelle; i numeri di questo documento vengono da quelle del 2026-09-16.
+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,31 @@
# ADR-0001 — Broker: eToro
Data: 2026-09-16. Stato: accettata.
## Contesto
La strategia opera otto coppie forex (EURUSD, USDCHF, AUDUSD, USDCAD, NZDUSD, EURNZD, EURAUD, AUDCAD) come CFD. Il bot aveva un adattatore Binance (ritirato: dal 1° luglio 2026 l'utente non può operare in USDT) e un adattatore cTrader mai collegato. Alpaca è esclusa perché non offre forex.
## Decisione
Il modulo basket opera su **eToro Public API** (`https://public-api.etoro.com`), autenticazione con `x-api-key` + `x-user-key` (chiavi diverse per demo e reale, ambiente sempre visibile in UI), `x-request-id` obbligatorio (usato anche come `referenceId` idempotente degli ordini).
Fatti verificati il 2026-09-16 sulla specifica OpenAPI servita dall'API (v1.379.0):
- Quote: `GET /api/v2/market-data/rates?instrumentIds=…` (batch, quota condivisa 120/min).
- Candele: `GET /api/v1/market-data/instruments/{id}/history/candles/{asc|desc}/{FifteenMinutes}/{≤1000}` — senza data di partenza: **non pagina lo storico**.
- Strumenti: `GET /api/v2/market-data/instruments?symbols=…`; eligibility `POST /api/v2/trading/info/{demo/}eligibility`; costi what-if `POST /api/v2/trading/info/{demo/}costs`.
- Conto e posizioni: `GET /api/v1/trading/info/{demo/}pnl` (posizioni con P&L non realizzato, `credit`); saldi `GET /api/v1/balances`.
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (20/min), esito con `GET /api/v2/trading/info/{demo/}orders:lookup?referenceId=…`; chiusura `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}` con esito in `GET /api/v1/trading/info/{demo|real}/close-orders/{orderId}`; SL/TP `PATCH /api/v2/trading/{demo/}positions/{id}`.
- Storico chiusure: `GET /api/v1/trading/info/trade/{demo/}history?minDate=…`.
## Alternative
- **IC Markets / cTrader**: spread ECN più stretti e Open API con streaming, ma l'utente ha chiesto eToro e usa già l'API; l'adattatore resta nel repo per il motore `proba`.
- **Alpaca**: niente forex.
## Conseguenze
- Nessun order book, spread con markup, esecuzione solo a mercato (o MIT) con SL/TP nativi: il cost gate deve leggere spread e markup reali a ogni decisione.
- Lo storico per il backtest viene dai tick MT5 dell'utente, non dall'API.
- Ogni ordine nasce con uno stop nativo (richiesto per `sellShort` e per leva > 1); il bot gestisce comunque lo stop di basket.
@@ -0,0 +1,31 @@
# ADR-0002 — Storage su file per il modulo basket
Data: 2026-09-16. Stato: accettata.
## Contesto
Il repository ha già un database SQLite (`Encelado.Storage`, unica dipendenza NuGet a runtime) usato dal motore `proba` per barre, dataset, modelli e journal. La specifica del modulo basket chiede storage su file: CSV `;` con `motivazione`, JSONL append-only per ledger e notizie, JSON per modelli e stato, scritture atomiche, rotazione mensile, nessuna riga del ledger modificata.
## Decisione
Il modulo basket **non usa SQLite**. Tutto vive in file sotto `Documenti\Encelado\`:
```
data/market/candles_<SYMBOL>_M15.csv barre M15 bid/ask (dallo strumento ticks e dal delta API)
data/calendar/events.jsonl eventi economici (dedup title+date+country)
data/news/news_YYYYMM.jsonl notizie RSS (dedup hash(link))
data/ledger/decisions.jsonl ogni valutazione di ogni basket (append-only, rotazione mensile in decisions_YYYYMM.jsonl)
data/ledger/baskets.csv una riga per basket chiuso (label, P&L, costi, slippage)
data/models/logreg_v<N>.json, mlp_v<N>.json, bandit.json, state.json
knowledge/calibration.csv, insights_YYYYWW.md, proposals.csv, forward_registry.csv, models_registry.csv, preregistrazione.csv
reports/*.csv, results/trials.csv
```
## Alternative
- Riusare SQLite: comodo per query, ma introduce un binario nativo nel percorso del modulo, contraddice la specifica e rende il ledger modificabile per errore.
## Conseguenze
- Le tabelle si aprono in un foglio di calcolo così come sono; il ledger è verificabile riga per riga.
- L'analisi (ricostruzione del dataset, calibrazione) rilegge i file: costa qualche secondo per centinaia di migliaia di righe, accettabile per un ciclo settimanale.
@@ -0,0 +1,15 @@
# ADR-0003 — Motore cTrader mantenuto selezionabile
Data: 2026-09-16 (mattina). Stato: **superata da ADR-0004** (stesso giorno, pomeriggio).
## Contesto
All'inizio del lavoro sui Correlation Baskets l'albero conteneva un motore probabilistico su cTrader (`proba`) non committato e non compilante. La regola «non toccare i comportamenti esistenti se non richiesto» suggeriva di rimetterlo in compilazione e lasciarlo selezionabile con `engine.strategy = "proba"`, con `"baskets"` come predefinito.
## Decisione (originaria)
Tenere entrambi i motori dietro `IEngine`, con la finestra che sceglie le pagine in base al motore configurato.
## Esito
Nel pomeriggio l'utente ha chiesto la rimozione di tutto ciò che riguarda le gestioni precedenti e, alla domanda esplicita, ha incluso cTrader e la ricerca (D-21). La decisione è registrata in ADR-0004; questo documento resta per la cronologia.
@@ -0,0 +1,19 @@
# ADR-0004 — Rimozione dei motori precedenti (Binance, cTrader/proba, ricerca)
Data: 2026-09-16. Stato: accettata. Sostituisce ADR-0003.
## Contesto
Il repository portava tre generazioni di codice: l'arbitraggio statistico su Binance Futures (con adattatore già rimosso), il motore probabilistico su cTrader con la sua pipeline di ricerca (SQLite, GBDT, RL, TA-Lib, backtest a coppie) e il modulo Correlation Baskets su eToro. ADR-0003 aveva tenuto il motore cTrader selezionabile per non toccare comportamenti esistenti. L'utente ha chiesto un rework completo che elimini «qualsiasi cosa legata a vecchie gestioni (binance, alpaca, ecc ecc)» e, alla domanda esplicita, ha scelto di rimuovere anche cTrader e la ricerca (D-21).
## Decisione
Restano solo `Encelado.Core` (basket, broker, notizie, statistica condivisa), `Encelado.Etoro`, `Encelado.Bot` e lo strumento `tools/Encelado.Backtest` con i tre comandi `ticks`, `baskets`, `falsify`. Sono stati eliminati i progetti `Encelado.CTrader` e `Encelado.Storage`, le cartelle `Core/Backtest`, `Indicators`, `Journal`, `Market`, `Portfolio`, `Research`, `Risk`, `Rl`, `Strategies`, quasi tutto `Ml` (restano `Classification` e `Pbo`) e `Statistics` (restano `Performance`, `Ols`, `Distributions`), il motore `ProbaEngine`, le pagine e i test relativi, il selettore `engine.strategy`, le sezioni di configurazione `ctrader`, `engine`, `strategy`, `risk`, `storage`, `symbols`. Nessun pacchetto NuGet resta nei progetti dell'applicazione.
Il codice rimosso è nella storia git (tag `v3.5.0` e commit `b39e08b`).
## Conseguenze
- Una sola strategia, una sola configurazione, una sola finestra: meno codice da capire e da testare (da 322 a 172 test, tutti sul modulo che gira).
- Le conclusioni delle ricerche precedenti (StatArb su BTC non valida fuori campione, ProbaBot) restano solo nei documenti e nella memoria di lavoro; non sono più riproducibili da questo albero.
- Un file di configurazione della versione precedente viene letto con avvisi mirati («sezione di una versione precedente») e il ripristino dei valori di fabbrica lo riscrive.
@@ -0,0 +1,19 @@
# ADR-0005 — Nessuna approvazione manuale dei singoli ordini
Data: 2026-09-16. Stato: accettata (decisione dell'utente, D-20).
## Contesto
La specifica prevedeva cinque modalità (`Paper`, `DemoApprove`, `DemoAuto`, `LiveApprove`, `LiveAuto`) con `DemoApprove` predefinita: ogni apertura, aggiunta e take-profit era una proposta che aspettava una persona per quindici minuti. Nella sessione di prova del 2026-09-16 il bot in `DemoApprove` non ha mai potuto operare senza qualcuno alla finestra, e il test lungo richiesto dall'utente («sperando di piazzare trade») non è possibile in quel modo. Alla domanda «posso usare DemoAuto per il test lungo?» l'utente ha risposto: «tutti gli Approve devono sparire, almeno per il momento. Il bot deve girare in completa autonomia aprendo e chiudendo le posizioni senza il mio consenso».
## Decisione
Le modalità diventano tre: `Paper`, `Demo` (predefinita) e `Live`. In tutte il bot esegue da solo le decisioni del decisore. La coda delle approvazioni (`ApprovalQueue`), i comandi `approve`/`reject`, il flag `run.allowDemoAuto` e la conferma all'avvio del demo automatico sono rimossi. I nomi precedenti (`DemoApprove`, `DemoAuto`, `LiveApprove`, `LiveAuto`) vengono ancora letti dal file di configurazione, mappati su `Demo`/`Live` con un avviso.
Restano i gate umani che non riguardano il singolo ordine: la frase `CONFERMO LIVE` all'avvio del reale (con `run.allowLive = true`), il kill-switch, il reset motivato dopo un equity stop, il cambio di preset a caldo.
## Conseguenze
- Le regole «mai un ordine reale senza flag e conferma» restano vere a livello di sessione: il reale non parte senza flag e frase.
- Le uscite protettive erano già automatiche in ogni modalità; ora lo sono anche le aperture e i take-profit.
- Se in futuro servisse una revisione umana, va reintrodotta come modalità esplicita e non come default: la specifica originaria resta documentata qui.
@@ -0,0 +1,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

+14
View File
@@ -0,0 +1,14 @@
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
1_segnale;ZScoreSynthetic;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
1_segnale;PipDivergence;3124;0.4238;-9406.4611;-3.7746;0.9412;-75.4011;-31.0045;-0.6061;3.1477;0;0;3124 basket, win rate 42 %, netto -9406 USD, Sharpe -3.77, DD 94.1 %, 1% -75 USD, 5% -31 USD: perde al netto dei costi assunti
2_averaging;Off x1.0;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
2_averaging;AddOnce x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
2_averaging;Grid x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
2_averaging;AddOnce x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
2_averaging;Grid x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
3_stop;con stop;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
3_stop;senza stop;1844;0.5542;-9228.1048;-2.3634;0.9251;-136.115;-67.6782;-0.1898;3.1101;0;0;1844 basket, win rate 55 %, netto -9228 USD, Sharpe -2.36, DD 92.5 %, 1% -136 USD, 5% -68 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
4_cost_gate;2x;2135;0.4843;-9800.3487;-3.7111;0.9803;-93.8542;-45.5499;-0.7129;3.5108;0;0;2135 basket, win rate 48 %, netto -9800 USD, Sharpe -3.71, DD 98.0 %, 1% -94 USD, 5% -46 USD: perde al netto dei costi assunti
4_cost_gate;3x;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
4_cost_gate;4x;0;;0;0;0;;;;;0.5;0;0 basket, win rate NaN, netto 0 USD, Sharpe 0.00, DD 0.0 %, 1% NaN USD, 5% NaN USD: perde al netto dei costi assunti
5_inverso;segnale invertito;1824;0.4331;-9735.0255;-4.0007;0.974;-82.2238;-45.0301;-2.5283;3.1747;0;0;1824 basket, win rate 43 %, netto -9735 USD, Sharpe -4.00, DD 97.4 %, 1% -82 USD, 5% -45 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
1 test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
2 1_segnale;ZScoreSynthetic;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
3 1_segnale;PipDivergence;3124;0.4238;-9406.4611;-3.7746;0.9412;-75.4011;-31.0045;-0.6061;3.1477;0;0;3124 basket, win rate 42 %, netto -9406 USD, Sharpe -3.77, DD 94.1 %, 1% -75 USD, 5% -31 USD: perde al netto dei costi assunti
4 2_averaging;Off x1.0;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
5 2_averaging;AddOnce x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
6 2_averaging;Grid x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
7 2_averaging;AddOnce x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
8 2_averaging;Grid x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
9 3_stop;con stop;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
10 3_stop;senza stop;1844;0.5542;-9228.1048;-2.3634;0.9251;-136.115;-67.6782;-0.1898;3.1101;0;0;1844 basket, win rate 55 %, netto -9228 USD, Sharpe -2.36, DD 92.5 %, 1% -136 USD, 5% -68 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
11 4_cost_gate;2x;2135;0.4843;-9800.3487;-3.7111;0.9803;-93.8542;-45.5499;-0.7129;3.5108;0;0;2135 basket, win rate 48 %, netto -9800 USD, Sharpe -3.71, DD 98.0 %, 1% -94 USD, 5% -46 USD: perde al netto dei costi assunti
12 4_cost_gate;3x;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
13 4_cost_gate;4x;0;;0;0;0;;;;;0.5;0;0 basket, win rate NaN, netto 0 USD, Sharpe 0.00, DD 0.0 %, 1% NaN USD, 5% NaN USD: perde al netto dei costi assunti
14 5_inverso;segnale invertito;1824;0.4331;-9735.0255;-4.0007;0.974;-82.2238;-45.0301;-2.5283;3.1747;0;0;1824 basket, win rate 43 %, netto -9735 USD, Sharpe -4.00, DD 97.4 %, 1% -82 USD, 5% -45 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
@@ -0,0 +1,14 @@
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
1_segnale;ZScoreSynthetic;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
1_segnale;PipDivergence;2877;0.4282;-9783.9713;-4.2544;0.9786;-74.479;-33.8654;-0.4263;3.1921;0;0;2877 basket, win rate 43 %, netto -9784 USD, Sharpe -4.25, DD 97.9 %, 1% -74 USD, 5% -34 USD: perde al netto dei costi assunti
2_averaging;Off x1.0;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
2_averaging;AddOnce x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
2_averaging;Grid x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
2_averaging;AddOnce x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
2_averaging;Grid x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
3_stop;con stop;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
3_stop;senza stop;1949;0.5387;-9795.9761;-3.054;0.9799;-107.4238;-51.7478;-0.3737;3.1928;0;0;1949 basket, win rate 54 %, netto -9796 USD, Sharpe -3.05, DD 98.0 %, 1% -107 USD, 5% -52 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
4_cost_gate;2x;2052;0.4771;-9800.0224;-3.7234;0.9802;-93.1449;-44.9303;-0.6448;3.4791;0;0;2052 basket, win rate 48 %, netto -9800 USD, Sharpe -3.72, DD 98.0 %, 1% -93 USD, 5% -45 USD: perde al netto dei costi assunti
4_cost_gate;3x;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
4_cost_gate;4x;3;0.3333;-81.1762;-0.3256;0.0081;-72.0723;-72.0723;3.8259;2.4592;0.0228;0;3 basket, win rate 33 %, netto -81 USD, Sharpe -0.33, DD 0.8 %, 1% -72 USD, 5% -72 USD: perde al netto dei costi assunti
5_inverso;segnale invertito;1537;0.3852;-9800.9519;-4.3499;0.9801;-83.2267;-48.4287;-2.8935;3.2095;0;0;1537 basket, win rate 39 %, netto -9801 USD, Sharpe -4.35, DD 98.0 %, 1% -83 USD, 5% -48 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
1 test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
2 1_segnale;ZScoreSynthetic;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
3 1_segnale;PipDivergence;2877;0.4282;-9783.9713;-4.2544;0.9786;-74.479;-33.8654;-0.4263;3.1921;0;0;2877 basket, win rate 43 %, netto -9784 USD, Sharpe -4.25, DD 97.9 %, 1% -74 USD, 5% -34 USD: perde al netto dei costi assunti
4 2_averaging;Off x1.0;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
5 2_averaging;AddOnce x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
6 2_averaging;Grid x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
7 2_averaging;AddOnce x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
8 2_averaging;Grid x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
9 3_stop;con stop;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
10 3_stop;senza stop;1949;0.5387;-9795.9761;-3.054;0.9799;-107.4238;-51.7478;-0.3737;3.1928;0;0;1949 basket, win rate 54 %, netto -9796 USD, Sharpe -3.05, DD 98.0 %, 1% -107 USD, 5% -52 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
11 4_cost_gate;2x;2052;0.4771;-9800.0224;-3.7234;0.9802;-93.1449;-44.9303;-0.6448;3.4791;0;0;2052 basket, win rate 48 %, netto -9800 USD, Sharpe -3.72, DD 98.0 %, 1% -93 USD, 5% -45 USD: perde al netto dei costi assunti
12 4_cost_gate;3x;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
13 4_cost_gate;4x;3;0.3333;-81.1762;-0.3256;0.0081;-72.0723;-72.0723;3.8259;2.4592;0.0228;0;3 basket, win rate 33 %, netto -81 USD, Sharpe -0.33, DD 0.8 %, 1% -72 USD, 5% -72 USD: perde al netto dei costi assunti
14 5_inverso;segnale invertito;1537;0.3852;-9800.9519;-4.3499;0.9801;-83.2267;-48.4287;-2.8935;3.2095;0;0;1537 basket, win rate 39 %, netto -9801 USD, Sharpe -4.35, DD 98.0 %, 1% -83 USD, 5% -48 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
voce;valore;motivazione
periodo;2018-12-12 → 2026-09-15;barre M15 dai tick MT5, spread minimo = tipico eToro, slippage 0.3 pip/gamba, overnight 0.3 pip/gamba/giorno
prove;57;ogni configurazione provata conta nel Sharpe deflazionato
baseline Moderate;Sharpe -3.38, netto -9608 USD, 2224 basket, win rate 50 %;la configurazione di fabbrica così com'è
migliore;BASE-CON: Sharpe 0.00, DSR 0.000;non distinguibile dalla selezione fra le prove
PBO;0.000;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
walk-forward;Sharpe -0.75, DD 13.2 %, PSR 0.005;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
1 voce;valore;motivazione
2 periodo;2018-12-12 → 2026-09-15;barre M15 dai tick MT5, spread minimo = tipico eToro, slippage 0.3 pip/gamba, overnight 0.3 pip/gamba/giorno
3 prove;57;ogni configurazione provata conta nel Sharpe deflazionato
4 baseline Moderate;Sharpe -3.38, netto -9608 USD, 2224 basket, win rate 50 %;la configurazione di fabbrica così com'è
5 migliore;BASE-CON: Sharpe 0.00, DSR 0.000;non distinguibile dalla selezione fra le prove
6 PBO;0.000;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
7 walk-forward;Sharpe -0.75, DD 13.2 %, PSR 0.005;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
8 verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
@@ -0,0 +1,8 @@
voce;valore;motivazione
periodo;2018-12-12 → 2026-09-16;barre M15 dai tick MT5, spread minimo = nessuno (spread dei tick), slippage 0.3 pip/gamba, overnight 0.9 pip/gamba/giorno
prove;57;ogni configurazione provata conta nel Sharpe deflazionato
baseline Moderate;Sharpe -3.88, netto -9802 USD, 2076 basket, win rate 48 %;la configurazione di fabbrica così com'è
migliore;T013: Sharpe -0.10, DSR 0.000;non distinguibile dalla selezione fra le prove
PBO;0.001;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
walk-forward;Sharpe -0.91, DD 13.5 %, PSR 0.000;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
1 voce;valore;motivazione
2 periodo;2018-12-12 → 2026-09-16;barre M15 dai tick MT5, spread minimo = nessuno (spread dei tick), slippage 0.3 pip/gamba, overnight 0.9 pip/gamba/giorno
3 prove;57;ogni configurazione provata conta nel Sharpe deflazionato
4 baseline Moderate;Sharpe -3.88, netto -9802 USD, 2076 basket, win rate 48 %;la configurazione di fabbrica così com'è
5 migliore;T013: Sharpe -0.10, DSR 0.000;non distinguibile dalla selezione fra le prove
6 PBO;0.001;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
7 walk-forward;Sharpe -0.91, DD 13.5 %, PSR 0.000;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
8 verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
+58
View File
@@ -0,0 +1,58 @@
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
BASE-CON;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
BASE-MOD;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2224;0.5018;-9608.2555;-3.38;0.9612;-0.3966;3.1231;-98.1631;-50.2921;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
BASE-AGG;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;2219;0.5273;-9801.7437;-3.605;0.9808;-0.154;3.4299;-107.4078;-54.9455;0;0;2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
T001;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T002;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T003;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T004;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T005;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T006;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T007;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T008;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T009;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T010;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T011;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T012;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T013;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T014;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T015;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T016;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T017;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T018;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
T019;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.5;3;1;2273;0.5095;-9776.971;-3.676;0.9777;-0.5181;3.1978;-100.8336;-48.8858;0;0;2273 basket, win rate 51 %, netto -9777 USD, Sharpe -3.68, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
T020;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.5;3;1;2361;0.5078;-9780.3682;-3.8412;0.9781;-0.4561;3.1972;-93.6221;-45.8502;0;0;2361 basket, win rate 51 %, netto -9780 USD, Sharpe -3.84, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
T021;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.6;3;1;2114;0.5019;-9777.7272;-3.7077;0.9778;-0.9027;3.1945;-97.6587;-50.0165;0;0;2114 basket, win rate 50 %, netto -9778 USD, Sharpe -3.71, DD 97.8 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
T022;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.6;3;1;2186;0.4973;-9775.8854;-3.9248;0.9777;-0.7664;3.1947;-91.6099;-48.0941;0;0;2186 basket, win rate 50 %, netto -9776 USD, Sharpe -3.92, DD 97.8 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
T023;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.7;3;1;1904;0.4806;-9758.7378;-3.9609;0.9759;-1.3315;3.1716;-104.8435;-53.09;0;0;1904 basket, win rate 48 %, netto -9759 USD, Sharpe -3.96, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
T024;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.7;3;1;1943;0.4668;-9761.2755;-4.0828;0.9761;-1.3364;3.1729;-99.5743;-52.3411;0;0;1943 basket, win rate 47 %, netto -9761 USD, Sharpe -4.08, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
T025;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.5;3;1;2386;0.4987;-9698.3332;-3.4922;0.9703;-0.5028;3.1298;-97.1191;-49.2531;0;0;2386 basket, win rate 50 %, netto -9698 USD, Sharpe -3.49, DD 97.0 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
T026;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.5;3;1;2325;0.4886;-9733.1429;-3.7736;0.9737;-0.716;3.1401;-89.9765;-47.1863;0;0;2325 basket, win rate 49 %, netto -9733 USD, Sharpe -3.77, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
T027;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2224;0.5018;-9608.2555;-3.38;0.9612;-0.3966;3.1231;-98.1631;-50.2921;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
T028;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.6;3;1;2232;0.4928;-9626.41;-3.5471;0.963;-0.4681;3.1283;-92.7272;-48.5099;0;0;2232 basket, win rate 49 %, netto -9626 USD, Sharpe -3.55, DD 96.3 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
T029;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.7;3;1;1620;0.4969;-9097.1212;-3.0336;0.9104;-0.7071;3.1249;-110.3729;-57.6683;0;0;1620 basket, win rate 50 %, netto -9097 USD, Sharpe -3.03, DD 91.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
T030;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.7;3;1;1659;0.4846;-9151.1206;-3.1928;0.9158;-0.6745;3.125;-105.9072;-54.6317;0;0;1659 basket, win rate 48 %, netto -9151 USD, Sharpe -3.19, DD 91.6 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
T031;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.5;3;1;1724;0.518;-8946.7157;-2.9015;0.8957;-0.8274;3.1056;-96.5951;-58.2571;0;0;1724 basket, win rate 52 %, netto -8947 USD, Sharpe -2.90, DD 89.6 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
T032;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.5;3;1;1750;0.5114;-8984.9522;-3.0214;0.8994;-0.7697;3.1053;-94.7592;-56.1468;0;0;1750 basket, win rate 51 %, netto -8985 USD, Sharpe -3.02, DD 89.9 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
T033;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.6;3;1;1524;0.523;-8546.383;-2.6559;0.856;-0.5673;3.1088;-108.0414;-60.4233;0;0;1524 basket, win rate 52 %, netto -8546 USD, Sharpe -2.66, DD 85.6 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
T034;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.6;3;1;1555;0.5125;-8644.9455;-2.7834;0.8655;-0.5618;3.1095;-104.0531;-58.2323;0;0;1555 basket, win rate 51 %, netto -8645 USD, Sharpe -2.78, DD 86.5 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
T035;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.7;3;1;1057;0.5232;-7365.987;-2.1831;0.7384;-0.7041;3.1237;-120.7347;-71.8169;0;0;1057 basket, win rate 52 %, netto -7366 USD, Sharpe -2.18, DD 73.8 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
T036;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.7;3;1;1080;0.513;-7548.9926;-2.318;0.7562;-0.8988;3.124;-116.8562;-67.6427;0;0;1080 basket, win rate 51 %, netto -7549 USD, Sharpe -2.32, DD 75.6 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
T037;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.5;3;1;2324;0.503;-9801.8989;-3.7439;0.9802;0.1247;3.453;-94.1066;-44.0792;0;0;2324 basket, win rate 50 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
T038;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.5;3;1;2505;0.5022;-9801.8568;-3.8586;0.9802;0.2991;3.4527;-85.3644;-37.9877;0;0;2505 basket, win rate 50 %, netto -9802 USD, Sharpe -3.86, DD 98.0 %, costo medio 3.5 pip, break-even 0.3 pip: perde al netto dei costi
T039;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.6;3;1;2206;0.4946;-9801.6714;-3.7449;0.9802;-0.122;3.4445;-95.2361;-46.4834;0;0;2206 basket, win rate 49 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
T040;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.6;3;1;2382;0.5008;-9801.8364;-3.8898;0.9802;0.1249;3.4433;-91.5065;-39.2958;0;0;2382 basket, win rate 50 %, netto -9802 USD, Sharpe -3.89, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
T041;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.7;3;1;2066;0.4782;-9800.3225;-3.9569;0.9801;-0.3695;3.4129;-106.8203;-50.7168;0;0;2066 basket, win rate 48 %, netto -9800 USD, Sharpe -3.96, DD 98.0 %, costo medio 3.4 pip, break-even -0.4 pip: perde al netto dei costi
T042;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.7;3;1;2228;0.478;-9800.5097;-4.0478;0.9802;-0.0551;3.4114;-99.1796;-46.4296;0;0;2228 basket, win rate 48 %, netto -9801 USD, Sharpe -4.05, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
T043;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.5;3;1;2333;0.5362;-9800.5583;-3.6212;0.9807;-0.1462;3.455;-114.2527;-52.729;0;0;2333 basket, win rate 54 %, netto -9801 USD, Sharpe -3.62, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
T044;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.5;3;1;2418;0.5434;-9801.6954;-3.7353;0.9807;-0.0542;3.4505;-106.0593;-47.7018;0;0;2418 basket, win rate 54 %, netto -9802 USD, Sharpe -3.74, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
T045;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;2219;0.5273;-9801.7437;-3.605;0.9808;-0.154;3.4299;-107.4078;-54.9455;0;0;2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
T046;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.6;3;1;2299;0.5385;-9800.1165;-3.8043;0.9805;-0.0498;3.4277;-102.5053;-49.1087;0;0;2299 basket, win rate 54 %, netto -9800 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
T047;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.7;3;1;2034;0.5103;-9801.176;-3.5533;0.9805;-0.7064;3.3793;-110.5845;-56.0264;0;0;2034 basket, win rate 51 %, netto -9801 USD, Sharpe -3.55, DD 98.1 %, costo medio 3.4 pip, break-even -0.7 pip: perde al netto dei costi
T048;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.7;3;1;2111;0.5216;-9801.5062;-3.6808;0.9805;-0.5194;3.3807;-102.1236;-50.7364;0;0;2111 basket, win rate 52 %, netto -9802 USD, Sharpe -3.68, DD 98.1 %, costo medio 3.4 pip, break-even -0.5 pip: perde al netto dei costi
T049;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.5;3;1;2535;0.5582;-9800.4562;-3.2691;0.9804;-0.1898;3.4255;-122.7526;-63.8002;0;0;2535 basket, win rate 56 %, netto -9800 USD, Sharpe -3.27, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
T050;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.5;3;1;2652;0.5618;-9800.054;-3.34;0.9804;-0.12;3.4243;-117.3288;-56.7329;0;0;2652 basket, win rate 56 %, netto -9800 USD, Sharpe -3.34, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
T051;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.6;3;1;2611;0.558;-9800.3025;-3.3139;0.9804;0.0259;3.3951;-122.3324;-61.2879;0;0;2611 basket, win rate 56 %, netto -9800 USD, Sharpe -3.31, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
T052;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.6;3;1;2722;0.5643;-9800.3917;-3.4336;0.9804;0.1646;3.3925;-117.9914;-53.3356;0;0;2722 basket, win rate 56 %, netto -9800 USD, Sharpe -3.43, DD 98.0 %, costo medio 3.4 pip, break-even 0.2 pip: perde al netto dei costi
T053;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.7;3;1;2549;0.5457;-9799.5834;-3.5684;0.9806;-0.0172;3.3539;-130.1295;-56.5296;0;0;2549 basket, win rate 55 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
T054;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.7;3;1;2602;0.5473;-9802.9899;-3.6937;0.9808;-0.0068;3.3538;-125.0607;-49.82;0;0;2602 basket, win rate 55 %, netto -9803 USD, Sharpe -3.69, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
1 trial_id preset signalMode exitMode averaging lot_multiplier z_in z_out z_stop TP W rho_min cost_multiple basket_stop n_baskets win_rate pnl_net sharpe maxdd break_even_cost avg_cost_pips p1_pnl p5_pnl psr dsr motivazione
2 BASE-CON Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 100 0.6 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
3 BASE-MOD Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 100 0.6 3 1 2224 0.5018 -9608.2555 -3.38 0.9612 -0.3966 3.1231 -98.1631 -50.2921 0 0 2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
4 BASE-AGG Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 100 0.6 3 1 2219 0.5273 -9801.7437 -3.605 0.9808 -0.154 3.4299 -107.4078 -54.9455 0 0 2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
5 T001 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 60 0.5 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
6 T002 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 60 0.5 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
7 T003 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 60 0.6 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
8 T004 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 60 0.6 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
9 T005 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 60 0.7 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
10 T006 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 60 0.7 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
11 T007 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 100 0.5 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
12 T008 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 100 0.5 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
13 T009 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 100 0.6 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
14 T010 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 100 0.6 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
15 T011 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 100 0.7 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
16 T012 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 100 0.7 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
17 T013 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 150 0.5 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
18 T014 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 150 0.5 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
19 T015 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 150 0.6 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
20 T016 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 150 0.6 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
21 T017 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 150 0.7 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
22 T018 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 150 0.7 3 1 0 0 0 0 0.5 0 nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
23 T019 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 60 0.5 3 1 2273 0.5095 -9776.971 -3.676 0.9777 -0.5181 3.1978 -100.8336 -48.8858 0 0 2273 basket, win rate 51 %, netto -9777 USD, Sharpe -3.68, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
24 T020 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 60 0.5 3 1 2361 0.5078 -9780.3682 -3.8412 0.9781 -0.4561 3.1972 -93.6221 -45.8502 0 0 2361 basket, win rate 51 %, netto -9780 USD, Sharpe -3.84, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
25 T021 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 60 0.6 3 1 2114 0.5019 -9777.7272 -3.7077 0.9778 -0.9027 3.1945 -97.6587 -50.0165 0 0 2114 basket, win rate 50 %, netto -9778 USD, Sharpe -3.71, DD 97.8 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
26 T022 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 60 0.6 3 1 2186 0.4973 -9775.8854 -3.9248 0.9777 -0.7664 3.1947 -91.6099 -48.0941 0 0 2186 basket, win rate 50 %, netto -9776 USD, Sharpe -3.92, DD 97.8 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
27 T023 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 60 0.7 3 1 1904 0.4806 -9758.7378 -3.9609 0.9759 -1.3315 3.1716 -104.8435 -53.09 0 0 1904 basket, win rate 48 %, netto -9759 USD, Sharpe -3.96, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
28 T024 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 60 0.7 3 1 1943 0.4668 -9761.2755 -4.0828 0.9761 -1.3364 3.1729 -99.5743 -52.3411 0 0 1943 basket, win rate 47 %, netto -9761 USD, Sharpe -4.08, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
29 T025 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 100 0.5 3 1 2386 0.4987 -9698.3332 -3.4922 0.9703 -0.5028 3.1298 -97.1191 -49.2531 0 0 2386 basket, win rate 50 %, netto -9698 USD, Sharpe -3.49, DD 97.0 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
30 T026 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 100 0.5 3 1 2325 0.4886 -9733.1429 -3.7736 0.9737 -0.716 3.1401 -89.9765 -47.1863 0 0 2325 basket, win rate 49 %, netto -9733 USD, Sharpe -3.77, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
31 T027 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 100 0.6 3 1 2224 0.5018 -9608.2555 -3.38 0.9612 -0.3966 3.1231 -98.1631 -50.2921 0 0 2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
32 T028 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 100 0.6 3 1 2232 0.4928 -9626.41 -3.5471 0.963 -0.4681 3.1283 -92.7272 -48.5099 0 0 2232 basket, win rate 49 %, netto -9626 USD, Sharpe -3.55, DD 96.3 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
33 T029 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 100 0.7 3 1 1620 0.4969 -9097.1212 -3.0336 0.9104 -0.7071 3.1249 -110.3729 -57.6683 0 0 1620 basket, win rate 50 %, netto -9097 USD, Sharpe -3.03, DD 91.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
34 T030 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 100 0.7 3 1 1659 0.4846 -9151.1206 -3.1928 0.9158 -0.6745 3.125 -105.9072 -54.6317 0 0 1659 basket, win rate 48 %, netto -9151 USD, Sharpe -3.19, DD 91.6 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
35 T031 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 150 0.5 3 1 1724 0.518 -8946.7157 -2.9015 0.8957 -0.8274 3.1056 -96.5951 -58.2571 0 0 1724 basket, win rate 52 %, netto -8947 USD, Sharpe -2.90, DD 89.6 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
36 T032 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 150 0.5 3 1 1750 0.5114 -8984.9522 -3.0214 0.8994 -0.7697 3.1053 -94.7592 -56.1468 0 0 1750 basket, win rate 51 %, netto -8985 USD, Sharpe -3.02, DD 89.9 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
37 T033 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 150 0.6 3 1 1524 0.523 -8546.383 -2.6559 0.856 -0.5673 3.1088 -108.0414 -60.4233 0 0 1524 basket, win rate 52 %, netto -8546 USD, Sharpe -2.66, DD 85.6 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
38 T034 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 150 0.6 3 1 1555 0.5125 -8644.9455 -2.7834 0.8655 -0.5618 3.1095 -104.0531 -58.2323 0 0 1555 basket, win rate 51 %, netto -8645 USD, Sharpe -2.78, DD 86.5 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
39 T035 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 150 0.7 3 1 1057 0.5232 -7365.987 -2.1831 0.7384 -0.7041 3.1237 -120.7347 -71.8169 0 0 1057 basket, win rate 52 %, netto -7366 USD, Sharpe -2.18, DD 73.8 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
40 T036 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 150 0.7 3 1 1080 0.513 -7548.9926 -2.318 0.7562 -0.8988 3.124 -116.8562 -67.6427 0 0 1080 basket, win rate 51 %, netto -7549 USD, Sharpe -2.32, DD 75.6 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
41 T037 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 60 0.5 3 1 2324 0.503 -9801.8989 -3.7439 0.9802 0.1247 3.453 -94.1066 -44.0792 0 0 2324 basket, win rate 50 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
42 T038 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 60 0.5 3 1 2505 0.5022 -9801.8568 -3.8586 0.9802 0.2991 3.4527 -85.3644 -37.9877 0 0 2505 basket, win rate 50 %, netto -9802 USD, Sharpe -3.86, DD 98.0 %, costo medio 3.5 pip, break-even 0.3 pip: perde al netto dei costi
43 T039 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 60 0.6 3 1 2206 0.4946 -9801.6714 -3.7449 0.9802 -0.122 3.4445 -95.2361 -46.4834 0 0 2206 basket, win rate 49 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
44 T040 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 60 0.6 3 1 2382 0.5008 -9801.8364 -3.8898 0.9802 0.1249 3.4433 -91.5065 -39.2958 0 0 2382 basket, win rate 50 %, netto -9802 USD, Sharpe -3.89, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
45 T041 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 60 0.7 3 1 2066 0.4782 -9800.3225 -3.9569 0.9801 -0.3695 3.4129 -106.8203 -50.7168 0 0 2066 basket, win rate 48 %, netto -9800 USD, Sharpe -3.96, DD 98.0 %, costo medio 3.4 pip, break-even -0.4 pip: perde al netto dei costi
46 T042 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 60 0.7 3 1 2228 0.478 -9800.5097 -4.0478 0.9802 -0.0551 3.4114 -99.1796 -46.4296 0 0 2228 basket, win rate 48 %, netto -9801 USD, Sharpe -4.05, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
47 T043 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 100 0.5 3 1 2333 0.5362 -9800.5583 -3.6212 0.9807 -0.1462 3.455 -114.2527 -52.729 0 0 2333 basket, win rate 54 %, netto -9801 USD, Sharpe -3.62, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
48 T044 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 100 0.5 3 1 2418 0.5434 -9801.6954 -3.7353 0.9807 -0.0542 3.4505 -106.0593 -47.7018 0 0 2418 basket, win rate 54 %, netto -9802 USD, Sharpe -3.74, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
49 T045 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 100 0.6 3 1 2219 0.5273 -9801.7437 -3.605 0.9808 -0.154 3.4299 -107.4078 -54.9455 0 0 2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
50 T046 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 100 0.6 3 1 2299 0.5385 -9800.1165 -3.8043 0.9805 -0.0498 3.4277 -102.5053 -49.1087 0 0 2299 basket, win rate 54 %, netto -9800 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
51 T047 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 100 0.7 3 1 2034 0.5103 -9801.176 -3.5533 0.9805 -0.7064 3.3793 -110.5845 -56.0264 0 0 2034 basket, win rate 51 %, netto -9801 USD, Sharpe -3.55, DD 98.1 %, costo medio 3.4 pip, break-even -0.7 pip: perde al netto dei costi
52 T048 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 100 0.7 3 1 2111 0.5216 -9801.5062 -3.6808 0.9805 -0.5194 3.3807 -102.1236 -50.7364 0 0 2111 basket, win rate 52 %, netto -9802 USD, Sharpe -3.68, DD 98.1 %, costo medio 3.4 pip, break-even -0.5 pip: perde al netto dei costi
53 T049 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 150 0.5 3 1 2535 0.5582 -9800.4562 -3.2691 0.9804 -0.1898 3.4255 -122.7526 -63.8002 0 0 2535 basket, win rate 56 %, netto -9800 USD, Sharpe -3.27, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
54 T050 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 150 0.5 3 1 2652 0.5618 -9800.054 -3.34 0.9804 -0.12 3.4243 -117.3288 -56.7329 0 0 2652 basket, win rate 56 %, netto -9800 USD, Sharpe -3.34, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
55 T051 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 150 0.6 3 1 2611 0.558 -9800.3025 -3.3139 0.9804 0.0259 3.3951 -122.3324 -61.2879 0 0 2611 basket, win rate 56 %, netto -9800 USD, Sharpe -3.31, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
56 T052 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 150 0.6 3 1 2722 0.5643 -9800.3917 -3.4336 0.9804 0.1646 3.3925 -117.9914 -53.3356 0 0 2722 basket, win rate 56 %, netto -9800 USD, Sharpe -3.43, DD 98.0 %, costo medio 3.4 pip, break-even 0.2 pip: perde al netto dei costi
57 T053 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 150 0.7 3 1 2549 0.5457 -9799.5834 -3.5684 0.9806 -0.0172 3.3539 -130.1295 -56.5296 0 0 2549 basket, win rate 55 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
58 T054 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 150 0.7 3 1 2602 0.5473 -9802.9899 -3.6937 0.9808 -0.0068 3.3538 -125.0607 -49.82 0 0 2602 basket, win rate 55 %, netto -9803 USD, Sharpe -3.69, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
+58
View File
@@ -0,0 +1,58 @@
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
BASE-CON;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
BASE-MOD;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2076;0.4769;-9801.9491;-3.8807;0.9804;-0.4892;3.2085;-96.1599;-51.6818;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
BASE-AGG;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;1995;0.5143;-9800.4836;-3.7229;0.9806;-0.1782;3.4269;-113.7756;-55.2381;0;0;1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
T001;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.5;3;1;8;0.5;-69.5629;-0.2771;0.0086;4.9254;2.5879;-80.2036;-80.2036;0.1196;0;8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
T002;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.5;3;1;8;0.375;-110.4189;-0.3764;0.0127;3.8879;2.5879;-80.2036;-80.2036;0.0455;0;8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
T003;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.6;3;1;8;0.5;-69.5629;-0.2771;0.0086;4.9254;2.5879;-80.2036;-80.2036;0.1196;0;8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
T004;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.6;3;1;8;0.375;-110.4189;-0.3764;0.0127;3.8879;2.5879;-80.2036;-80.2036;0.0455;0;8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
T005;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.7;3;1;3;0.3333;-16.5569;-0.2092;0.0024;10.6949;2.5283;-24.5014;-24.5014;0.1936;0;3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
T006;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.7;3;1;3;0.3333;-16.5569;-0.2092;0.0024;10.6949;2.5283;-24.5014;-24.5014;0.1936;0;3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
T007;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.5;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
T008;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.5;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
T009;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
T010;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
T011;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.7;3;1;3;0.3333;-31.767;-0.2049;0.0048;5.9618;2.5284;-38.4174;-38.4174;0.2014;0;3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
T012;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.7;3;1;3;0.3333;-31.767;-0.2049;0.0048;5.9618;2.5284;-38.4174;-38.4174;0.2014;0;3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
T013;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.5;3;1;8;0.625;-44.5609;-0.1017;0.0143;0.1426;2.3801;-111.7539;-111.7539;0.3732;0;8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
T014;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.5;3;1;8;0.625;-44.5609;-0.1017;0.0143;0.1426;2.3801;-111.7539;-111.7539;0.3732;0;8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
T015;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.6;3;1;6;0.5;-113.611;-0.2917;0.0152;-4.221;2.629;-111.7539;-111.7539;0.1381;0;6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
T016;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.6;3;1;6;0.5;-113.611;-0.2917;0.0152;-4.221;2.629;-111.7539;-111.7539;0.1381;0;6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
T017;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.7;3;1;4;0.5;-100.5194;-0.2632;0.0139;-8.8621;2.6129;-111.7035;-111.7035;0.1631;0;4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
T018;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.7;3;1;4;0.5;-100.5194;-0.2632;0.0139;-8.8621;2.6129;-111.7035;-111.7035;0.1631;0;4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
T019;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.5;3;1;2018;0.4792;-9800.6546;-4.1396;0.9801;-0.7742;3.2141;-103.5455;-51.4822;0;0;2018 basket, win rate 48 %, netto -9801 USD, Sharpe -4.14, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
T020;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.5;3;1;2192;0.4886;-9803.1597;-4.2704;0.9803;-0.5484;3.2139;-97.7234;-46.9665;0;0;2192 basket, win rate 49 %, netto -9803 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
T021;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.6;3;1;1972;0.4787;-9801.7849;-4.0332;0.9802;-0.9003;3.2155;-100.0295;-49.8798;0;0;1972 basket, win rate 48 %, netto -9802 USD, Sharpe -4.03, DD 98.0 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
T022;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.6;3;1;2069;0.4819;-9803.6976;-4.2507;0.9804;-0.782;3.2167;-93.6889;-48.8346;0;0;2069 basket, win rate 48 %, netto -9804 USD, Sharpe -4.25, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
T023;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.7;3;1;2055;0.4818;-9801.1624;-4.0098;0.9801;-0.5217;3.2143;-102.1531;-50.7697;0;0;2055 basket, win rate 48 %, netto -9801 USD, Sharpe -4.01, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
T024;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.7;3;1;2132;0.4855;-9801.1437;-4.1302;0.9801;-0.4595;3.2148;-98.965;-47.3869;0;0;2132 basket, win rate 49 %, netto -9801 USD, Sharpe -4.13, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
T025;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.5;3;1;2101;0.4731;-9801.082;-3.7918;0.9804;-0.5503;3.2078;-94.4597;-50.6244;0;0;2101 basket, win rate 47 %, netto -9801 USD, Sharpe -3.79, DD 98.0 %, costo medio 3.2 pip, break-even -0.6 pip: perde al netto dei costi
T026;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.5;3;1;2140;0.4738;-9801.717;-3.9388;0.9804;-0.5081;3.2089;-80.9489;-48.5339;0;0;2140 basket, win rate 47 %, netto -9802 USD, Sharpe -3.94, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
T027;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2076;0.4769;-9801.9491;-3.8807;0.9804;-0.4892;3.2085;-96.1599;-51.6818;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
T028;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.6;3;1;2108;0.4782;-9801.8954;-4.0225;0.9803;-0.4629;3.2091;-87.7027;-50.0178;0;0;2108 basket, win rate 48 %, netto -9802 USD, Sharpe -4.02, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
T029;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.7;3;1;1984;0.4582;-9797.6241;-4.1086;0.9799;-0.798;3.1973;-92.7908;-47.5743;0;0;1984 basket, win rate 46 %, netto -9798 USD, Sharpe -4.11, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
T030;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.7;3;1;2040;0.4574;-9797.1369;-4.2728;0.9799;-0.7345;3.1987;-87.6021;-45.6211;0;0;2040 basket, win rate 46 %, netto -9797 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.7 pip: perde al netto dei costi
T031;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.5;3;1;2238;0.4915;-9733.8654;-3.7948;0.9738;-0.5971;3.1445;-90.112;-47.5574;0;0;2238 basket, win rate 49 %, netto -9734 USD, Sharpe -3.79, DD 97.4 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
T032;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.5;3;1;2247;0.4887;-9738.5622;-3.9701;0.9742;-0.6768;3.1479;-89.2127;-45.6445;0;0;2247 basket, win rate 49 %, netto -9739 USD, Sharpe -3.97, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
T033;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.6;3;1;2090;0.4856;-9697.0682;-3.7782;0.97;-0.7223;3.1429;-95.5498;-51.6315;0;0;2090 basket, win rate 49 %, netto -9697 USD, Sharpe -3.78, DD 97.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
T034;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.6;3;1;2120;0.4863;-9681.6883;-3.8261;0.9684;-0.5759;3.1425;-96.173;-48.981;0;0;2120 basket, win rate 49 %, netto -9682 USD, Sharpe -3.83, DD 96.8 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
T035;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.7;3;1;1616;0.4901;-9304.7978;-3.3946;0.9309;-0.9725;3.1466;-103.2808;-55.1427;0;0;1616 basket, win rate 49 %, netto -9305 USD, Sharpe -3.39, DD 93.1 %, costo medio 3.1 pip, break-even -1.0 pip: perde al netto dei costi
T036;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.7;3;1;1650;0.4818;-9315.3955;-3.4957;0.9319;-0.9219;3.1469;-103.8865;-53.903;0;0;1650 basket, win rate 48 %, netto -9315 USD, Sharpe -3.50, DD 93.2 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
T037;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.5;3;1;2222;0.5018;-9800.1789;-3.493;0.9801;0.1139;3.4884;-95.5877;-43.0006;0;0;2222 basket, win rate 50 %, netto -9800 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
T038;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.5;3;1;2319;0.5071;-9801.3643;-3.6642;0.9802;0.1721;3.4888;-90.1871;-38.1818;0;0;2319 basket, win rate 51 %, netto -9801 USD, Sharpe -3.66, DD 98.0 %, costo medio 3.5 pip, break-even 0.2 pip: perde al netto dei costi
T039;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.6;3;1;2068;0.4971;-9802.4311;-3.6774;0.9803;-0.3555;3.4622;-96.564;-45.9072;0;0;2068 basket, win rate 50 %, netto -9802 USD, Sharpe -3.68, DD 98.0 %, costo medio 3.5 pip, break-even -0.4 pip: perde al netto dei costi
T040;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.6;3;1;2261;0.5042;-9801.8015;-3.7059;0.9802;-0.0458;3.4572;-87.7666;-43.2972;0;0;2261 basket, win rate 50 %, netto -9802 USD, Sharpe -3.71, DD 98.0 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
T041;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.7;3;1;2032;0.4818;-9803.4939;-3.8824;0.9804;-0.2081;3.3942;-102.0523;-51.4273;0;0;2032 basket, win rate 48 %, netto -9803 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
T042;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.7;3;1;2223;0.4876;-9799.9736;-4.122;0.9801;0.1232;3.3884;-96.1687;-46.1443;0;0;2223 basket, win rate 49 %, netto -9800 USD, Sharpe -4.12, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
T043;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.5;3;1;2161;0.5298;-9800.4196;-3.5742;0.9806;-0.0636;3.461;-110.6802;-51.9967;0;0;2161 basket, win rate 53 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
T044;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.5;3;1;2289;0.5382;-9801.341;-3.7496;0.9806;-0.0314;3.4567;-99.7287;-47.5057;0;0;2289 basket, win rate 54 %, netto -9801 USD, Sharpe -3.75, DD 98.1 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
T045;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;1995;0.5143;-9800.4836;-3.7229;0.9806;-0.1782;3.4269;-113.7756;-55.2381;0;0;1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
T046;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.6;3;1;2092;0.5268;-9801.2355;-3.8187;0.9806;-0.1043;3.4231;-103.851;-49.8169;0;0;2092 basket, win rate 53 %, netto -9801 USD, Sharpe -3.82, DD 98.1 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
T047;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.7;3;1;1790;0.4955;-9801.26;-3.7117;0.9805;-1.011;3.3543;-107.8876;-56.2754;0;0;1790 basket, win rate 50 %, netto -9801 USD, Sharpe -3.71, DD 98.1 %, costo medio 3.4 pip, break-even -1.0 pip: perde al netto dei costi
T048;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.7;3;1;1972;0.5137;-9802.9285;-3.7993;0.9806;-0.6154;3.3518;-97.0256;-50.7654;0;0;1972 basket, win rate 51 %, netto -9803 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.6 pip: perde al netto dei costi
T049;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.5;3;1;2267;0.5483;-9800.1872;-3.3236;0.9804;-0.2425;3.4352;-127.609;-57.2805;0;0;2267 basket, win rate 55 %, netto -9800 USD, Sharpe -3.32, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
T050;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.5;3;1;2289;0.547;-9800.6111;-3.4919;0.9804;-0.2567;3.4319;-121.7571;-55.2384;0;0;2289 basket, win rate 55 %, netto -9801 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.4 pip, break-even -0.3 pip: perde al netto dei costi
T051;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.6;3;1;2280;0.55;-9799.948;-3.3521;0.9804;-0.177;3.383;-116.3317;-59.2741;0;0;2280 basket, win rate 55 %, netto -9800 USD, Sharpe -3.35, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
T052;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.6;3;1;2412;0.5547;-9800.0702;-3.4091;0.9804;0.0354;3.3835;-114.6115;-56.6657;0;0;2412 basket, win rate 55 %, netto -9800 USD, Sharpe -3.41, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
T053;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.7;3;1;2298;0.5361;-9802.0629;-3.5954;0.9808;-0.121;3.3223;-127.3343;-57.6739;0;0;2298 basket, win rate 54 %, netto -9802 USD, Sharpe -3.60, DD 98.1 %, costo medio 3.3 pip, break-even -0.1 pip: perde al netto dei costi
T054;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.7;3;1;2441;0.5453;-9800.0711;-3.6641;0.9805;0.0417;3.3249;-123.3318;-50.6626;0;0;2441 basket, win rate 55 %, netto -9800 USD, Sharpe -3.66, DD 98.1 %, costo medio 3.3 pip, break-even 0.0 pip: perde al netto dei costi
1 trial_id preset signalMode exitMode averaging lot_multiplier z_in z_out z_stop TP W rho_min cost_multiple basket_stop n_baskets win_rate pnl_net sharpe maxdd break_even_cost avg_cost_pips p1_pnl p5_pnl psr dsr motivazione
2 BASE-CON Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 100 0.6 3 1 5 0.4 -68.6995 -0.3203 0.0092 5.8625 2.5825 -60.6171 -60.6171 0.0947 0 5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
3 BASE-MOD Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 100 0.6 3 1 2076 0.4769 -9801.9491 -3.8807 0.9804 -0.4892 3.2085 -96.1599 -51.6818 0 0 2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
4 BASE-AGG Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 100 0.6 3 1 1995 0.5143 -9800.4836 -3.7229 0.9806 -0.1782 3.4269 -113.7756 -55.2381 0 0 1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
5 T001 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 60 0.5 3 1 8 0.5 -69.5629 -0.2771 0.0086 4.9254 2.5879 -80.2036 -80.2036 0.1196 0 8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
6 T002 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 60 0.5 3 1 8 0.375 -110.4189 -0.3764 0.0127 3.8879 2.5879 -80.2036 -80.2036 0.0455 0 8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
7 T003 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 60 0.6 3 1 8 0.5 -69.5629 -0.2771 0.0086 4.9254 2.5879 -80.2036 -80.2036 0.1196 0 8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
8 T004 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 60 0.6 3 1 8 0.375 -110.4189 -0.3764 0.0127 3.8879 2.5879 -80.2036 -80.2036 0.0455 0 8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
9 T005 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 60 0.7 3 1 3 0.3333 -16.5569 -0.2092 0.0024 10.6949 2.5283 -24.5014 -24.5014 0.1936 0 3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
10 T006 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 60 0.7 3 1 3 0.3333 -16.5569 -0.2092 0.0024 10.6949 2.5283 -24.5014 -24.5014 0.1936 0 3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
11 T007 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 100 0.5 3 1 5 0.4 -68.6995 -0.3203 0.0092 5.8625 2.5825 -60.6171 -60.6171 0.0947 0 5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
12 T008 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 100 0.5 3 1 5 0.4 -68.6995 -0.3203 0.0092 5.8625 2.5825 -60.6171 -60.6171 0.0947 0 5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
13 T009 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 100 0.6 3 1 5 0.4 -68.6995 -0.3203 0.0092 5.8625 2.5825 -60.6171 -60.6171 0.0947 0 5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
14 T010 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 100 0.6 3 1 5 0.4 -68.6995 -0.3203 0.0092 5.8625 2.5825 -60.6171 -60.6171 0.0947 0 5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
15 T011 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 100 0.7 3 1 3 0.3333 -31.767 -0.2049 0.0048 5.9618 2.5284 -38.4174 -38.4174 0.2014 0 3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
16 T012 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 100 0.7 3 1 3 0.3333 -31.767 -0.2049 0.0048 5.9618 2.5284 -38.4174 -38.4174 0.2014 0 3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
17 T013 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 150 0.5 3 1 8 0.625 -44.5609 -0.1017 0.0143 0.1426 2.3801 -111.7539 -111.7539 0.3732 0 8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
18 T014 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 150 0.5 3 1 8 0.625 -44.5609 -0.1017 0.0143 0.1426 2.3801 -111.7539 -111.7539 0.3732 0 8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
19 T015 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 150 0.6 3 1 6 0.5 -113.611 -0.2917 0.0152 -4.221 2.629 -111.7539 -111.7539 0.1381 0 6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
20 T016 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 150 0.6 3 1 6 0.5 -113.611 -0.2917 0.0152 -4.221 2.629 -111.7539 -111.7539 0.1381 0 6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
21 T017 Conservative ZScoreSynthetic First Off 1 2.5 0.25 3 8 150 0.7 3 1 4 0.5 -100.5194 -0.2632 0.0139 -8.8621 2.6129 -111.7035 -111.7035 0.1631 0 4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
22 T018 Conservative ZScoreSynthetic First Off 1 2.5 0.5 3 8 150 0.7 3 1 4 0.5 -100.5194 -0.2632 0.0139 -8.8621 2.6129 -111.7035 -111.7035 0.1631 0 4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
23 T019 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 60 0.5 3 1 2018 0.4792 -9800.6546 -4.1396 0.9801 -0.7742 3.2141 -103.5455 -51.4822 0 0 2018 basket, win rate 48 %, netto -9801 USD, Sharpe -4.14, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
24 T020 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 60 0.5 3 1 2192 0.4886 -9803.1597 -4.2704 0.9803 -0.5484 3.2139 -97.7234 -46.9665 0 0 2192 basket, win rate 49 %, netto -9803 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
25 T021 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 60 0.6 3 1 1972 0.4787 -9801.7849 -4.0332 0.9802 -0.9003 3.2155 -100.0295 -49.8798 0 0 1972 basket, win rate 48 %, netto -9802 USD, Sharpe -4.03, DD 98.0 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
26 T022 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 60 0.6 3 1 2069 0.4819 -9803.6976 -4.2507 0.9804 -0.782 3.2167 -93.6889 -48.8346 0 0 2069 basket, win rate 48 %, netto -9804 USD, Sharpe -4.25, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
27 T023 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 60 0.7 3 1 2055 0.4818 -9801.1624 -4.0098 0.9801 -0.5217 3.2143 -102.1531 -50.7697 0 0 2055 basket, win rate 48 %, netto -9801 USD, Sharpe -4.01, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
28 T024 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 60 0.7 3 1 2132 0.4855 -9801.1437 -4.1302 0.9801 -0.4595 3.2148 -98.965 -47.3869 0 0 2132 basket, win rate 49 %, netto -9801 USD, Sharpe -4.13, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
29 T025 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 100 0.5 3 1 2101 0.4731 -9801.082 -3.7918 0.9804 -0.5503 3.2078 -94.4597 -50.6244 0 0 2101 basket, win rate 47 %, netto -9801 USD, Sharpe -3.79, DD 98.0 %, costo medio 3.2 pip, break-even -0.6 pip: perde al netto dei costi
30 T026 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 100 0.5 3 1 2140 0.4738 -9801.717 -3.9388 0.9804 -0.5081 3.2089 -80.9489 -48.5339 0 0 2140 basket, win rate 47 %, netto -9802 USD, Sharpe -3.94, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
31 T027 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 100 0.6 3 1 2076 0.4769 -9801.9491 -3.8807 0.9804 -0.4892 3.2085 -96.1599 -51.6818 0 0 2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
32 T028 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 100 0.6 3 1 2108 0.4782 -9801.8954 -4.0225 0.9803 -0.4629 3.2091 -87.7027 -50.0178 0 0 2108 basket, win rate 48 %, netto -9802 USD, Sharpe -4.02, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
33 T029 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 100 0.7 3 1 1984 0.4582 -9797.6241 -4.1086 0.9799 -0.798 3.1973 -92.7908 -47.5743 0 0 1984 basket, win rate 46 %, netto -9798 USD, Sharpe -4.11, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
34 T030 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 100 0.7 3 1 2040 0.4574 -9797.1369 -4.2728 0.9799 -0.7345 3.1987 -87.6021 -45.6211 0 0 2040 basket, win rate 46 %, netto -9797 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.7 pip: perde al netto dei costi
35 T031 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 150 0.5 3 1 2238 0.4915 -9733.8654 -3.7948 0.9738 -0.5971 3.1445 -90.112 -47.5574 0 0 2238 basket, win rate 49 %, netto -9734 USD, Sharpe -3.79, DD 97.4 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
36 T032 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 150 0.5 3 1 2247 0.4887 -9738.5622 -3.9701 0.9742 -0.6768 3.1479 -89.2127 -45.6445 0 0 2247 basket, win rate 49 %, netto -9739 USD, Sharpe -3.97, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
37 T033 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 150 0.6 3 1 2090 0.4856 -9697.0682 -3.7782 0.97 -0.7223 3.1429 -95.5498 -51.6315 0 0 2090 basket, win rate 49 %, netto -9697 USD, Sharpe -3.78, DD 97.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
38 T034 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 150 0.6 3 1 2120 0.4863 -9681.6883 -3.8261 0.9684 -0.5759 3.1425 -96.173 -48.981 0 0 2120 basket, win rate 49 %, netto -9682 USD, Sharpe -3.83, DD 96.8 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
39 T035 Moderate ZScoreSynthetic First Off 1 2 0.25 3.5 10 150 0.7 3 1 1616 0.4901 -9304.7978 -3.3946 0.9309 -0.9725 3.1466 -103.2808 -55.1427 0 0 1616 basket, win rate 49 %, netto -9305 USD, Sharpe -3.39, DD 93.1 %, costo medio 3.1 pip, break-even -1.0 pip: perde al netto dei costi
40 T036 Moderate ZScoreSynthetic First Off 1 2 0.5 3.5 10 150 0.7 3 1 1650 0.4818 -9315.3955 -3.4957 0.9319 -0.9219 3.1469 -103.8865 -53.903 0 0 1650 basket, win rate 48 %, netto -9315 USD, Sharpe -3.50, DD 93.2 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
41 T037 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 60 0.5 3 1 2222 0.5018 -9800.1789 -3.493 0.9801 0.1139 3.4884 -95.5877 -43.0006 0 0 2222 basket, win rate 50 %, netto -9800 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
42 T038 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 60 0.5 3 1 2319 0.5071 -9801.3643 -3.6642 0.9802 0.1721 3.4888 -90.1871 -38.1818 0 0 2319 basket, win rate 51 %, netto -9801 USD, Sharpe -3.66, DD 98.0 %, costo medio 3.5 pip, break-even 0.2 pip: perde al netto dei costi
43 T039 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 60 0.6 3 1 2068 0.4971 -9802.4311 -3.6774 0.9803 -0.3555 3.4622 -96.564 -45.9072 0 0 2068 basket, win rate 50 %, netto -9802 USD, Sharpe -3.68, DD 98.0 %, costo medio 3.5 pip, break-even -0.4 pip: perde al netto dei costi
44 T040 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 60 0.6 3 1 2261 0.5042 -9801.8015 -3.7059 0.9802 -0.0458 3.4572 -87.7666 -43.2972 0 0 2261 basket, win rate 50 %, netto -9802 USD, Sharpe -3.71, DD 98.0 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
45 T041 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 60 0.7 3 1 2032 0.4818 -9803.4939 -3.8824 0.9804 -0.2081 3.3942 -102.0523 -51.4273 0 0 2032 basket, win rate 48 %, netto -9803 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
46 T042 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 60 0.7 3 1 2223 0.4876 -9799.9736 -4.122 0.9801 0.1232 3.3884 -96.1687 -46.1443 0 0 2223 basket, win rate 49 %, netto -9800 USD, Sharpe -4.12, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
47 T043 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 100 0.5 3 1 2161 0.5298 -9800.4196 -3.5742 0.9806 -0.0636 3.461 -110.6802 -51.9967 0 0 2161 basket, win rate 53 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
48 T044 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 100 0.5 3 1 2289 0.5382 -9801.341 -3.7496 0.9806 -0.0314 3.4567 -99.7287 -47.5057 0 0 2289 basket, win rate 54 %, netto -9801 USD, Sharpe -3.75, DD 98.1 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
49 T045 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 100 0.6 3 1 1995 0.5143 -9800.4836 -3.7229 0.9806 -0.1782 3.4269 -113.7756 -55.2381 0 0 1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
50 T046 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 100 0.6 3 1 2092 0.5268 -9801.2355 -3.8187 0.9806 -0.1043 3.4231 -103.851 -49.8169 0 0 2092 basket, win rate 53 %, netto -9801 USD, Sharpe -3.82, DD 98.1 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
51 T047 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 100 0.7 3 1 1790 0.4955 -9801.26 -3.7117 0.9805 -1.011 3.3543 -107.8876 -56.2754 0 0 1790 basket, win rate 50 %, netto -9801 USD, Sharpe -3.71, DD 98.1 %, costo medio 3.4 pip, break-even -1.0 pip: perde al netto dei costi
52 T048 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 100 0.7 3 1 1972 0.5137 -9802.9285 -3.7993 0.9806 -0.6154 3.3518 -97.0256 -50.7654 0 0 1972 basket, win rate 51 %, netto -9803 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.6 pip: perde al netto dei costi
53 T049 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 150 0.5 3 1 2267 0.5483 -9800.1872 -3.3236 0.9804 -0.2425 3.4352 -127.609 -57.2805 0 0 2267 basket, win rate 55 %, netto -9800 USD, Sharpe -3.32, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
54 T050 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 150 0.5 3 1 2289 0.547 -9800.6111 -3.4919 0.9804 -0.2567 3.4319 -121.7571 -55.2384 0 0 2289 basket, win rate 55 %, netto -9801 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.4 pip, break-even -0.3 pip: perde al netto dei costi
55 T051 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 150 0.6 3 1 2280 0.55 -9799.948 -3.3521 0.9804 -0.177 3.383 -116.3317 -59.2741 0 0 2280 basket, win rate 55 %, netto -9800 USD, Sharpe -3.35, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
56 T052 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 150 0.6 3 1 2412 0.5547 -9800.0702 -3.4091 0.9804 0.0354 3.3835 -114.6115 -56.6657 0 0 2412 basket, win rate 55 %, netto -9800 USD, Sharpe -3.41, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
57 T053 Aggressive ZScoreSynthetic First Off 1 1.5 0.25 4 12 150 0.7 3 1 2298 0.5361 -9802.0629 -3.5954 0.9808 -0.121 3.3223 -127.3343 -57.6739 0 0 2298 basket, win rate 54 %, netto -9802 USD, Sharpe -3.60, DD 98.1 %, costo medio 3.3 pip, break-even -0.1 pip: perde al netto dei costi
58 T054 Aggressive ZScoreSynthetic First Off 1 1.5 0.5 4 12 150 0.7 3 1 2441 0.5453 -9800.0711 -3.6641 0.9805 0.0417 3.3249 -123.3318 -50.6626 0 0 2441 basket, win rate 55 %, netto -9800 USD, Sharpe -3.66, DD 98.1 %, costo medio 3.3 pip, break-even 0.0 pip: perde al netto dei costi
+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
@@ -1,114 +0,0 @@
using Encelado.Core.Market;
namespace Encelado.Alpaca;
/// <summary>Connection settings for every Alpaca endpoint the bot talks to.</summary>
public sealed class AlpacaOptions
{
public const string PaperTradingBase = "https://paper-api.alpaca.markets";
public const string LiveTradingBase = "https://api.alpaca.markets";
public const string MarketDataBase = "https://data.alpaca.markets";
public const string MarketDataStreamBase = "wss://stream.data.alpaca.markets";
public string KeyId { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
/// <summary>Paper trading is the default. Flipping this to <see langword="false"/> risks real money.</summary>
public bool Paper { get; set; } = true;
/// <summary>
/// Equity data feed: <c>iex</c> (free), <c>sip</c> (full tape, paid),
/// <c>delayed_sip</c>, or <c>test</c> (Alpaca's synthetic FAKEPACA stream).
/// </summary>
public string DataFeed { get; set; } = "iex";
/// <summary>Overrides the trading REST base URL. Leave empty to derive it from <see cref="Paper"/>.</summary>
public string TradingBaseUrlOverride { get; set; } = string.Empty;
/// <summary>Overrides the market-data REST base URL.</summary>
public string DataBaseUrlOverride { get; set; } = string.Empty;
/// <summary>Client-side throttle. Alpaca allows 200 requests/minute per account on the basic plan.</summary>
public int RequestsPerMinute { get; set; } = 180;
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>Number of retries for transient failures (429 / 5xx / socket errors).</summary>
public int MaxRetries { get; set; } = 4;
public string TradingBaseUrl =>
string.IsNullOrWhiteSpace(TradingBaseUrlOverride)
? (Paper ? PaperTradingBase : LiveTradingBase)
: TradingBaseUrlOverride.TrimEnd('/');
public string DataBaseUrl =>
string.IsNullOrWhiteSpace(DataBaseUrlOverride)
? MarketDataBase
: DataBaseUrlOverride.TrimEnd('/');
/// <summary>Order/position event stream. Lives on the trading host, not the data host.</summary>
public Uri TradeUpdatesStreamUri =>
new(TradingBaseUrl.Replace("https://", "wss://", StringComparison.Ordinal) + "/stream");
public Uri MarketDataStreamUri(AssetClass assetClass) => assetClass switch
{
AssetClass.Crypto => new Uri($"{MarketDataStreamBase}/v1beta3/crypto/us"),
_ => new Uri($"{MarketDataStreamBase}/v2/{DataFeed}"),
};
public AlpacaOptions Validate()
{
if (string.IsNullOrWhiteSpace(KeyId) || string.IsNullOrWhiteSpace(SecretKey))
{
throw new InvalidOperationException(
"Alpaca credentials are missing. Set APCA_API_KEY_ID and APCA_API_SECRET_KEY " +
"(or alpaca.keyId / alpaca.secretKey in the config file).");
}
// Credentials travel as HTTP headers. A stray non-ASCII character (a smart quote
// from a copy/paste, a BOM, a UTF-16 artefact from a pipe) would otherwise
// surface much later as an opaque "invalid char encoding" transport failure.
RequirePrintableAscii(KeyId, nameof(KeyId));
RequirePrintableAscii(SecretKey, nameof(SecretKey));
if (DataFeed is not ("iex" or "sip" or "delayed_sip" or "otc" or "test"))
{
throw new InvalidOperationException(
$"alpaca.dataFeed '{DataFeed}' is not one of: iex, sip, delayed_sip, otc, test.");
}
if (RequestsPerMinute is < 1 or > 1000)
{
throw new InvalidOperationException("alpaca.requestsPerMinute must be between 1 and 1000.");
}
return this;
}
private static void RequirePrintableAscii(string value, string field)
{
foreach (char c in value)
{
if (c is < ' ' or > '~')
{
throw new InvalidOperationException(
$"alpaca.{char.ToLowerInvariant(field[0])}{field[1..]} contains a character that is not " +
$"printable ASCII (U+{(int)c:X4}). Re-copy the key from the Alpaca dashboard — " +
"invisible characters are usually picked up by copy/paste.");
}
}
}
}
/// <summary>Raised when Alpaca answers with a non-success status or an unusable payload.</summary>
public sealed class AlpacaApiException(string message, int statusCode = 0, string? body = null)
: Exception(message)
{
public int StatusCode { get; } = statusCode;
public string? Body { get; } = body;
/// <summary>Transient conditions worth retrying.</summary>
public bool IsTransient => StatusCode is 429 or >= 500;
}
@@ -1,12 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Encelado.Alpaca</RootNamespace>
<AssemblyName>Encelado.Alpaca</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Encelado.Core\Encelado.Core.csproj" />
</ItemGroup>
</Project>
@@ -1,98 +0,0 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Alpaca.Internal;
/// <summary>
/// Reading helpers for Alpaca's REST payloads. Alpaca encodes most numeric fields as
/// JSON <i>strings</i> (<c>"qty": "10"</c>), and omits or nulls fields liberally, so
/// every accessor tolerates both shapes and a missing property.
/// </summary>
public static class JsonRead
{
public static string? StringOrNull(this JsonElement e, string name) =>
e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
public static string StringOrEmpty(this JsonElement e, string name) =>
e.StringOrNull(name) ?? string.Empty;
public static double Double(this JsonElement e, string name, double fallback = 0)
{
if (!e.TryGetProperty(name, out JsonElement v))
{
return fallback;
}
return v.ValueKind switch
{
JsonValueKind.Number => v.GetDouble(),
JsonValueKind.String => double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d)
? d
: fallback,
_ => fallback,
};
}
public static decimal Decimal(this JsonElement e, string name, decimal fallback = 0)
{
if (!e.TryGetProperty(name, out JsonElement v))
{
return fallback;
}
return v.ValueKind switch
{
JsonValueKind.Number => v.GetDecimal(),
JsonValueKind.String => decimal.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out decimal d)
? d
: fallback,
_ => fallback,
};
}
public static int Int32(this JsonElement e, string name, int fallback = 0)
{
if (!e.TryGetProperty(name, out JsonElement v))
{
return fallback;
}
return v.ValueKind switch
{
JsonValueKind.Number => v.TryGetInt32(out int i) ? i : (int)v.GetDouble(),
JsonValueKind.String => int.TryParse(v.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int i)
? i
: fallback,
_ => fallback,
};
}
public static bool Bool(this JsonElement e, string name, bool fallback = false)
{
if (!e.TryGetProperty(name, out JsonElement v))
{
return fallback;
}
return v.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(v.GetString(), out bool b) ? b : fallback,
_ => fallback,
};
}
public static DateTime Timestamp(this JsonElement e, string name) =>
e.TryGetProperty(name, out JsonElement v) && v.ValueKind == JsonValueKind.String
? Rfc3339.ParseUtc(v.GetString())
: DateTime.MinValue;
public static DateTime? TimestampOrNull(this JsonElement e, string name)
{
DateTime dt = e.Timestamp(name);
return dt == DateTime.MinValue ? null : dt;
}
}
@@ -1,121 +0,0 @@
using System.Globalization;
namespace Encelado.Alpaca.Internal;
/// <summary>
/// Hand-rolled RFC3339 parser for the shape Alpaca actually emits
/// (<c>2024-05-17T13:04:56.334262119Z</c>). It runs on every tick of the market-data
/// stream, so it avoids the general-purpose date parser and its culture lookups.
/// Falls back to <see cref="DateTime.TryParse(ReadOnlySpan{char}, IFormatProvider, DateTimeStyles, out DateTime)"/>
/// for anything unusual (offsets, missing fractions, non-UTC).
/// </summary>
public static class Rfc3339
{
/// <summary>Parses a UTC timestamp from UTF-8 bytes. Returns <see cref="DateTime.MinValue"/> on failure.</summary>
public static DateTime ParseUtc(ReadOnlySpan<byte> utf8)
{
// Fast path: exactly "YYYY-MM-DDTHH:MM:SS" plus optional ".fraction" and a "Z".
if (utf8.Length >= 20 && utf8[^1] == (byte)'Z' &&
utf8[4] == (byte)'-' && utf8[7] == (byte)'-' &&
(utf8[10] == (byte)'T' || utf8[10] == (byte)' ') &&
utf8[13] == (byte)':' && utf8[16] == (byte)':')
{
if (TryDigits(utf8, 0, 4, out int year) &&
TryDigits(utf8, 5, 2, out int month) &&
TryDigits(utf8, 8, 2, out int day) &&
TryDigits(utf8, 11, 2, out int hour) &&
TryDigits(utf8, 14, 2, out int minute) &&
TryDigits(utf8, 17, 2, out int second))
{
long fractionTicks = 0;
if (utf8.Length > 20 && utf8[19] == (byte)'.')
{
// Consume up to 7 fractional digits (100 ns resolution); ignore the rest.
int i = 20;
int digits = 0;
long value = 0;
while (i < utf8.Length - 1 && utf8[i] >= (byte)'0' && utf8[i] <= (byte)'9')
{
if (digits < 7)
{
value = (value * 10) + (utf8[i] - (byte)'0');
digits++;
}
i++;
}
while (digits < 7)
{
value *= 10;
digits++;
}
fractionTicks = value;
}
try
{
return new DateTime(year, month, day, hour, minute, second, DateTimeKind.Utc)
.AddTicks(fractionTicks);
}
catch (ArgumentOutOfRangeException)
{
return DateTime.MinValue;
}
}
}
return SlowParse(utf8);
}
public static DateTime ParseUtc(string? text)
{
if (string.IsNullOrEmpty(text))
{
return DateTime.MinValue;
}
return DateTime.TryParse(
text,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out DateTime dt)
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
: DateTime.MinValue;
}
private static DateTime SlowParse(ReadOnlySpan<byte> utf8)
{
Span<char> chars = utf8.Length <= 64 ? stackalloc char[utf8.Length] : new char[utf8.Length];
for (int i = 0; i < utf8.Length; i++)
{
chars[i] = (char)utf8[i];
}
return DateTime.TryParse(
chars,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out DateTime dt)
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
: DateTime.MinValue;
}
private static bool TryDigits(ReadOnlySpan<byte> utf8, int offset, int count, out int value)
{
value = 0;
for (int i = offset; i < offset + count; i++)
{
byte b = utf8[i];
if (b < (byte)'0' || b > (byte)'9')
{
return false;
}
value = (value * 10) + (b - (byte)'0');
}
return true;
}
}
@@ -1,197 +0,0 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Encelado.Alpaca.Internal;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Rest;
/// <summary>
/// Historical and latest-snapshot market data. Used to warm indicators up before the
/// live stream takes over, and by the replay/backtest mode.
/// </summary>
public sealed class AlpacaDataClient(AlpacaOptions options) : IDisposable
{
private readonly AlpacaHttp _http = new(options.Validate(), options.DataBaseUrl);
private readonly string _feed = options.DataFeed;
/// <summary>Alpaca caps a single bars page at 10 000 rows.</summary>
private const int PageLimit = 10_000;
public Task WarmupAsync(CancellationToken ct) =>
_http.WarmupAsync("v2/stocks/bars?symbols=SPY&timeframe=1Day&limit=1", ct);
/// <summary>
/// Fetches historical bars for one or more symbols in chronological order,
/// following pagination across the whole requested range.
/// <para>
/// <paramref name="maxBarsPerSymbol"/> keeps the <b>most recent</b> N bars, which is
/// what indicator warm-up needs — trimming while paging would keep the oldest ones
/// and leave the strategies primed with stale state.
/// </para>
/// </summary>
public async Task<Dictionary<string, List<Bar>>> GetBarsAsync(
IReadOnlyList<string> symbols,
TimeFrame timeFrame,
DateTime startUtc,
DateTime? endUtc,
AssetClass assetClass,
int maxBarsPerSymbol,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(symbols);
Dictionary<string, List<Bar>> result = new(StringComparer.OrdinalIgnoreCase);
if (symbols.Count == 0 || maxBarsPerSymbol <= 0)
{
return result;
}
string basePath = assetClass == AssetClass.Crypto
? "v1beta3/crypto/us/bars"
: "v2/stocks/bars";
string? pageToken = null;
int guard = 0;
do
{
StringBuilder path = new(256);
path.Append(basePath)
.Append("?symbols=").Append(Uri.EscapeDataString(string.Join(',', symbols)))
.Append("&timeframe=").Append(timeFrame.ToAlpaca())
.Append("&limit=").Append(PageLimit)
.Append("&sort=asc")
.Append("&start=").Append(Uri.EscapeDataString(FormatInstant(startUtc)));
if (endUtc is { } end)
{
path.Append("&end=").Append(Uri.EscapeDataString(FormatInstant(end)));
}
if (assetClass != AssetClass.Crypto)
{
path.Append("&adjustment=raw&feed=").Append(_feed == "test" ? "iex" : _feed);
}
if (pageToken is not null)
{
path.Append("&page_token=").Append(Uri.EscapeDataString(pageToken));
}
using JsonDocument doc = await _http.GetAsync(path.ToString(), ct).ConfigureAwait(false);
JsonElement root = doc.RootElement;
if (root.TryGetProperty("bars", out JsonElement barsBySymbol) &&
barsBySymbol.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty symbolBars in barsBySymbol.EnumerateObject())
{
if (!result.TryGetValue(symbolBars.Name, out List<Bar>? list))
{
list = new List<Bar>(Math.Min(maxBarsPerSymbol, 1024));
result[symbolBars.Name] = list;
}
foreach (JsonElement b in symbolBars.Value.EnumerateArray())
{
list.Add(ParseBar(b));
}
}
}
pageToken = root.TryGetProperty("next_page_token", out JsonElement token) &&
token.ValueKind == JsonValueKind.String
? token.GetString()
: null;
}
while (pageToken is not null && ++guard < 500);
// Keep only the newest slice, preserving chronological order.
foreach (string key in result.Keys)
{
List<Bar> bars = result[key];
if (bars.Count > maxBarsPerSymbol)
{
result[key] = bars.GetRange(bars.Count - maxBarsPerSymbol, maxBarsPerSymbol);
}
}
return result;
}
public async Task<Dictionary<string, Quote>> GetLatestQuotesAsync(
IReadOnlyList<string> symbols,
AssetClass assetClass,
CancellationToken ct)
{
Dictionary<string, Quote> result = new(StringComparer.OrdinalIgnoreCase);
if (symbols.Count == 0)
{
return result;
}
string path = assetClass == AssetClass.Crypto
? $"v1beta3/crypto/us/latest/quotes?symbols={Uri.EscapeDataString(string.Join(',', symbols))}"
: $"v2/stocks/quotes/latest?symbols={Uri.EscapeDataString(string.Join(',', symbols))}&feed={(_feed == "test" ? "iex" : _feed)}";
using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false);
if (doc.RootElement.TryGetProperty("quotes", out JsonElement quotes) &&
quotes.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty p in quotes.EnumerateObject())
{
result[p.Name] = new Quote(
p.Value.Timestamp("t"),
p.Value.Double("bp"),
p.Value.Double("bs"),
p.Value.Double("ap"),
p.Value.Double("as"));
}
}
return result;
}
public async Task<Dictionary<string, Tick>> GetLatestTradesAsync(
IReadOnlyList<string> symbols,
AssetClass assetClass,
CancellationToken ct)
{
Dictionary<string, Tick> result = new(StringComparer.OrdinalIgnoreCase);
if (symbols.Count == 0)
{
return result;
}
string path = assetClass == AssetClass.Crypto
? $"v1beta3/crypto/us/latest/trades?symbols={Uri.EscapeDataString(string.Join(',', symbols))}"
: $"v2/stocks/trades/latest?symbols={Uri.EscapeDataString(string.Join(',', symbols))}&feed={(_feed == "test" ? "iex" : _feed)}";
using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false);
if (doc.RootElement.TryGetProperty("trades", out JsonElement trades) &&
trades.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty p in trades.EnumerateObject())
{
result[p.Name] = new Tick(p.Value.Timestamp("t"), p.Value.Double("p"), p.Value.Double("s"));
}
}
return result;
}
internal static Bar ParseBar(JsonElement e) => new(
e.Timestamp("t"),
e.Double("o"),
e.Double("h"),
e.Double("l"),
e.Double("c"),
e.Double("v"),
e.Double("vw"),
e.Int32("n"));
private static string FormatInstant(DateTime utc) =>
DateTime.SpecifyKind(utc, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
public void Dispose() => _http.Dispose();
}
@@ -1,225 +0,0 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Encelado.Alpaca.Rest;
/// <summary>
/// Shared HTTP transport for the Alpaca REST APIs: one pooled, pre-warmed HTTP/2
/// connection per host, a client-side rate limiter that keeps us under Alpaca's
/// 200 req/min, and bounded retries for transient failures.
/// </summary>
public sealed class AlpacaHttp : IDisposable
{
private readonly HttpClient _http;
private readonly MinuteRateLimiter _limiter;
private readonly int _maxRetries;
public AlpacaHttp(AlpacaOptions options, string baseUrl)
{
ArgumentNullException.ThrowIfNull(options);
SocketsHttpHandler handler = new()
{
// Long-lived pooled connections: TLS handshakes are the single biggest
// source of order latency, so we never want to pay one on the hot path.
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),
MaxConnectionsPerServer = 16,
EnableMultipleHttp2Connections = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
ConnectTimeout = TimeSpan.FromSeconds(10),
KeepAlivePingDelay = TimeSpan.FromSeconds(30),
KeepAlivePingTimeout = TimeSpan.FromSeconds(10),
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests,
};
_http = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
Timeout = options.HttpTimeout,
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};
_http.DefaultRequestHeaders.Add("APCA-API-KEY-ID", options.KeyId);
_http.DefaultRequestHeaders.Add("APCA-API-SECRET-KEY", options.SecretKey);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Encelado/2.0");
_limiter = new MinuteRateLimiter(options.RequestsPerMinute);
_maxRetries = Math.Max(0, options.MaxRetries);
}
/// <summary>
/// Opens the TLS connection ahead of the first real request so the first order
/// does not pay for the handshake.
/// </summary>
public async Task WarmupAsync(string probePath, CancellationToken ct)
{
try
{
using JsonDocument _ = await GetAsync(probePath, ct).ConfigureAwait(false);
}
catch (AlpacaApiException)
{
// A 4xx still means the socket is up, which is all warm-up needs.
}
}
public Task<JsonDocument> GetAsync(string path, CancellationToken ct) =>
SendAsync(HttpMethod.Get, path, null, ct);
public Task<JsonDocument> PostAsync(string path, ReadOnlyMemory<byte> json, CancellationToken ct) =>
SendAsync(HttpMethod.Post, path, json, ct);
public Task<JsonDocument> PatchAsync(string path, ReadOnlyMemory<byte> json, CancellationToken ct) =>
SendAsync(HttpMethod.Patch, path, json, ct);
public Task<JsonDocument> DeleteAsync(string path, CancellationToken ct) =>
SendAsync(HttpMethod.Delete, path, null, ct);
/// <summary>Like <see cref="GetAsync"/> but maps HTTP 404 to <see langword="null"/>.</summary>
public async Task<JsonDocument?> GetOrNullAsync(string path, CancellationToken ct)
{
try
{
return await GetAsync(path, ct).ConfigureAwait(false);
}
catch (AlpacaApiException ex) when (ex.StatusCode == 404)
{
return null;
}
}
private async Task<JsonDocument> SendAsync(
HttpMethod method,
string path,
ReadOnlyMemory<byte>? body,
CancellationToken ct)
{
AlpacaApiException? last = null;
for (int attempt = 0; attempt <= _maxRetries; attempt++)
{
await _limiter.WaitAsync(ct).ConfigureAwait(false);
using HttpRequestMessage request = new(method, path);
if (body is { } payload)
{
request.Content = new ReadOnlyMemoryContent(payload);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
HttpResponseMessage? response = null;
try
{
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct)
.ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
await using Stream stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NoContent || response.Content.Headers.ContentLength == 0)
{
return JsonDocument.Parse("{}"u8.ToArray());
}
return await JsonDocument.ParseAsync(stream, default, ct).ConfigureAwait(false);
}
string errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
last = new AlpacaApiException(
$"{method} {path} -> {(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(errorBody)}",
(int)response.StatusCode,
errorBody);
if (!last.IsTransient || attempt == _maxRetries)
{
throw last;
}
await BackoffAsync(attempt, response.Headers.RetryAfter, ct).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (attempt < _maxRetries)
{
last = new AlpacaApiException($"{method} {path} -> transport failure: {ex.Message}");
await BackoffAsync(attempt, null, ct).ConfigureAwait(false);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < _maxRetries)
{
last = new AlpacaApiException($"{method} {path} -> timed out after {_http.Timeout.TotalSeconds:F0}s");
await BackoffAsync(attempt, null, ct).ConfigureAwait(false);
}
finally
{
response?.Dispose();
}
}
throw last ?? new AlpacaApiException($"{method} {path} failed without a response.");
}
private static async Task BackoffAsync(int attempt, RetryConditionHeaderValue? retryAfter, CancellationToken ct)
{
TimeSpan delay;
if (retryAfter?.Delta is { } delta && delta > TimeSpan.Zero)
{
delay = delta;
}
else
{
double baseMs = 200 * Math.Pow(2, attempt);
delay = TimeSpan.FromMilliseconds(baseMs + Random.Shared.Next(0, 150));
}
await Task.Delay(delay > TimeSpan.FromSeconds(30) ? TimeSpan.FromSeconds(30) : delay, ct)
.ConfigureAwait(false);
}
private static string Truncate(string s) => s.Length <= 400 ? s : s[..400] + "…";
public void Dispose() => _http.Dispose();
}
/// <summary>
/// Sliding-window limiter: remembers when each of the last N requests went out and
/// blocks until the oldest falls out of the 60 second window.
/// </summary>
internal sealed class MinuteRateLimiter(int permitsPerMinute)
{
private static readonly long WindowTicks = Stopwatch.Frequency * 60;
private readonly long[] _sentAt = new long[Math.Max(1, permitsPerMinute)];
private readonly SemaphoreSlim _gate = new(1, 1);
private int _index;
public async ValueTask WaitAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
long now = Stopwatch.GetTimestamp();
long oldest = _sentAt[_index];
if (oldest != 0)
{
long elapsed = now - oldest;
if (elapsed < WindowTicks)
{
double waitSeconds = (WindowTicks - elapsed) / (double)Stopwatch.Frequency;
await Task.Delay(TimeSpan.FromSeconds(waitSeconds), ct).ConfigureAwait(false);
now = Stopwatch.GetTimestamp();
}
}
_sentAt[_index] = now;
_index = _index + 1 == _sentAt.Length ? 0 : _index + 1;
}
finally
{
_gate.Release();
}
}
}
@@ -1,350 +0,0 @@
using System.Text.Json;
using Encelado.Alpaca.Internal;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Rest;
public enum OrderStatus : byte
{
Unknown = 0,
New,
PendingNew,
Accepted,
AcceptedForBidding,
PartiallyFilled,
Filled,
DoneForDay,
Canceled,
PendingCancel,
Expired,
Replaced,
PendingReplace,
Rejected,
Suspended,
Stopped,
Calculated,
Held,
}
public static class OrderStatusParser
{
public static OrderStatus Parse(string? s) => s switch
{
"new" => OrderStatus.New,
"pending_new" => OrderStatus.PendingNew,
"accepted" => OrderStatus.Accepted,
"accepted_for_bidding" => OrderStatus.AcceptedForBidding,
"partially_filled" => OrderStatus.PartiallyFilled,
"filled" => OrderStatus.Filled,
"done_for_day" => OrderStatus.DoneForDay,
"canceled" => OrderStatus.Canceled,
"pending_cancel" => OrderStatus.PendingCancel,
"expired" => OrderStatus.Expired,
"replaced" => OrderStatus.Replaced,
"pending_replace" => OrderStatus.PendingReplace,
"rejected" => OrderStatus.Rejected,
"suspended" => OrderStatus.Suspended,
"stopped" => OrderStatus.Stopped,
"calculated" => OrderStatus.Calculated,
"held" => OrderStatus.Held,
_ => OrderStatus.Unknown,
};
/// <summary>True once the order can no longer change state.</summary>
public static bool IsTerminal(this OrderStatus s) =>
s is OrderStatus.Filled or OrderStatus.Canceled or OrderStatus.Expired
or OrderStatus.Rejected or OrderStatus.Replaced or OrderStatus.DoneForDay;
public static bool IsWorking(this OrderStatus s) =>
s is OrderStatus.New or OrderStatus.PendingNew or OrderStatus.Accepted
or OrderStatus.AcceptedForBidding or OrderStatus.PartiallyFilled
or OrderStatus.PendingCancel or OrderStatus.PendingReplace or OrderStatus.Held;
}
public sealed record AlpacaAccount(
string Id,
string AccountNumber,
string Status,
string Currency,
decimal Cash,
decimal Equity,
decimal LastEquity,
decimal BuyingPower,
decimal DaytradingBuyingPower,
decimal PortfolioValue,
decimal Multiplier,
int DaytradeCount,
bool PatternDayTrader,
bool TradingBlocked,
bool AccountBlocked,
bool TransfersBlocked,
bool TradeSuspendedByUser,
bool ShortingEnabled)
{
/// <summary>True when the broker will refuse new orders for any reason.</summary>
public bool CanTrade => !TradingBlocked && !AccountBlocked && !TradeSuspendedByUser &&
string.Equals(Status, "ACTIVE", StringComparison.OrdinalIgnoreCase);
public static AlpacaAccount FromJson(JsonElement e) => new(
e.StringOrEmpty("id"),
e.StringOrEmpty("account_number"),
e.StringOrEmpty("status"),
e.StringOrEmpty("currency"),
e.Decimal("cash"),
e.Decimal("equity"),
e.Decimal("last_equity"),
e.Decimal("buying_power"),
e.Decimal("daytrading_buying_power"),
e.Decimal("portfolio_value"),
e.Decimal("multiplier", 1),
e.Int32("daytrade_count"),
e.Bool("pattern_day_trader"),
e.Bool("trading_blocked"),
e.Bool("account_blocked"),
e.Bool("transfers_blocked"),
e.Bool("trade_suspended_by_user"),
e.Bool("shorting_enabled"));
}
public sealed record AlpacaPosition(
string Symbol,
string AssetClass,
double Quantity,
double AverageEntryPrice,
double CurrentPrice,
double MarketValue,
double UnrealizedPnl,
double UnrealizedPnlPct)
{
public static AlpacaPosition FromJson(JsonElement e)
{
double qty = e.Double("qty");
// Alpaca reports short positions with a negative qty already, but be explicit.
if (string.Equals(e.StringOrNull("side"), "short", StringComparison.OrdinalIgnoreCase) && qty > 0)
{
qty = -qty;
}
return new AlpacaPosition(
e.StringOrEmpty("symbol"),
e.StringOrEmpty("asset_class"),
qty,
e.Double("avg_entry_price"),
e.Double("current_price"),
e.Double("market_value"),
e.Double("unrealized_pl"),
e.Double("unrealized_plpc"));
}
}
public sealed record AlpacaOrder(
string Id,
string ClientOrderId,
string Symbol,
Side Side,
string Type,
string OrderClass,
OrderStatus Status,
double Quantity,
double FilledQuantity,
double FilledAveragePrice,
double LimitPrice,
double StopPrice,
DateTime SubmittedAtUtc,
DateTime? FilledAtUtc,
IReadOnlyList<AlpacaOrder> Legs)
{
private static readonly AlpacaOrder[] NoLegs = [];
public bool IsWorking => Status.IsWorking();
public static AlpacaOrder FromJson(JsonElement e)
{
AlpacaOrder[] legs = NoLegs;
if (e.TryGetProperty("legs", out JsonElement legsElement) && legsElement.ValueKind == JsonValueKind.Array)
{
int n = legsElement.GetArrayLength();
if (n > 0)
{
legs = new AlpacaOrder[n];
int i = 0;
foreach (JsonElement leg in legsElement.EnumerateArray())
{
legs[i++] = FromJson(leg);
}
}
}
return new AlpacaOrder(
e.StringOrEmpty("id"),
e.StringOrEmpty("client_order_id"),
e.StringOrEmpty("symbol"),
string.Equals(e.StringOrNull("side"), "sell", StringComparison.OrdinalIgnoreCase) ? Side.Sell : Side.Buy,
e.StringOrEmpty("type"),
e.StringOrEmpty("order_class"),
OrderStatusParser.Parse(e.StringOrNull("status")),
e.Double("qty"),
e.Double("filled_qty"),
e.Double("filled_avg_price"),
e.Double("limit_price", double.NaN),
e.Double("stop_price", double.NaN),
e.Timestamp("submitted_at"),
e.TimestampOrNull("filled_at"),
legs);
}
}
public sealed record AlpacaClock(
DateTime TimestampUtc,
bool IsOpen,
DateTime NextOpenUtc,
DateTime NextCloseUtc)
{
public static AlpacaClock FromJson(JsonElement e) => new(
e.Timestamp("timestamp"),
e.Bool("is_open"),
e.Timestamp("next_open"),
e.Timestamp("next_close"));
}
public sealed record AlpacaAsset(
string Symbol,
string Name,
string Exchange,
string Class,
string Status,
bool Tradable,
bool Marginable,
bool Shortable,
bool EasyToBorrow,
bool Fractionable,
double MinOrderSize,
double MinTradeIncrement,
double PriceIncrement)
{
public bool IsActive => Tradable && string.Equals(Status, "active", StringComparison.OrdinalIgnoreCase);
public static AlpacaAsset FromJson(JsonElement e) => new(
e.StringOrEmpty("symbol"),
e.StringOrEmpty("name"),
e.StringOrEmpty("exchange"),
e.StringOrEmpty("class"),
e.StringOrEmpty("status"),
e.Bool("tradable"),
e.Bool("marginable"),
e.Bool("shortable"),
e.Bool("easy_to_borrow"),
e.Bool("fractionable"),
e.Double("min_order_size"),
e.Double("min_trade_increment"),
e.Double("price_increment"));
}
/// <summary>
/// Account equity over time, straight from Alpaca. <see cref="BaseValue"/> is the
/// equity at the start of the requested window, so lifetime P&amp;L is
/// <c>Equity[^1] - BaseValue</c> — with the usual caveat that deposits and withdrawals
/// move equity without being profit.
/// </summary>
public sealed record AlpacaPortfolioHistory(
IReadOnlyList<long> TimestampsUnix,
IReadOnlyList<double> Equity,
IReadOnlyList<double> ProfitLoss,
double BaseValue,
string Timeframe)
{
public static readonly AlpacaPortfolioHistory Empty =
new([], [], [], 0, string.Empty);
public bool HasData => Equity.Count > 0;
public double LastEquity => Equity.Count > 0 ? Equity[^1] : 0;
/// <summary>Change over the whole window in absolute terms.</summary>
public double TotalProfitLoss => HasData && BaseValue > 0 ? LastEquity - BaseValue : 0;
public double TotalProfitLossPct => BaseValue > 0 ? TotalProfitLoss / BaseValue : 0;
public static AlpacaPortfolioHistory FromJson(JsonElement e)
{
return new AlpacaPortfolioHistory(
ReadLongs(e, "timestamp"),
ReadDoubles(e, "equity"),
ReadDoubles(e, "profit_loss"),
e.Double("base_value"),
e.StringOrEmpty("timeframe"));
static double[] ReadDoubles(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out JsonElement array) || array.ValueKind != JsonValueKind.Array)
{
return [];
}
double[] values = new double[array.GetArrayLength()];
int i = 0;
foreach (JsonElement item in array.EnumerateArray())
{
values[i++] = item.ValueKind == JsonValueKind.Number ? item.GetDouble() : 0;
}
return values;
}
static long[] ReadLongs(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out JsonElement array) || array.ValueKind != JsonValueKind.Array)
{
return [];
}
long[] values = new long[array.GetArrayLength()];
int i = 0;
foreach (JsonElement item in array.EnumerateArray())
{
values[i++] = item.ValueKind == JsonValueKind.Number ? item.GetInt64() : 0;
}
return values;
}
}
}
/// <summary>An order about to be submitted. Built by the execution router, never by strategies.</summary>
public sealed record NewOrder
{
public required string Symbol { get; init; }
public required Side Side { get; init; }
public required double Quantity { get; init; }
public OrderType Type { get; init; } = OrderType.Market;
public TimeInForce TimeInForce { get; init; } = TimeInForce.Day;
public double LimitPrice { get; init; } = double.NaN;
public double StopPrice { get; init; } = double.NaN;
/// <summary>Idempotency key. Alpaca rejects duplicates, which is exactly what we want on a retry.</summary>
public string? ClientOrderId { get; init; }
public bool ExtendedHours { get; init; }
/// <summary>Attached protective stop. Turns the order into a bracket/OTO order.</summary>
public double TakeProfitLimitPrice { get; init; } = double.NaN;
public double StopLossStopPrice { get; init; } = double.NaN;
public double StopLossLimitPrice { get; init; } = double.NaN;
public bool HasBracket => !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice);
/// <summary>Alpaca's <c>order_class</c> implied by the attached legs.</summary>
public string OrderClass =>
!double.IsNaN(TakeProfitLimitPrice) && !double.IsNaN(StopLossStopPrice) ? "bracket"
: !double.IsNaN(TakeProfitLimitPrice) || !double.IsNaN(StopLossStopPrice) ? "oto"
: "simple";
}
@@ -1,307 +0,0 @@
using System.Buffers;
using System.Globalization;
using System.Text.Json;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Rest;
/// <summary>
/// Typed wrapper over Alpaca's trading REST API (<c>/v2/account</c>, <c>/v2/orders</c>,
/// <c>/v2/positions</c>, …). Request bodies are written straight to UTF-8 with
/// <see cref="Utf8JsonWriter"/> — no serializer, no reflection, no per-order allocation
/// beyond a pooled buffer.
/// </summary>
public sealed class AlpacaTradingClient(AlpacaOptions options) : IDisposable
{
private readonly AlpacaHttp _http = new(options.Validate(), options.TradingBaseUrl);
public string BaseUrl { get; } = options.TradingBaseUrl;
public bool IsPaper { get; } = options.Paper;
/// <summary>Opens the TLS/HTTP2 connection before the session starts.</summary>
public Task WarmupAsync(CancellationToken ct) => _http.WarmupAsync("v2/clock", ct);
public async Task<AlpacaAccount> GetAccountAsync(CancellationToken ct)
{
using JsonDocument doc = await _http.GetAsync("v2/account", ct).ConfigureAwait(false);
return AlpacaAccount.FromJson(doc.RootElement);
}
public async Task<AlpacaClock> GetClockAsync(CancellationToken ct)
{
using JsonDocument doc = await _http.GetAsync("v2/clock", ct).ConfigureAwait(false);
return AlpacaClock.FromJson(doc.RootElement);
}
/// <summary>
/// Equity curve for the account. <paramref name="period"/> uses Alpaca's notation
/// (<c>1D</c>, <c>1M</c>, <c>1A</c>, <c>all</c>) and <paramref name="timeframe"/> the
/// bucket size (<c>1Min</c>, <c>15Min</c>, <c>1H</c>, <c>1D</c>).
/// </summary>
public async Task<AlpacaPortfolioHistory> GetPortfolioHistoryAsync(
string period,
string timeframe,
CancellationToken ct)
{
string path = $"v2/account/portfolio/history?period={Uri.EscapeDataString(period)}" +
$"&timeframe={Uri.EscapeDataString(timeframe)}&intraday_reporting=continuous";
try
{
using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false);
return AlpacaPortfolioHistory.FromJson(doc.RootElement);
}
catch (AlpacaApiException)
{
// History is a nice-to-have for the dashboard, never a reason to stop trading.
return AlpacaPortfolioHistory.Empty;
}
}
public async Task<AlpacaAsset?> GetAssetAsync(string symbol, CancellationToken ct)
{
using JsonDocument? doc = await _http.GetOrNullAsync($"v2/assets/{Uri.EscapeDataString(symbol)}", ct)
.ConfigureAwait(false);
return doc is null ? null : AlpacaAsset.FromJson(doc.RootElement);
}
public async Task<List<AlpacaPosition>> ListPositionsAsync(CancellationToken ct)
{
using JsonDocument doc = await _http.GetAsync("v2/positions", ct).ConfigureAwait(false);
List<AlpacaPosition> positions = [];
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement e in doc.RootElement.EnumerateArray())
{
positions.Add(AlpacaPosition.FromJson(e));
}
}
return positions;
}
public async Task<AlpacaPosition?> GetPositionAsync(string symbol, CancellationToken ct)
{
using JsonDocument? doc = await _http.GetOrNullAsync($"v2/positions/{Uri.EscapeDataString(symbol)}", ct)
.ConfigureAwait(false);
return doc is null ? null : AlpacaPosition.FromJson(doc.RootElement);
}
/// <summary>Liquidates a position at market. Alpaca cancels the open legs for us.</summary>
public async Task<AlpacaOrder?> ClosePositionAsync(string symbol, double? quantity, CancellationToken ct)
{
string path = $"v2/positions/{Uri.EscapeDataString(symbol)}";
if (quantity is > 0)
{
path += $"?qty={FormatQuantity(quantity.Value)}";
}
try
{
using JsonDocument doc = await _http.DeleteAsync(path, ct).ConfigureAwait(false);
return doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("id", out _)
? AlpacaOrder.FromJson(doc.RootElement)
: null;
}
catch (AlpacaApiException ex) when (ex.StatusCode == 404)
{
// Already flat: treat as success so the caller's exit path is idempotent.
return null;
}
}
public async Task CloseAllPositionsAsync(bool cancelOrders, CancellationToken ct)
{
using JsonDocument _ = await _http
.DeleteAsync($"v2/positions?cancel_orders={(cancelOrders ? "true" : "false")}", ct)
.ConfigureAwait(false);
}
public async Task<List<AlpacaOrder>> ListOrdersAsync(
string status,
int limit,
string? symbols,
CancellationToken ct)
{
string path = $"v2/orders?status={status}&limit={limit}&nested=true";
if (!string.IsNullOrWhiteSpace(symbols))
{
path += $"&symbols={Uri.EscapeDataString(symbols)}";
}
using JsonDocument doc = await _http.GetAsync(path, ct).ConfigureAwait(false);
List<AlpacaOrder> orders = [];
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement e in doc.RootElement.EnumerateArray())
{
orders.Add(AlpacaOrder.FromJson(e));
}
}
return orders;
}
public Task<List<AlpacaOrder>> ListOpenOrdersAsync(CancellationToken ct) =>
ListOrdersAsync("open", 500, null, ct);
public async Task<AlpacaOrder> SubmitOrderAsync(NewOrder order, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(order);
byte[] body = WriteOrderJson(order);
using JsonDocument doc = await _http.PostAsync("v2/orders", body, ct).ConfigureAwait(false);
return AlpacaOrder.FromJson(doc.RootElement);
}
/// <summary>Moves an open order's stop/limit — used to trail protective stops.</summary>
public async Task<AlpacaOrder> ReplaceOrderAsync(
string orderId,
double? quantity,
double? limitPrice,
double? stopPrice,
string? clientOrderId,
CancellationToken ct)
{
ArrayBufferWriter<byte> buffer = new(192);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
if (quantity is > 0)
{
w.WriteString("qty", FormatQuantity(quantity.Value));
}
if (limitPrice is > 0)
{
w.WriteString("limit_price", FormatPrice(limitPrice.Value));
}
if (stopPrice is > 0)
{
w.WriteString("stop_price", FormatPrice(stopPrice.Value));
}
if (!string.IsNullOrEmpty(clientOrderId))
{
w.WriteString("client_order_id", clientOrderId);
}
w.WriteEndObject();
}
using JsonDocument doc = await _http
.PatchAsync($"v2/orders/{Uri.EscapeDataString(orderId)}", buffer.WrittenMemory, ct)
.ConfigureAwait(false);
return AlpacaOrder.FromJson(doc.RootElement);
}
public async Task<bool> CancelOrderAsync(string orderId, CancellationToken ct)
{
try
{
using JsonDocument _ = await _http
.DeleteAsync($"v2/orders/{Uri.EscapeDataString(orderId)}", ct)
.ConfigureAwait(false);
return true;
}
catch (AlpacaApiException ex) when (ex.StatusCode is 404 or 422)
{
// 404 = gone, 422 = already in a terminal state. Both mean "not working any more".
return false;
}
}
public async Task CancelAllOrdersAsync(CancellationToken ct)
{
using JsonDocument _ = await _http.DeleteAsync("v2/orders", ct).ConfigureAwait(false);
}
/// <summary>Serialises an order to Alpaca's wire format. Public so it can be asserted on in tests.</summary>
public static byte[] WriteOrderJson(NewOrder order)
{
ArrayBufferWriter<byte> buffer = new(384);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("symbol", order.Symbol);
w.WriteString("qty", FormatQuantity(order.Quantity));
w.WriteString("side", order.Side.ToAlpaca());
w.WriteString("type", order.Type.ToAlpaca());
w.WriteString("time_in_force", order.TimeInForce.ToAlpaca());
if (order.Type is OrderType.Limit or OrderType.StopLimit && !double.IsNaN(order.LimitPrice))
{
w.WriteString("limit_price", FormatPrice(order.LimitPrice));
}
if (order.Type is OrderType.Stop or OrderType.StopLimit && !double.IsNaN(order.StopPrice))
{
w.WriteString("stop_price", FormatPrice(order.StopPrice));
}
if (!string.IsNullOrEmpty(order.ClientOrderId))
{
w.WriteString("client_order_id", order.ClientOrderId);
}
if (order.ExtendedHours)
{
w.WriteBoolean("extended_hours", true);
}
if (order.HasBracket)
{
w.WriteString("order_class", order.OrderClass);
if (!double.IsNaN(order.TakeProfitLimitPrice))
{
w.WriteStartObject("take_profit");
w.WriteString("limit_price", FormatPrice(order.TakeProfitLimitPrice));
w.WriteEndObject();
}
if (!double.IsNaN(order.StopLossStopPrice))
{
w.WriteStartObject("stop_loss");
w.WriteString("stop_price", FormatPrice(order.StopLossStopPrice));
if (!double.IsNaN(order.StopLossLimitPrice))
{
w.WriteString("limit_price", FormatPrice(order.StopLossLimitPrice));
}
w.WriteEndObject();
}
}
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
/// <summary>
/// Alpaca rejects prices that are not a valid sub-penny increment: two decimals at
/// or above $1, four decimals below it.
/// </summary>
public static string FormatPrice(double price)
{
double rounded = price >= 1.0
? Math.Round(price, 2, MidpointRounding.AwayFromZero)
: Math.Round(price, 4, MidpointRounding.AwayFromZero);
return rounded.ToString(price >= 1.0 ? "0.##" : "0.####", CultureInfo.InvariantCulture);
}
/// <summary>Whole shares stay integral; fractional sizes get at most 9 decimals.</summary>
public static string FormatQuantity(double quantity)
{
double abs = Math.Abs(quantity);
return abs == Math.Floor(abs)
? abs.ToString("0", CultureInfo.InvariantCulture)
: Math.Round(abs, 9, MidpointRounding.ToZero).ToString("0.#########", CultureInfo.InvariantCulture);
}
public void Dispose() => _http.Dispose();
}
@@ -1,486 +0,0 @@
using System.Buffers;
using System.Globalization;
using System.Text.Json;
using Encelado.Alpaca.Internal;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Streaming;
/// <summary>Which side crossed the spread on a print. Unknown when the feed omits it.</summary>
public enum Aggressor : byte
{
Unknown = 0,
Buy = 1,
Sell = 2,
}
public delegate void TickHandler(int symbolId, string symbol, in Tick tick, Aggressor aggressor);
public delegate void QuoteHandler(int symbolId, string symbol, in Quote quote);
public delegate void BarHandler(int symbolId, string symbol, in Bar bar);
/// <summary>
/// Alpaca's real-time market data socket. Frames are decoded straight out of the
/// receive buffer with <see cref="Utf8JsonReader"/> and symbols are resolved through
/// a <see cref="SymbolTable"/>, so a live tape produces no garbage per tick.
/// </summary>
public sealed class MarketDataStream : WebSocketChannel
{
private enum MsgKind : byte
{
Unknown = 0,
Trade,
Quote,
Bar,
UpdatedBar,
DailyBar,
Status,
Success,
Error,
Subscription,
}
private readonly byte[] _authPayload;
private readonly byte[] _subscribePayload;
private readonly SymbolTable _symbols;
private CancellationToken _channelToken;
public MarketDataStream(
AlpacaOptions options,
IReadOnlyList<string> symbols,
AssetClass assetClass,
bool subscribeTrades = true,
bool subscribeQuotes = true,
bool subscribeBars = true)
: base(options.MarketDataStreamUri(assetClass), $"data:{(assetClass == AssetClass.Crypto ? "crypto" : options.DataFeed)}")
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(symbols);
_symbols = new SymbolTable(symbols);
AssetClass = assetClass;
_authPayload = BuildAuth(options.KeyId, options.SecretKey);
_subscribePayload = BuildSubscribe(symbols, subscribeTrades, subscribeQuotes, subscribeBars);
}
public AssetClass AssetClass { get; }
public SymbolTable Symbols => _symbols;
/// <summary>Fired for every print on the tape.</summary>
public TickHandler? OnTrade { get; set; }
/// <summary>Fired on every top-of-book change.</summary>
public QuoteHandler? OnQuote { get; set; }
/// <summary>Fired when a minute bar closes — the engine's main decision trigger.</summary>
public BarHandler? OnBar { get; set; }
/// <summary>Fired for Alpaca's rolling daily bar.</summary>
public BarHandler? OnDailyBar { get; set; }
public long TradesReceived { get; private set; }
public long QuotesReceived { get; private set; }
public long BarsReceived { get; private set; }
protected override async ValueTask OnOpenAsync(CancellationToken ct)
{
_channelToken = ct;
// Alpaca accepts the auth frame immediately; the "connected" greeting and the
// "authenticated" acknowledgement both arrive on the receive loop.
await SendAsync(_authPayload, ct).ConfigureAwait(false);
}
protected override void OnMessage(ReadOnlySpan<byte> payload, bool isText)
{
if (!isText)
{
Log($"[{Name}] ignoring a binary frame ({payload.Length} bytes); expected JSON.");
return;
}
Utf8JsonReader reader = new(payload, isFinalBlock: true, state: default);
if (!reader.Read())
{
return;
}
if (reader.TokenType == JsonTokenType.StartArray)
{
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType == JsonTokenType.StartObject)
{
DecodeObject(ref reader);
}
else
{
reader.Skip();
}
}
}
else if (reader.TokenType == JsonTokenType.StartObject)
{
DecodeObject(ref reader);
}
}
private void DecodeObject(ref Utf8JsonReader r)
{
MsgKind kind = MsgKind.Unknown;
int symbolId = -1;
double open = 0, high = 0, low = 0, close = 0, volume = 0, vwap = 0;
double price = 0, size = 0, bidPrice = 0, bidSize = 0, askPrice = 0, askSize = 0;
int tradeCount = 0;
DateTime timestamp = default;
string? message = null;
int code = 0;
Aggressor aggressor = Aggressor.Unknown;
while (r.Read() && r.TokenType != JsonTokenType.EndObject)
{
if (r.TokenType != JsonTokenType.PropertyName)
{
r.Skip();
continue;
}
if (r.ValueTextEquals("T"u8))
{
r.Read();
kind = ParseKind(r.ValueSpan);
}
else if (r.ValueTextEquals("S"u8))
{
r.Read();
symbolId = _symbols.Resolve(r.ValueSpan);
}
else if (r.ValueTextEquals("t"u8))
{
r.Read();
timestamp = Rfc3339.ParseUtc(r.ValueSpan);
}
else if (r.ValueTextEquals("p"u8))
{
r.Read();
price = ReadNumber(ref r);
}
else if (r.ValueTextEquals("s"u8))
{
r.Read();
size = ReadNumber(ref r);
}
else if (r.ValueTextEquals("bp"u8))
{
r.Read();
bidPrice = ReadNumber(ref r);
}
else if (r.ValueTextEquals("bs"u8))
{
r.Read();
bidSize = ReadNumber(ref r);
}
else if (r.ValueTextEquals("ap"u8))
{
r.Read();
askPrice = ReadNumber(ref r);
}
else if (r.ValueTextEquals("as"u8))
{
r.Read();
askSize = ReadNumber(ref r);
}
else if (r.ValueTextEquals("o"u8))
{
r.Read();
open = ReadNumber(ref r);
}
else if (r.ValueTextEquals("h"u8))
{
r.Read();
high = ReadNumber(ref r);
}
else if (r.ValueTextEquals("l"u8))
{
r.Read();
low = ReadNumber(ref r);
}
else if (r.ValueTextEquals("c"u8))
{
r.Read();
// On a bar "c" is the close; on a trade/quote it is the condition array.
if (r.TokenType == JsonTokenType.Number)
{
close = r.GetDouble();
}
else
{
r.Skip();
}
}
else if (r.ValueTextEquals("v"u8))
{
r.Read();
volume = ReadNumber(ref r);
}
else if (r.ValueTextEquals("vw"u8))
{
r.Read();
vwap = ReadNumber(ref r);
}
else if (r.ValueTextEquals("n"u8))
{
r.Read();
tradeCount = (int)ReadNumber(ref r);
}
else if (r.ValueTextEquals("tks"u8))
{
r.Read();
// Alpaca's crypto feed reports the taker side as "B" or "S". It is the
// only way to know whether a print lifted an offer or hit a bid, which
// is what the volume delta is built from.
if (r.TokenType == JsonTokenType.String && r.ValueSpan.Length > 0)
{
aggressor = r.ValueSpan[0] switch
{
(byte)'B' or (byte)'b' => Aggressor.Buy,
(byte)'S' or (byte)'s' => Aggressor.Sell,
_ => Aggressor.Unknown,
};
}
}
else if (r.ValueTextEquals("msg"u8))
{
r.Read();
message = r.TokenType == JsonTokenType.String ? r.GetString() : null;
}
else if (r.ValueTextEquals("code"u8))
{
r.Read();
code = (int)ReadNumber(ref r);
}
else
{
r.Read();
r.Skip();
}
}
Dispatch(kind, symbolId, timestamp, message, code,
open, high, low, close, volume, vwap, tradeCount,
price, size, bidPrice, bidSize, askPrice, askSize, aggressor);
}
private void Dispatch(
MsgKind kind, int symbolId, DateTime timestamp, string? message, int code,
double open, double high, double low, double close, double volume, double vwap, int tradeCount,
double price, double size, double bidPrice, double bidSize, double askPrice, double askSize,
Aggressor aggressor)
{
switch (kind)
{
case MsgKind.Trade when symbolId >= 0:
{
TradesReceived++;
Tick tick = new(timestamp, price, size);
OnTrade?.Invoke(symbolId, _symbols.Name(symbolId), in tick, aggressor);
break;
}
case MsgKind.Quote when symbolId >= 0:
{
QuotesReceived++;
Quote quote = new(timestamp, bidPrice, bidSize, askPrice, askSize);
OnQuote?.Invoke(symbolId, _symbols.Name(symbolId), in quote);
break;
}
case MsgKind.Bar when symbolId >= 0:
{
BarsReceived++;
Bar bar = new(timestamp, open, high, low, close, volume, vwap, tradeCount);
OnBar?.Invoke(symbolId, _symbols.Name(symbolId), in bar);
break;
}
case MsgKind.DailyBar when symbolId >= 0:
{
Bar bar = new(timestamp, open, high, low, close, volume, vwap, tradeCount);
OnDailyBar?.Invoke(symbolId, _symbols.Name(symbolId), in bar);
break;
}
case MsgKind.Success:
if (string.Equals(message, "authenticated", StringComparison.Ordinal))
{
Log($"[{Name}] authenticated; subscribing to {_symbols.Count} symbol(s)");
_ = SendSubscribeAsync();
}
else
{
Log($"[{Name}] {message}");
}
break;
case MsgKind.Subscription:
SetState(ChannelState.Live);
Log($"[{Name}] subscription confirmed");
break;
case MsgKind.Error:
OnServerError(code, message);
break;
case MsgKind.Status:
case MsgKind.UpdatedBar:
case MsgKind.Unknown:
default:
break;
}
}
/// <summary>
/// Turns an Alpaca stream error into either a refusal or a note.
/// <para>
/// The distinction is the whole point. Codes in the 400s here mean the server has
/// decided about this session: reconnecting straight away cannot change its mind,
/// and — because an unauthenticated socket keeps the account's single market-data
/// slot busy for ten seconds — trying again quickly is what keeps the refusal true.
/// Treating these as informational is what produced an endless connect / 406 /
/// auth-timeout loop that never recovered on its own.
/// </para>
/// </summary>
private void OnServerError(int code, string? message)
{
string? refusal = code switch
{
406 => "un'altra connessione sta già usando i dati di mercato di questo conto " +
"(Alpaca ne consente una sola). Chiudi l'altra istanza di Encelado, oppure " +
"attendi: una sessione interrotta male viene liberata dal server dopo poco.",
401 or 403 => "credenziali rifiutate dallo stream dati. Controlla le chiavi in " +
"Impostazioni e che siano quelle dell'ambiente giusto (paper o live).",
409 => "abbonamento dati insufficiente per i simboli richiesti.",
_ => null,
};
if (refusal is null)
{
Log($"[{Name}] server error {code}: {message}");
return;
}
Log($"[{Name}] {code}: {refusal}");
Reject(refusal);
}
private async Task SendSubscribeAsync()
{
try
{
await SendAsync(_subscribePayload, _channelToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log($"[{Name}] subscribe failed: {ex.Message}", ex);
}
}
private static double ReadNumber(ref Utf8JsonReader r) => r.TokenType switch
{
JsonTokenType.Number => r.GetDouble(),
JsonTokenType.String => double.TryParse(
r.ValueSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out double d) ? d : 0,
_ => 0,
};
private static MsgKind ParseKind(ReadOnlySpan<byte> value)
{
if (value.Length == 1)
{
return value[0] switch
{
(byte)'t' => MsgKind.Trade,
(byte)'q' => MsgKind.Quote,
(byte)'b' => MsgKind.Bar,
(byte)'u' => MsgKind.UpdatedBar,
(byte)'d' => MsgKind.DailyBar,
(byte)'s' => MsgKind.Status,
_ => MsgKind.Unknown,
};
}
if (value.SequenceEqual("success"u8))
{
return MsgKind.Success;
}
if (value.SequenceEqual("error"u8))
{
return MsgKind.Error;
}
return value.SequenceEqual("subscription"u8) ? MsgKind.Subscription : MsgKind.Unknown;
}
private static byte[] BuildAuth(string key, string secret)
{
ArrayBufferWriter<byte> buffer = new(160);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "auth");
w.WriteString("key", key);
w.WriteString("secret", secret);
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
private static byte[] BuildSubscribe(IReadOnlyList<string> symbols, bool trades, bool quotes, bool bars)
{
ArrayBufferWriter<byte> buffer = new(256);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "subscribe");
if (trades)
{
WriteArray(w, "trades", symbols);
}
if (quotes)
{
WriteArray(w, "quotes", symbols);
}
if (bars)
{
WriteArray(w, "bars", symbols);
}
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
static void WriteArray(Utf8JsonWriter w, string name, IReadOnlyList<string> values)
{
w.WriteStartArray(name);
foreach (string v in values)
{
w.WriteStringValue(v);
}
w.WriteEndArray();
}
}
}
@@ -1,62 +0,0 @@
namespace Encelado.Alpaca.Streaming;
/// <summary>
/// Maps a symbol's UTF-8 bytes to a stable integer id and a single interned string
/// instance. The market-data decoder resolves symbols straight from the receive
/// buffer, so a busy tape does not allocate one string per tick.
/// </summary>
public sealed class SymbolTable
{
private readonly Dictionary<string, int> _ids;
private readonly Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> _lookup;
private readonly List<string> _names = [];
public SymbolTable(IEnumerable<string> symbols)
{
ArgumentNullException.ThrowIfNull(symbols);
_ids = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (string s in symbols)
{
string symbol = s.Trim();
if (symbol.Length > 0 && _ids.TryAdd(symbol, _names.Count))
{
_names.Add(symbol);
}
}
_lookup = _ids.GetAlternateLookup<ReadOnlySpan<char>>();
}
public int Count => _names.Count;
public IReadOnlyList<string> Names => _names;
/// <summary>Resolves a symbol from raw UTF-8. Returns -1 when it is not subscribed.</summary>
public int Resolve(ReadOnlySpan<byte> utf8)
{
// Symbols are short ASCII (crypto pairs like BTC/USD included), so a stack
// buffer covers every real case without touching the heap.
if (utf8.Length is 0 or > 32)
{
return -1;
}
Span<char> chars = stackalloc char[32];
for (int i = 0; i < utf8.Length; i++)
{
byte b = utf8[i];
if (b > 127)
{
return -1;
}
chars[i] = (char)b;
}
return _lookup.TryGetValue(chars[..utf8.Length], out int id) ? id : -1;
}
public int Resolve(string symbol) => _ids.TryGetValue(symbol, out int id) ? id : -1;
public string Name(int id) => (uint)id < (uint)_names.Count ? _names[id] : string.Empty;
}
@@ -1,258 +0,0 @@
using System.Buffers;
using System.Text.Json;
using Encelado.Alpaca.Internal;
using Encelado.Alpaca.Rest;
using Encelado.Core.Market;
namespace Encelado.Alpaca.Streaming;
/// <summary>One order lifecycle event pushed by Alpaca.</summary>
public sealed record TradeUpdate(
string Event,
DateTime TimestampUtc,
string Symbol,
Side Side,
double Price,
double Quantity,
double PositionQuantity,
AlpacaOrder Order)
{
/// <summary>True when shares actually changed hands.</summary>
public bool IsExecution => Event is "fill" or "partial_fill";
public bool IsTerminal => Event is "fill" or "canceled" or "expired" or "rejected" or "done_for_day";
}
/// <summary>
/// Order and position events straight from the broker, so the bot learns about fills
/// in milliseconds instead of polling. The reconciler still sweeps REST periodically:
/// this stream is the fast path, not the source of truth.
/// </summary>
public sealed class TradeUpdateStream : WebSocketChannel
{
private readonly byte[] _authPrimary;
private readonly byte[] _authAlternate;
private readonly byte[] _listenPayload;
private CancellationToken _channelToken;
private volatile bool _authorized;
private bool _warnedBinary;
public TradeUpdateStream(AlpacaOptions options)
: base(options.TradeUpdatesStreamUri, "trade-updates")
{
ArgumentNullException.ThrowIfNull(options);
_authPrimary = BuildEnvelopeAuth(options.KeyId, options.SecretKey);
_authAlternate = BuildFlatAuth(options.KeyId, options.SecretKey);
_listenPayload = BuildListen();
}
/// <summary>Raised for every order lifecycle event. Runs on the receive thread.</summary>
public Action<TradeUpdate>? OnTradeUpdate { get; set; }
public long UpdatesReceived { get; private set; }
protected override async ValueTask OnOpenAsync(CancellationToken ct)
{
_channelToken = ct;
_authorized = false;
// The documented handshake for the trading /stream endpoint.
await SendAsync(_authPrimary, ct).ConfigureAwait(false);
// Alpaca has shipped two auth shapes for this endpoint over the years. If the
// first one is not acknowledged shortly, try the other before giving up.
_ = FallbackAuthAsync();
}
private async Task FallbackAuthAsync()
{
try
{
await Task.Delay(TimeSpan.FromSeconds(3), _channelToken).ConfigureAwait(false);
if (!_authorized)
{
Log($"[{Name}] no auth acknowledgement yet; retrying with the alternate handshake");
await SendAsync(_authAlternate, _channelToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is OperationCanceledException or InvalidOperationException)
{
// Socket closed while we were waiting; the reconnect loop takes over.
}
catch (Exception ex)
{
Log($"[{Name}] fallback auth failed: {ex.Message}", ex);
}
}
protected override void OnMessage(ReadOnlySpan<byte> payload, bool isText)
{
if (!isText)
{
if (!_warnedBinary)
{
_warnedBinary = true;
Log($"[{Name}] received a binary (msgpack) frame; falling back to REST reconciliation for fills.");
}
return;
}
JsonDocument doc;
try
{
Utf8JsonReader reader = new(payload, isFinalBlock: true, state: default);
doc = JsonDocument.ParseValue(ref reader);
}
catch (JsonException ex)
{
Log($"[{Name}] undecodable frame: {ex.Message}");
return;
}
using (doc)
{
JsonElement root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return;
}
string stream = root.StringOrEmpty("stream");
if (!root.TryGetProperty("data", out JsonElement data))
{
return;
}
switch (stream)
{
case "authorization":
HandleAuthorization(data);
break;
case "listening":
SetState(ChannelState.Live);
Log($"[{Name}] listening for trade updates");
break;
case "trade_updates":
HandleTradeUpdate(data);
break;
default:
break;
}
}
}
private void HandleAuthorization(JsonElement data)
{
string status = data.StringOrEmpty("status");
if (string.Equals(status, "authorized", StringComparison.OrdinalIgnoreCase))
{
_authorized = true;
Log($"[{Name}] authorized");
_ = SendListenAsync();
}
else
{
Log($"[{Name}] authorization refused: {status}");
}
}
private void HandleTradeUpdate(JsonElement data)
{
UpdatesReceived++;
AlpacaOrder order = data.TryGetProperty("order", out JsonElement orderElement) &&
orderElement.ValueKind == JsonValueKind.Object
? AlpacaOrder.FromJson(orderElement)
: new AlpacaOrder(string.Empty, string.Empty, string.Empty, Side.Buy, string.Empty, string.Empty,
OrderStatus.Unknown, 0, 0, 0, double.NaN, double.NaN, DateTime.MinValue, null, []);
DateTime timestamp = data.Timestamp("timestamp");
TradeUpdate update = new(
data.StringOrEmpty("event"),
timestamp == DateTime.MinValue ? DateTime.UtcNow : timestamp,
order.Symbol,
order.Side,
data.Double("price", order.FilledAveragePrice),
data.Double("qty"),
data.Double("position_qty"),
order);
try
{
OnTradeUpdate?.Invoke(update);
}
catch (Exception ex)
{
Log($"[{Name}] trade-update handler threw: {ex.Message}", ex);
}
}
private async Task SendListenAsync()
{
try
{
await SendAsync(_listenPayload, _channelToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log($"[{Name}] listen failed: {ex.Message}", ex);
}
}
/// <summary><c>{"action":"authenticate","data":{"key_id":…,"secret_key":…}}</c></summary>
private static byte[] BuildEnvelopeAuth(string key, string secret)
{
ArrayBufferWriter<byte> buffer = new(192);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "authenticate");
w.WriteStartObject("data");
w.WriteString("key_id", key);
w.WriteString("secret_key", secret);
w.WriteEndObject();
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
/// <summary><c>{"action":"auth","key":…,"secret":…}</c></summary>
private static byte[] BuildFlatAuth(string key, string secret)
{
ArrayBufferWriter<byte> buffer = new(160);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "auth");
w.WriteString("key", key);
w.WriteString("secret", secret);
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
private static byte[] BuildListen()
{
ArrayBufferWriter<byte> buffer = new(96);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
w.WriteString("action", "listen");
w.WriteStartObject("data");
w.WriteStartArray("streams");
w.WriteStringValue("trade_updates");
w.WriteEndArray();
w.WriteEndObject();
w.WriteEndObject();
}
return buffer.WrittenSpan.ToArray();
}
}
@@ -1,378 +0,0 @@
using System.Buffers;
using System.Net.WebSockets;
namespace Encelado.Alpaca.Streaming;
public enum ChannelState : byte
{
Disconnected = 0,
Connecting,
Authenticating,
Live,
Faulted,
}
/// <summary>
/// Long-lived WebSocket with authentication, resubscribe-on-reconnect and capped
/// exponential backoff. Subclasses only implement the handshake and the message
/// decoder; the reconnect loop, frame reassembly and buffer pooling live here.
/// </summary>
public abstract class WebSocketChannel(Uri uri, string name) : IAsyncDisposable
{
private const int InitialBufferSize = 64 * 1024;
private const int MaxBufferSize = 8 * 1024 * 1024;
private readonly SemaphoreSlim _sendGate = new(1, 1);
private ClientWebSocket? _socket;
private CancellationTokenSource? _cts;
private Task? _loop;
private int _consecutiveFailures;
private int _rejections;
private volatile string? _rejection;
public string Name { get; } = name;
public Uri Uri { get; } = uri;
public ChannelState State { get; private set; } = ChannelState.Disconnected;
public bool IsLive => State == ChannelState.Live;
/// <summary>Number of times the channel has (re)established a live session.</summary>
public int ConnectCount { get; private set; }
public DateTime LastMessageUtc { get; private set; }
/// <summary>Diagnostics sink. Set by the host so channel events land in the bot log.</summary>
public Action<string, Exception?>? OnLog { get; set; }
/// <summary>Raised whenever the channel transitions to or away from <see cref="ChannelState.Live"/>.</summary>
public Action<bool>? OnLiveChanged { get; set; }
/// <summary>
/// Why the server is refusing this channel, or null when nothing has refused it.
/// Survives across reconnects so the UI can explain a channel that keeps bouncing.
/// </summary>
public string? RejectionReason => _rejection;
/// <summary>
/// Records a server-side refusal that reconnecting cannot fix on its own, and tears
/// the socket down now rather than waiting for the server to time it out.
/// <para>
/// The timing matters more than it looks. Alpaca permits one market-data connection
/// per account and closes an unauthenticated socket after ten seconds; a client that
/// reconnects on a three-second backoff therefore opens the next socket while the
/// refused one is still occupying the only slot, and refuses itself forever. Closing
/// immediately, and backing off past the server's own timeout, is what breaks that.
/// </para>
/// </summary>
protected void Reject(string reason)
{
_rejection = reason;
Interlocked.Increment(ref _rejections);
// Aborting rather than closing politely: a graceful close needs a round trip the
// server has already decided not to complete.
try
{
_socket?.Abort();
}
catch (ObjectDisposedException)
{
// Raced with the reconnect loop disposing it. Nothing left to abort.
}
}
/// <summary>Clears the refusal once a session actually comes up.</summary>
private void Accept()
{
_rejection = null;
Interlocked.Exchange(ref _rejections, 0);
Interlocked.Exchange(ref _consecutiveFailures, 0);
}
public Task StartAsync(CancellationToken ct)
{
if (_loop is not null)
{
return Task.CompletedTask;
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_loop = Task.Run(() => RunAsync(_cts.Token), CancellationToken.None);
return Task.CompletedTask;
}
public async Task StopAsync()
{
if (_cts is not null)
{
await _cts.CancelAsync().ConfigureAwait(false);
}
if (_loop is not null)
{
try
{
await _loop.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected on shutdown.
}
_loop = null;
}
SetState(ChannelState.Disconnected);
}
private async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
SetState(ChannelState.Connecting);
_socket = new ClientWebSocket();
_socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(20);
ConfigureSocket(_socket.Options);
await _socket.ConnectAsync(Uri, ct).ConfigureAwait(false);
OnLog?.Invoke($"[{Name}] socket open -> {Uri}", null);
SetState(ChannelState.Authenticating);
await OnOpenAsync(ct).ConfigureAwait(false);
ConnectCount++;
// The failure counter is NOT reset here. Opening a socket and sending
// the handshake proves nothing: the server can still refuse the session
// a moment later. Resetting at this point was the bug behind an endless
// reconnect loop — every attempt looked like a success, so the backoff
// never grew past its first step and the client hammered a connection
// limit every three seconds indefinitely. It is reset in Accept(),
// called when the channel actually reaches Live.
await ReceiveLoopAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_consecutiveFailures++;
SetState(ChannelState.Faulted);
// A socket the server aborted after refusing us is the expected outcome
// of Reject(), not a separate fault worth its own alarming line.
if (_rejection is null)
{
OnLog?.Invoke($"[{Name}] connection failed ({_consecutiveFailures}): {ex.Message}", ex);
}
}
finally
{
SetState(ChannelState.Disconnected);
DisposeSocket();
// The session ended without ever going live, so the attempt failed even
// if no exception was thrown — a refusal followed by a clean server
// close looks exactly like that.
if (_rejection is not null)
{
_consecutiveFailures++;
}
}
if (ct.IsCancellationRequested)
{
break;
}
TimeSpan delay = BackoffDelay(_consecutiveFailures);
if (_rejection is { } reason)
{
OnLog?.Invoke(
$"[{Name}] rifiutato dal server ({_rejections}x): {reason} — nuovo tentativo fra {delay.TotalSeconds:F0}s",
null);
}
else
{
OnLog?.Invoke($"[{Name}] reconnecting in {delay.TotalSeconds:F1}s", null);
}
try
{
await Task.Delay(delay, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(InitialBufferSize);
try
{
while (!ct.IsCancellationRequested && _socket is { State: WebSocketState.Open })
{
int offset = 0;
ValueWebSocketReceiveResult result;
do
{
if (offset == buffer.Length)
{
if (buffer.Length >= MaxBufferSize)
{
throw new InvalidOperationException(
$"[{Name}] message exceeded {MaxBufferSize / (1024 * 1024)} MB.");
}
byte[] bigger = ArrayPool<byte>.Shared.Rent(buffer.Length * 2);
Buffer.BlockCopy(buffer, 0, bigger, 0, offset);
ArrayPool<byte>.Shared.Return(buffer);
buffer = bigger;
}
result = await _socket.ReceiveAsync(buffer.AsMemory(offset), ct).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
OnLog?.Invoke(
$"[{Name}] server closed: {_socket.CloseStatus} {_socket.CloseStatusDescription}", null);
return;
}
offset += result.Count;
}
while (!result.EndOfMessage);
LastMessageUtc = DateTime.UtcNow;
try
{
OnMessage(buffer.AsSpan(0, offset), result.MessageType == WebSocketMessageType.Text);
}
catch (Exception ex)
{
// A malformed frame must never take the channel down.
OnLog?.Invoke($"[{Name}] message handler threw: {ex.Message}", ex);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
protected async ValueTask SendAsync(ReadOnlyMemory<byte> payload, CancellationToken ct)
{
ClientWebSocket? socket = _socket;
if (socket is not { State: WebSocketState.Open })
{
throw new InvalidOperationException($"[{Name}] cannot send: socket is {socket?.State.ToString() ?? "null"}.");
}
await _sendGate.WaitAsync(ct).ConfigureAwait(false);
try
{
await socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, ct).ConfigureAwait(false);
}
finally
{
_sendGate.Release();
}
}
/// <summary>Sends the auth (and subscribe) handshake right after the socket opens.</summary>
protected abstract ValueTask OnOpenAsync(CancellationToken ct);
/// <summary>Decodes one complete frame. Runs on the receive thread — keep it allocation free.</summary>
protected abstract void OnMessage(ReadOnlySpan<byte> payload, bool isText);
protected virtual void ConfigureSocket(ClientWebSocketOptions socketOptions)
{
}
protected void SetState(ChannelState state)
{
if (State == state)
{
return;
}
bool wasLive = State == ChannelState.Live;
State = state;
bool isLive = state == ChannelState.Live;
// Reaching Live is the only evidence that a connection attempt worked, so it is
// the only place the backoff is allowed to reset.
if (isLive)
{
Accept();
}
if (wasLive != isLive)
{
OnLiveChanged?.Invoke(isLive);
}
}
protected void Log(string message, Exception? ex = null) => OnLog?.Invoke(message, ex);
/// <summary>
/// Server-side timeout for an unauthenticated socket. Any backoff shorter than this
/// risks opening the next connection while the previous one still holds the
/// account's single market-data slot.
/// </summary>
private static readonly TimeSpan ServerAuthTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// 1s, 2s, 4s … capped at 60s, with jitter. Alpaca allows a single market-data
/// connection per account, so hammering reconnects just earns a 406.
/// <para>
/// Once the server has actually refused us, the floor rises above its own ten-second
/// timeout. Otherwise the client competes with its own dying socket for the one slot
/// available and can never win.
/// </para>
/// </summary>
private TimeSpan BackoffDelay(int failures)
{
if (failures <= 0)
{
return TimeSpan.FromSeconds(1);
}
double seconds = Math.Min(60, Math.Pow(2, Math.Min(failures, 6)));
if (_rejection is not null)
{
seconds = Math.Max(seconds, ServerAuthTimeout.TotalSeconds * 1.5);
}
return TimeSpan.FromSeconds(seconds + (Random.Shared.NextDouble() * 1.5));
}
private void DisposeSocket()
{
ClientWebSocket? socket = Interlocked.Exchange(ref _socket, null);
socket?.Dispose();
}
public async ValueTask DisposeAsync()
{
await StopAsync().ConfigureAwait(false);
_cts?.Dispose();
_sendGate.Dispose();
DisposeSocket();
GC.SuppressFinalize(this);
}
}
-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>
-81
View File
@@ -1,81 +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;
}
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>
/// The config lives next to the executable. Working directories vary (debugger,
/// shortcut, taskbar), so resolving it relative to the assembly is the only choice
/// that always finds the file.
/// </summary>
private static string ResolveConfigPath()
{
string beside = Path.Combine(AppContext.BaseDirectory, "encelado.json");
return File.Exists(beside) ? beside : Path.GetFullPath("encelado.json");
}
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,328 +0,0 @@
using Encelado.Alpaca;
using Encelado.Core.Market;
using Encelado.Core.Risk;
using Encelado.Core.Strategies;
namespace Encelado.Bot.Configuration;
/// <summary>Where the Alpaca credentials in use actually came from.</summary>
public enum CredentialSource
{
None = 0,
ConfigFile,
Environment,
SavedStore,
Interactive,
}
public sealed class BotConfig
{
public AlpacaOptions Alpaca { get; set; } = new();
/// <summary>
/// Provenance of <see cref="AlpacaOptions.KeyId"/>. Set by the loader and by the
/// login flow so startup can report it without ever echoing the secret.
/// </summary>
public CredentialSource CredentialOrigin { get; set; } = CredentialSource.None;
public EngineOptions Engine { get; set; } = new();
public RiskLimits Risk { get; set; } = new();
public LoggingOptions Logging { get; set; } = new();
public UiOptions Ui { get; set; } = new();
public List<SymbolConfig> Symbols { get; set; } = [];
public IEnumerable<SymbolConfig> EnabledSymbols => Symbols.Where(s => s.Enabled);
public BotConfig Validate()
{
Alpaca.Validate();
Risk.Validate();
Engine.Validate();
Ui.Validate();
Logging.Validate();
List<SymbolConfig> enabled = [.. EnabledSymbols];
if (enabled.Count == 0)
{
throw new InvalidOperationException("No enabled symbols in the configuration.");
}
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (SymbolConfig s in enabled)
{
if (string.IsNullOrWhiteSpace(s.Symbol))
{
throw new InvalidOperationException("A symbol entry has an empty 'symbol'.");
}
if (!seen.Add(s.Symbol))
{
throw new InvalidOperationException($"Symbol '{s.Symbol}' is configured more than once.");
}
if (!StrategyFactory.IsKnown(s.Strategy))
{
throw new InvalidOperationException(
$"Symbol '{s.Symbol}' uses unknown strategy '{s.Strategy}'. " +
$"Available: {string.Join(", ", StrategyFactory.Available)}.");
}
}
return this;
}
}
public sealed class EngineOptions
{
/// <summary><c>us_equity</c> or <c>crypto</c>. Crypto trades 24/7 and requires fractional sizes.</summary>
public string AssetClass { get; set; } = "us_equity";
/// <summary>Decision timeframe. Bars are consumed straight from the stream at 1Min.</summary>
public string TimeFrame { get; set; } = "1Min";
/// <summary>Historical bars pulled at startup to warm the indicators.</summary>
public int WarmupBars { get; set; } = 300;
/// <summary>Refuse new entries outside 09:3016:00 ET.</summary>
public bool TradeOnlyRegularHours { get; set; } = true;
/// <summary>Flatten everything this many minutes before the close. 0 disables.</summary>
public int FlattenBeforeCloseMinutes { get; set; } = 10;
public bool AllowFractionalShares { get; set; }
/// <summary>Attach take-profit/stop-loss legs server-side so exits survive a bot crash.</summary>
public bool UseBracketOrders { get; set; } = true;
/// <summary><c>market</c> or <c>limit</c>. A marketable limit caps slippage.</summary>
public string EntryOrderType { get; set; } = "limit";
/// <summary>How far through the touch a marketable limit is priced, in basis points.</summary>
public double LimitOffsetBps { get; set; } = 5;
/// <summary>Log decisions but never send an order. The safest way to observe a new config.</summary>
public bool DryRun { get; set; }
public int ReconcileSeconds { get; set; } = 30;
public int StatusSeconds { get; set; } = 60;
/// <summary>
/// How often the engine re-reads what each strategy would do at the current price and
/// writes it to the log when it has changed. This is the heartbeat that makes a
/// patient bot distinguishable from a stuck one.
/// </summary>
public int ExplainSeconds { get; set; } = 5;
/// <summary>Reject entries when top-of-book is older than this. 0 disables the check.</summary>
public int MaxQuoteAgeSeconds { get; set; } = 30;
/// <summary>Liquidate everything when the bot shuts down.</summary>
public bool CloseOnShutdown { get; set; }
public AssetClass ResolvedAssetClass =>
AssetClass.Trim().ToLowerInvariant() is "crypto" or "us_crypto"
? Core.Market.AssetClass.Crypto
: Core.Market.AssetClass.UsEquity;
public TimeFrame ResolvedTimeFrame =>
TimeFrameExtensions.TryParse(TimeFrame, out TimeFrame tf) ? tf : Core.Market.TimeFrame.OneMinute;
public bool UseLimitEntries =>
EntryOrderType.Trim().Equals("limit", StringComparison.OrdinalIgnoreCase);
public void Validate()
{
if (!TimeFrameExtensions.TryParse(TimeFrame, out _))
{
throw new InvalidOperationException($"engine.timeFrame '{TimeFrame}' is not supported.");
}
if (EntryOrderType.Trim() is not ("limit" or "market"))
{
throw new InvalidOperationException("engine.entryOrderType must be 'limit' or 'market'.");
}
if (WarmupBars is < 0 or > 10_000)
{
throw new InvalidOperationException("engine.warmupBars must be between 0 and 10000.");
}
if (LimitOffsetBps is < 0 or > 500)
{
throw new InvalidOperationException("engine.limitOffsetBps must be between 0 and 500.");
}
if (ReconcileSeconds < 5)
{
throw new InvalidOperationException("engine.reconcileSeconds must be at least 5.");
}
if (ResolvedAssetClass == Core.Market.AssetClass.Crypto && !AllowFractionalShares)
{
throw new InvalidOperationException(
"engine.allowFractionalShares must be true when engine.assetClass is 'crypto'.");
}
}
}
/// <summary>Settings for the web dashboard served by the <c>ui</c> command.</summary>
public sealed class UiOptions
{
/// <summary>
/// Where the dashboard listens. Use <c>http://0.0.0.0:5088</c> to reach it from
/// another machine — there is no authentication, so only do that on a trusted LAN.
/// </summary>
public string Url { get; set; } = "http://localhost:5088";
/// <summary>Begin trading as soon as the dashboard starts, without pressing START.</summary>
public bool AutoStartBot { get; set; }
public bool OpenBrowser { get; set; } = true;
public void Validate()
{
if (!Uri.TryCreate(Url, UriKind.Absolute, out Uri? parsed) ||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException($"ui.url '{Url}' is not a valid http(s) URL.");
}
}
}
public sealed class LoggingOptions
{
/// <summary>
/// Verbosity: <c>trace</c>, <c>debug</c>, <c>info</c>, <c>warn</c>, <c>error</c> or
/// <c>none</c>. <c>debug</c> adds every rejected signal and risk refusal;
/// <c>trace</c> adds per-quote detail and is very noisy.
/// </summary>
public string Level { get; set; } = "info";
public bool Console { get; set; }
/// <summary>
/// Folder that holds every output file. Relative paths resolve against the
/// executable's directory, so the app writes to the same place regardless of where
/// it was launched from. An absolute path is used as given.
/// </summary>
public string Directory { get; set; } = "logs";
/// <summary>Application log file name. Empty disables file logging.</summary>
public string File { get; set; } = "encelado.log";
/// <summary>One JSON line per order event. Empty disables it.</summary>
public string TradeJournal { get; set; } = "trades.jsonl";
/// <summary>
/// One CSV row per evaluated bar, per symbol, with the full market state, every
/// indicator the strategy exposes, the position, and the resulting signal. This is
/// the dataset to analyse when tuning the model. Empty disables it.
/// </summary>
public string DecisionLog { get; set; } = "decisions.csv";
/// <summary>
/// One CSV row per signal that reached the order path, with the risk verdict and
/// the order outcome. Joins to <see cref="DecisionLog"/> on <c>decisionId</c>.
/// </summary>
public string ExecutionLog { get; set; } = "executions.csv";
/// <summary>Rotate the application log once it passes this size. 0 disables rotation.</summary>
public int MaxFileSizeMb { get; set; } = 32;
/// <summary>How many rotated application logs to keep.</summary>
public int MaxFiles { get; set; } = 10;
/// <summary>
/// Log every quote and trade tick. Produces enormous files and is only useful when
/// diagnosing the market-data path itself.
/// </summary>
public bool LogMarketData { get; set; }
/// <summary>
/// Lines kept in the activity strip on the status page. Small on purpose: that panel
/// is glanced at, not read, and every line held there is a live WPF visual.
/// </summary>
/// <summary>
/// Log every incoming bar from the stream, not only the ones that close a strategy
/// bucket. On a daily timeframe this is the difference between a log that shows the
/// market moving and one that shows nothing for twenty-four hours.
/// </summary>
public bool LogEveryBar { get; set; } = true;
public int StatusLines { get; set; } = 200;
/// <summary>
/// Lines kept by the log page. This is the memory ceiling for the in-app log: at the
/// default it is a few megabytes. It is deliberately not unbounded — a bot left
/// running for a week at <c>debug</c> would otherwise grow without limit. The file
/// on disk stays complete regardless, and the page can open it.
/// </summary>
public int BufferedLines { get; set; } = 5_000;
/// <summary>Absolute path of the log directory, created on demand.</summary>
public string ResolveDirectory()
{
string directory = string.IsNullOrWhiteSpace(Directory) ? "logs" : Directory;
return Path.IsPathRooted(directory)
? directory
: Path.Combine(AppContext.BaseDirectory, directory);
}
/// <summary>Absolute path of a file inside the log directory, or null when disabled.</summary>
public string? ResolvePath(string? fileName) =>
string.IsNullOrWhiteSpace(fileName)
? null
: Path.IsPathRooted(fileName)
? fileName
: Path.Combine(ResolveDirectory(), fileName);
public void Validate()
{
if (MaxFileSizeMb is < 0 or > 4096)
{
throw new InvalidOperationException("logging.maxFileSizeMb must be between 0 and 4096.");
}
if (MaxFiles is < 1 or > 500)
{
throw new InvalidOperationException("logging.maxFiles must be between 1 and 500.");
}
if (StatusLines is < 20 or > 5_000)
{
throw new InvalidOperationException("logging.statusLines must be between 20 and 5000.");
}
// The ceiling is a memory guard, not a preference: each buffered line is a live
// object plus, once scrolled into view, a WPF visual.
if (BufferedLines is < 100 or > 200_000)
{
throw new InvalidOperationException("logging.bufferedLines must be between 100 and 200000.");
}
if (BufferedLines < StatusLines)
{
throw new InvalidOperationException(
"logging.bufferedLines must be >= logging.statusLines: the log page cannot hold " +
"less history than the status strip.");
}
}
}
public sealed class SymbolConfig
{
public string Symbol { get; set; } = string.Empty;
public string Strategy { get; set; } = "ema-cross";
public bool Enabled { get; set; } = true;
public Dictionary<string, double> Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public StrategyParameters ToStrategyParameters() => new(Parameters);
}
@@ -1,392 +0,0 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Bot.Configuration;
/// <summary>
/// Reads <c>encelado.json</c> by hand with <see cref="JsonDocument"/>. No reflection
/// binder means no trimming surprises and no silent type coercion — an unknown key is
/// reported instead of ignored.
/// <para>
/// Precedence: file &lt; environment variables. Credentials should live in the
/// environment (or a gitignored local file), never in the committed config.
/// </para>
/// </summary>
public static class ConfigLoader
{
private static readonly JsonDocumentOptions ParseOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static BotConfig Load(string path, out List<string> warnings)
{
warnings = [];
BotConfig config = new();
if (File.Exists(path))
{
using FileStream stream = File.OpenRead(path);
using JsonDocument doc = JsonDocument.Parse(stream, ParseOptions);
ApplyJson(config, doc.RootElement, warnings);
}
else
{
warnings.Add($"config file '{path}' not found; using defaults plus environment variables");
}
// A sibling *.local.json overlays secrets and machine-specific overrides.
string localPath = Path.ChangeExtension(path, null) + ".local.json";
if (File.Exists(localPath))
{
using FileStream stream = File.OpenRead(localPath);
using JsonDocument doc = JsonDocument.Parse(stream, ParseOptions);
ApplyJson(config, doc.RootElement, warnings);
}
ApplyEnvironment(config);
return config;
}
private static void ApplyJson(BotConfig config, JsonElement root, List<string> warnings)
{
if (root.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException("The configuration root must be a JSON object.");
}
foreach (JsonProperty section in root.EnumerateObject())
{
if (section.Name.StartsWith('_'))
{
continue;
}
switch (section.Name.ToLowerInvariant())
{
case "alpaca":
ReadAlpaca(config, section.Value, warnings);
break;
case "engine":
ReadEngine(config, section.Value, warnings);
break;
case "risk":
ReadRisk(config, section.Value, warnings);
break;
case "logging":
ReadLogging(config, section.Value, warnings);
break;
case "ui":
ReadUi(config, section.Value, warnings);
break;
case "symbols":
ReadSymbols(config, section.Value, warnings);
break;
case "$schema":
case "_comment":
break;
default:
warnings.Add($"unknown config section '{section.Name}'");
break;
}
}
}
private static void ReadAlpaca(BotConfig config, JsonElement e, List<string> warnings)
{
foreach (JsonProperty p in Properties(e, "alpaca", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "keyid": config.Alpaca.KeyId = Str(p); break;
case "secretkey": config.Alpaca.SecretKey = Str(p); break;
case "paper": config.Alpaca.Paper = Bool(p); break;
case "datafeed": config.Alpaca.DataFeed = Str(p); break;
case "tradingbaseurl": config.Alpaca.TradingBaseUrlOverride = Str(p); break;
case "databaseurl": config.Alpaca.DataBaseUrlOverride = Str(p); break;
case "requestsperminute": config.Alpaca.RequestsPerMinute = Int(p); break;
case "httptimeoutseconds": config.Alpaca.HttpTimeout = TimeSpan.FromSeconds(Num(p)); break;
case "maxretries": config.Alpaca.MaxRetries = Int(p); break;
default: warnings.Add($"unknown key 'alpaca.{p.Name}'"); break;
}
}
}
private static void ReadEngine(BotConfig config, JsonElement e, List<string> warnings)
{
EngineOptions o = config.Engine;
foreach (JsonProperty p in Properties(e, "engine", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "assetclass": o.AssetClass = Str(p); break;
case "timeframe": o.TimeFrame = Str(p); break;
case "warmupbars": o.WarmupBars = Int(p); break;
case "tradeonlyregularhours": o.TradeOnlyRegularHours = Bool(p); break;
case "flattenbeforeclosminutes":
case "flattenbeforecloseminutes": o.FlattenBeforeCloseMinutes = Int(p); break;
case "allowfractionalshares": o.AllowFractionalShares = Bool(p); break;
case "usebracketorders": o.UseBracketOrders = Bool(p); break;
case "entryordertype": o.EntryOrderType = Str(p); break;
case "limitoffsetbps": o.LimitOffsetBps = Num(p); break;
case "dryrun": o.DryRun = Bool(p); break;
case "reconcileseconds": o.ReconcileSeconds = Int(p); break;
case "statusseconds": o.StatusSeconds = Int(p); break;
case "explainseconds": o.ExplainSeconds = Int(p); break;
case "maxquoteageseconds": o.MaxQuoteAgeSeconds = Int(p); break;
case "closeonshutdown": o.CloseOnShutdown = Bool(p); break;
default: warnings.Add($"unknown key 'engine.{p.Name}'"); break;
}
}
}
private static void ReadRisk(BotConfig config, JsonElement e, List<string> warnings)
{
Core.Risk.RiskLimits r = config.Risk;
foreach (JsonProperty p in Properties(e, "risk", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "maxriskpertradepct": r.MaxRiskPerTradePct = Num(p); break;
case "stakepct": r.StakePct = Num(p); break;
case "stakeamount": r.StakeAmount = Num(p); break;
case "maxpositionnotionalpct": r.MaxPositionNotionalPct = Num(p); break;
case "maxgrossexposurepct": r.MaxGrossExposurePct = Num(p); break;
case "maxopenpositions": r.MaxOpenPositions = Int(p); break;
case "maxtradesperday": r.MaxTradesPerDay = Int(p); break;
case "maxtradespersymbolperday": r.MaxTradesPerSymbolPerDay = Int(p); break;
case "maxdailylosspct": r.MaxDailyLossPct = Num(p); break;
case "maxdailyprofitpct": r.MaxDailyProfitPct = Num(p); break;
case "minsecondsbetweenentries": r.MinSecondsBetweenEntries = Int(p); break;
case "maxrelativespread": r.MaxRelativeSpread = Num(p); break;
case "minprice": r.MinPrice = Num(p); break;
case "maxprice": r.MaxPrice = Num(p); break;
case "minordernotional": r.MinOrderNotional = Num(p); break;
case "maxordernotional": r.MaxOrderNotional = Num(p); break;
case "allowshorting": r.AllowShorting = Bool(p); break;
case "defaultstoppct": r.DefaultStopPct = Num(p); break;
case "maxstopdistancepct": r.MaxStopDistancePct = Num(p); break;
default: warnings.Add($"unknown key 'risk.{p.Name}'"); break;
}
}
}
private static void ReadLogging(BotConfig config, JsonElement e, List<string> warnings)
{
LoggingOptions o = config.Logging;
foreach (JsonProperty p in Properties(e, "logging", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "level": o.Level = Str(p); break;
case "console": o.Console = Bool(p); break;
case "directory": o.Directory = Str(p); break;
case "file": o.File = Str(p); break;
case "tradejournal": o.TradeJournal = Str(p); break;
case "decisionlog": o.DecisionLog = Str(p); break;
case "executionlog": o.ExecutionLog = Str(p); break;
case "maxfilesizemb": o.MaxFileSizeMb = Int(p); break;
case "maxfiles": o.MaxFiles = Int(p); break;
case "logmarketdata": o.LogMarketData = Bool(p); break;
case "logeverybar": o.LogEveryBar = Bool(p); break;
case "statuslines": o.StatusLines = Int(p); break;
case "bufferedlines": o.BufferedLines = Int(p); break;
default: warnings.Add($"unknown key 'logging.{p.Name}'"); break;
}
}
}
private static void ReadUi(BotConfig config, JsonElement e, List<string> warnings)
{
UiOptions o = config.Ui;
foreach (JsonProperty p in Properties(e, "ui", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "url": o.Url = Str(p); break;
case "autostartbot": o.AutoStartBot = Bool(p); break;
case "openbrowser": o.OpenBrowser = Bool(p); break;
default: warnings.Add($"unknown key 'ui.{p.Name}'"); break;
}
}
}
private static void ReadSymbols(BotConfig config, JsonElement e, List<string> warnings)
{
if (e.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("'symbols' must be an array.");
}
config.Symbols.Clear();
foreach (JsonElement item in e.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
config.Symbols.Add(new SymbolConfig { Symbol = item.GetString() ?? string.Empty });
continue;
}
if (item.ValueKind != JsonValueKind.Object)
{
warnings.Add("ignoring a non-object entry in 'symbols'");
continue;
}
SymbolConfig sc = new();
foreach (JsonProperty p in item.EnumerateObject())
{
if (p.Name.StartsWith('_'))
{
continue;
}
switch (p.Name.ToLowerInvariant())
{
case "symbol": sc.Symbol = Str(p); break;
case "strategy": sc.Strategy = Str(p); break;
case "enabled": sc.Enabled = Bool(p); break;
case "parameters" or "params":
if (p.Value.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty kv in p.Value.EnumerateObject())
{
if (kv.Name.StartsWith('_'))
{
continue;
}
sc.Parameters[kv.Name] = kv.Value.ValueKind switch
{
JsonValueKind.Number => kv.Value.GetDouble(),
JsonValueKind.True => 1,
JsonValueKind.False => 0,
JsonValueKind.String when double.TryParse(
kv.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture,
out double parsed) => parsed,
_ => 0,
};
}
}
break;
default: warnings.Add($"unknown key 'symbols[].{p.Name}'"); break;
}
}
config.Symbols.Add(sc);
}
}
private static void ApplyEnvironment(BotConfig config)
{
// Alpaca's own variable names, so existing tooling keeps working.
bool fromEnvironment = false;
string? key = Environment.GetEnvironmentVariable("APCA_API_KEY_ID");
if (!string.IsNullOrWhiteSpace(key))
{
config.Alpaca.KeyId = key.Trim();
fromEnvironment = true;
}
string? secret = Environment.GetEnvironmentVariable("APCA_API_SECRET_KEY");
if (!string.IsNullOrWhiteSpace(secret))
{
config.Alpaca.SecretKey = secret.Trim();
fromEnvironment = true;
}
bool haveBoth =
!string.IsNullOrWhiteSpace(config.Alpaca.KeyId) &&
!string.IsNullOrWhiteSpace(config.Alpaca.SecretKey);
config.CredentialOrigin = haveBoth
? fromEnvironment ? CredentialSource.Environment : CredentialSource.ConfigFile
: CredentialSource.None;
if (TryEnvBool("ENCELADO_PAPER", out bool paper))
{
config.Alpaca.Paper = paper;
}
if (TryEnvBool("ENCELADO_DRY_RUN", out bool dryRun))
{
config.Engine.DryRun = dryRun;
}
string? feed = Environment.GetEnvironmentVariable("ENCELADO_DATA_FEED");
if (!string.IsNullOrWhiteSpace(feed))
{
config.Alpaca.DataFeed = feed.Trim();
}
string? level = Environment.GetEnvironmentVariable("ENCELADO_LOG_LEVEL");
if (!string.IsNullOrWhiteSpace(level))
{
config.Logging.Level = level.Trim();
}
}
private static bool TryEnvBool(string name, out bool value)
{
string? raw = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(raw))
{
value = false;
return false;
}
raw = raw.Trim();
value = raw is "1" or "true" or "True" or "TRUE" or "yes" or "YES" or "on";
return true;
}
private static IEnumerable<JsonProperty> Properties(JsonElement e, string section, List<string> warnings)
{
if (e.ValueKind != JsonValueKind.Object)
{
warnings.Add($"'{section}' must be an object; ignored");
yield break;
}
foreach (JsonProperty p in e.EnumerateObject())
{
// Keys beginning with '_' are inline documentation. JSON has no comments,
// and a config full of trading assumptions badly needs them.
if (!p.Name.StartsWith('_'))
{
yield return p;
}
}
}
private static string Str(JsonProperty p) => p.Value.ValueKind switch
{
JsonValueKind.String => p.Value.GetString() ?? string.Empty,
JsonValueKind.Number => p.Value.GetDouble().ToString(CultureInfo.InvariantCulture),
_ => string.Empty,
};
private static double Num(JsonProperty p) => p.Value.ValueKind switch
{
JsonValueKind.Number => p.Value.GetDouble(),
JsonValueKind.String when double.TryParse(
p.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) => d,
JsonValueKind.True => 1,
JsonValueKind.False => 0,
_ => throw new InvalidOperationException($"'{p.Name}' must be a number."),
};
private static int Int(JsonProperty p) => (int)Math.Round(Num(p));
private static bool Bool(JsonProperty p) => p.Value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => p.Value.GetDouble() != 0,
JsonValueKind.String => bool.TryParse(p.Value.GetString(), out bool b) && b,
_ => throw new InvalidOperationException($"'{p.Name}' must be a boolean."),
};
}
@@ -1,123 +0,0 @@
using Encelado.Alpaca;
using Encelado.Alpaca.Rest;
namespace Encelado.Bot.Configuration;
/// <summary>Outcome of trying to find usable Alpaca credentials.</summary>
public readonly record struct CredentialLookup(bool Found, CredentialSource Source, string MaskedKey)
{
public string Describe() => Source switch
{
CredentialSource.ConfigFile => $"file di configurazione ({MaskedKey})",
CredentialSource.Environment => $"variabili d'ambiente ({MaskedKey})",
CredentialSource.SavedStore => $"chiavi salvate ({MaskedKey})",
CredentialSource.Interactive => $"inserite a mano ({MaskedKey})",
_ => "nessuna credenziale",
};
}
/// <summary>
/// Decides which credentials the app should use, and verifies candidates against the
/// broker before they are trusted. Deliberately UI-free: the window owns the dialog,
/// this owns the policy.
/// </summary>
public static class CredentialResolver
{
/// <summary>
/// Resolves in order of explicitness: environment variables (automation), then keys
/// saved by the user, then the config file. Mutates <paramref name="config"/> with
/// whatever it settles on.
/// </summary>
public static CredentialLookup Resolve(BotConfig config)
{
ArgumentNullException.ThrowIfNull(config);
if (config.CredentialOrigin == CredentialSource.Environment)
{
return new CredentialLookup(true, CredentialSource.Environment,
CredentialStore.Mask(config.Alpaca.KeyId));
}
if (CredentialStore.Load(config.Alpaca.Paper) is { } saved)
{
config.Alpaca.KeyId = saved.KeyId;
config.Alpaca.SecretKey = saved.SecretKey;
config.CredentialOrigin = CredentialSource.SavedStore;
return new CredentialLookup(true, CredentialSource.SavedStore, CredentialStore.Mask(saved.KeyId));
}
if (config.CredentialOrigin == CredentialSource.ConfigFile)
{
return new CredentialLookup(true, CredentialSource.ConfigFile,
CredentialStore.Mask(config.Alpaca.KeyId));
}
return new CredentialLookup(false, CredentialSource.None, string.Empty);
}
/// <summary>
/// Confirms a key pair actually works by asking Alpaca for the account. Returns the
/// account on success so the caller can show who just logged in.
/// </summary>
public static async Task<(bool Ok, string Message, AlpacaAccount? Account)> VerifyAsync(
string keyId,
string secretKey,
bool paper,
CancellationToken ct)
{
AlpacaOptions probe = new()
{
KeyId = keyId,
SecretKey = secretKey,
Paper = paper,
HttpTimeout = TimeSpan.FromSeconds(20),
MaxRetries = 1,
};
try
{
probe.Validate();
}
catch (InvalidOperationException ex)
{
return (false, ex.Message, null);
}
try
{
using AlpacaTradingClient client = new(probe);
AlpacaAccount account = await client.GetAccountAsync(ct).ConfigureAwait(false);
return (true, $"Conto {account.AccountNumber} — {account.Status}", account);
}
catch (AlpacaApiException ex) when (ex.StatusCode is 401 or 403)
{
string hint = paper && keyId.StartsWith("AK", StringComparison.OrdinalIgnoreCase)
? " Sembra una chiave LIVE ma l'app è impostata su paper."
: !paper && keyId.StartsWith("PK", StringComparison.OrdinalIgnoreCase)
? " Sembra una chiave PAPER ma l'app è impostata su live."
: string.Empty;
return (false, $"Alpaca ha rifiutato le credenziali (HTTP {ex.StatusCode}).{hint}", null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return (false, $"Impossibile contattare Alpaca: {ex.Message}", null);
}
}
/// <summary>Stores a verified key pair and points the config at it.</summary>
public static void Apply(BotConfig config, string keyId, string secretKey, bool save)
{
ArgumentNullException.ThrowIfNull(config);
config.Alpaca.KeyId = keyId;
config.Alpaca.SecretKey = secretKey;
config.CredentialOrigin = CredentialSource.Interactive;
if (save)
{
CredentialStore.Save(config.Alpaca.Paper, keyId, secretKey);
config.CredentialOrigin = CredentialSource.SavedStore;
}
}
}
@@ -1,282 +0,0 @@
using System.Buffers;
using System.Security.Cryptography;
using System.Text.Json;
namespace Encelado.Bot.Configuration;
/// <summary>Credentials plus a human-readable note about where they came from.</summary>
public readonly record struct StoredCredentials(string KeyId, string SecretKey);
/// <summary>
/// Persists Alpaca API keys outside the repository, per user and per environment
/// (paper keys and live keys are different keys, so they are stored separately).
/// <para>
/// On Windows the file is encrypted with DPAPI bound to the current user account:
/// another user on the same machine cannot read it, and it needs no passphrase, which
/// matters for a bot that has to restart unattended. On other platforms DPAPI does not
/// exist, so the file is written as plain JSON with owner-only permissions and
/// <see cref="IsEncrypted"/> reports <see langword="false"/> so callers can warn.
/// </para>
/// </summary>
public static class CredentialStore
{
private const string PaperKey = "paper";
private const string LiveKey = "live";
/// <summary>True when the file at rest is encrypted rather than merely permission-restricted.</summary>
public static bool IsEncrypted => OperatingSystem.IsWindows();
/// <summary>
/// Where the store lives. <c>ENCELADO_HOME</c> overrides it, which keeps portable
/// installs and containers self-contained — and lets the tests run without ever
/// touching the real user profile. Read on every access so it stays overridable.
/// </summary>
public static string DirectoryPath =>
Environment.GetEnvironmentVariable("ENCELADO_HOME") is { Length: > 0 } custom
? custom
: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Encelado");
public static string FilePath => Path.Combine(DirectoryPath, "credentials.dat");
public static bool Exists => File.Exists(FilePath);
/// <summary>Reads the credentials saved for the given environment, or null when there are none.</summary>
public static StoredCredentials? Load(bool paper)
{
Dictionary<string, StoredCredentials> all = LoadAll();
return all.TryGetValue(paper ? PaperKey : LiveKey, out StoredCredentials found) ? found : null;
}
public static void Save(bool paper, string keyId, string secretKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentException.ThrowIfNullOrWhiteSpace(secretKey);
Dictionary<string, StoredCredentials> all = LoadAll();
all[paper ? PaperKey : LiveKey] = new StoredCredentials(keyId.Trim(), secretKey.Trim());
Write(all);
}
/// <summary>Removes the credentials for one environment. Returns whether anything was removed.</summary>
public static bool Clear(bool paper)
{
Dictionary<string, StoredCredentials> all = LoadAll();
if (!all.Remove(paper ? PaperKey : LiveKey))
{
return false;
}
if (all.Count == 0)
{
Delete();
}
else
{
Write(all);
}
return true;
}
public static bool ClearAll()
{
if (!Exists)
{
return false;
}
Delete();
return true;
}
/// <summary>
/// Strips control characters, byte-order marks and stray spacing from a pasted
/// credential. Keys copied out of a browser routinely carry a zero-width space or a
/// BOM, which would surface much later as an opaque "invalid char encoding" failure
/// deep inside the HTTP stack.
/// </summary>
public static string? Clean(string? raw)
{
if (string.IsNullOrEmpty(raw))
{
return null;
}
Span<char> buffer = raw.Length <= 256 ? stackalloc char[raw.Length] : new char[raw.Length];
int length = 0;
foreach (char c in raw)
{
if (!char.IsControl(c) && c != '\uFEFF' && c != '\u200B' && c != '\u00A0')
{
buffer[length++] = c;
}
}
string cleaned = new string(buffer[..length]).Trim();
return cleaned.Length == 0 ? null : cleaned;
}
/// <summary>
/// Masks a key for display. Only the first four characters survive — enough to tell
/// a paper key (<c>PK…</c>) from a live one (<c>AK…</c>) and to recognise which key
/// is loaded, without putting anything reusable into a log file that may be shared.
/// </summary>
public static string Mask(string? value)
{
if (string.IsNullOrEmpty(value))
{
return "(empty)";
}
if (value.Length <= 4)
{
return new string('*', value.Length);
}
return value[..4] + new string('*', Math.Min(12, value.Length - 4));
}
private static Dictionary<string, StoredCredentials> LoadAll()
{
Dictionary<string, StoredCredentials> result = new(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(FilePath))
{
return result;
}
byte[] raw;
try
{
raw = File.ReadAllBytes(FilePath);
}
catch (IOException)
{
return result;
}
catch (UnauthorizedAccessException)
{
return result;
}
byte[] plaintext;
try
{
plaintext = Unprotect(raw);
}
catch (CryptographicException)
{
// Written by a different Windows user, or the file is corrupt. Treat it as
// absent so the caller falls back to prompting instead of crashing.
return result;
}
try
{
using JsonDocument doc = JsonDocument.Parse(plaintext);
foreach (JsonProperty entry in doc.RootElement.EnumerateObject())
{
string? keyId = entry.Value.TryGetProperty("keyId", out JsonElement k) ? k.GetString() : null;
string? secret = entry.Value.TryGetProperty("secretKey", out JsonElement s) ? s.GetString() : null;
if (!string.IsNullOrWhiteSpace(keyId) && !string.IsNullOrWhiteSpace(secret))
{
result[entry.Name] = new StoredCredentials(keyId, secret);
}
}
}
catch (JsonException)
{
return [];
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
return result;
}
private static void Write(Dictionary<string, StoredCredentials> all)
{
ArrayBufferWriter<byte> buffer = new(256);
using (Utf8JsonWriter w = new(buffer))
{
w.WriteStartObject();
foreach ((string environment, StoredCredentials credentials) in all)
{
w.WriteStartObject(environment);
w.WriteString("keyId", credentials.KeyId);
w.WriteString("secretKey", credentials.SecretKey);
w.WriteEndObject();
}
w.WriteEndObject();
}
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
byte[] payload = Protect(buffer.WrittenSpan);
try
{
File.WriteAllBytes(FilePath, payload);
RestrictPermissions(FilePath);
}
finally
{
CryptographicOperations.ZeroMemory(payload);
}
}
private static void Delete()
{
try
{
File.Delete(FilePath);
}
catch (IOException)
{
// Nothing more we can do; the caller reports the path.
}
}
private static byte[] Protect(ReadOnlySpan<byte> plaintext)
{
if (OperatingSystem.IsWindows())
{
return ProtectedData.Protect(plaintext.ToArray(), optionalEntropy: null, DataProtectionScope.CurrentUser);
}
return plaintext.ToArray();
}
private static byte[] Unprotect(byte[] stored)
{
if (OperatingSystem.IsWindows())
{
return ProtectedData.Unprotect(stored, optionalEntropy: null, DataProtectionScope.CurrentUser);
}
return stored;
}
/// <summary>Owner-only access. On Windows DPAPI already scopes the data to the user.</summary>
private static void RestrictPermissions(string path)
{
if (OperatingSystem.IsWindows())
{
return;
}
try
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
// Best effort: the caller already warns that the file is not encrypted here.
}
}
}
@@ -1,358 +0,0 @@
using System.Globalization;
using System.Text;
using Encelado.Bot.Configuration;
using Encelado.Bot.Logging;
using Encelado.Core.Market;
using Encelado.Core.Portfolio;
using Encelado.Core.Strategies;
namespace Encelado.Bot.Diagnostics;
/// <summary>
/// Structured, machine-readable record of everything the engine decided and why.
/// <para>
/// Two CSV files, joined on <c>decisionId</c>:
/// </para>
/// <list type="bullet">
/// <item><b>decisions</b> — one row per evaluated bar per symbol: the bar itself
/// including the aggressor breakdown, every indicator the strategy publishes, the
/// position at the time, and the signal that came out. This is the dataset to load
/// into pandas when asking "why did it do that" or "would a different threshold have
/// helped".</item>
/// <item><b>executions</b> — one row per signal that reached the order path: the risk
/// verdict, the size that survived it, and the broker's answer.</item>
/// </list>
/// <para>
/// CSV rather than JSON on purpose: it opens in Excel, loads in one line of pandas, and
/// stays readable when a run produces tens of thousands of rows. Writes are buffered and
/// flushed on a timer, so the decision path never waits on the disk.
/// </para>
/// </summary>
public sealed class AnalyticsLog : IDisposable
{
private readonly StreamWriter? _decisions;
private readonly StreamWriter? _executions;
private readonly Lock _gate = new();
private readonly StringBuilder _row = new(512);
private string[] _metricNames = [];
private bool _decisionHeaderWritten;
private long _nextId;
private DateTime _lastFlush = DateTime.UtcNow;
public AnalyticsLog(LoggingOptions options)
{
ArgumentNullException.ThrowIfNull(options);
_decisions = Open(options.ResolvePath(options.DecisionLog));
_executions = Open(options.ResolvePath(options.ExecutionLog));
if (_executions is not null && _executions.BaseStream.Length == 0)
{
_executions.WriteLine(
"timestampUtc,decisionId,symbol,side,phase,approved,riskReason,riskDetail," +
"quantity,referencePrice,stopPrice,targetPrice,notional,equity,buyingPower," +
"grossExposure,openPositions,orderId,error,latencyMs");
}
}
public bool IsEnabled => _decisions is not null || _executions is not null;
public string? DecisionPath { get; private init; }
/// <summary>Allocates the id that ties a decision row to its execution row.</summary>
public long NextDecisionId() => Interlocked.Increment(ref _nextId);
/// <summary>
/// Records one bar evaluation. Called on the market-data thread once per closed
/// bar per symbol — a handful of times a day on this configuration, so the cost is
/// irrelevant, but it stays buffered anyway.
/// </summary>
public void Decision(
long decisionId,
string symbol,
in Bar bar,
IStrategy strategy,
in PositionView position,
in Signal signal,
in Quote quote,
double quoteAgeSeconds,
double equity,
bool sessionOpen,
bool halted)
{
if (_decisions is null)
{
return;
}
IReadOnlyList<StrategyMetric> metrics = strategy.Diagnostics;
lock (_gate)
{
if (!_decisionHeaderWritten)
{
WriteDecisionHeader(metrics);
}
_row.Clear();
Add(bar.TimeUtc.ToString("O", CultureInfo.InvariantCulture));
Add(decisionId);
Add(symbol);
Add(strategy.Name);
Add(strategy.IsReady ? 1 : 0);
Add(bar.Open);
Add(bar.High);
Add(bar.Low);
Add(bar.Close);
Add(bar.Volume);
Add(bar.TakerBuyVolume);
Add(bar.Delta);
Add(bar.TradeCount);
// Indicator values, in the same order the header declared.
foreach (string name in _metricNames)
{
double value = 0;
foreach (StrategyMetric m in metrics)
{
if (m.Name == name)
{
value = double.IsFinite(m.Value) ? m.Value : 0;
break;
}
}
Add(value);
}
Add(position.Quantity);
Add(position.AverageEntryPrice);
Add(position.UnrealizedPnl);
Add(position.BarsHeld);
Add(position.StopPrice);
Add(position.TargetPrice);
Add(signal.Kind.ToString());
Add(signal.Strength);
Add(signal.StopPrice);
Add(signal.TargetPrice);
Add(quote.IsValid ? quote.BidPrice : 0);
Add(quote.IsValid ? quote.AskPrice : 0);
Add(quote.IsValid ? quote.RelativeSpread : 0);
Add(quoteAgeSeconds);
Add(equity);
Add(sessionOpen ? 1 : 0);
Add(halted ? 1 : 0);
Add(signal.Reason, last: true);
_decisions.WriteLine(_row.ToString());
MaybeFlush();
}
}
/// <summary>Records what the order path did with a signal.</summary>
public void Execution(
long decisionId,
string symbol,
Side side,
string phase,
bool approved,
string riskReason,
string riskDetail,
double quantity,
double referencePrice,
double stopPrice,
double targetPrice,
double equity,
double buyingPower,
double grossExposure,
int openPositions,
string? orderId,
string? error,
double latencyMs)
{
if (_executions is null)
{
return;
}
lock (_gate)
{
_row.Clear();
Add(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
Add(decisionId);
Add(symbol);
Add(side.ToString());
Add(phase);
Add(approved ? 1 : 0);
Add(riskReason);
Add(riskDetail);
Add(quantity);
Add(referencePrice);
Add(stopPrice);
Add(targetPrice);
Add(quantity * referencePrice);
Add(equity);
Add(buyingPower);
Add(grossExposure);
Add(openPositions);
Add(orderId ?? string.Empty);
Add(error ?? string.Empty);
Add(latencyMs, last: true);
_executions.WriteLine(_row.ToString());
MaybeFlush();
}
}
private void WriteDecisionHeader(IReadOnlyList<StrategyMetric> metrics)
{
string[] names = new string[metrics.Count];
for (int i = 0; i < metrics.Count; i++)
{
names[i] = metrics[i].Name;
}
_metricNames = names;
_decisionHeaderWritten = true;
if (_decisions!.BaseStream.Length > 0)
{
// Appending to an existing file: keep its header rather than writing a
// second one in the middle.
return;
}
StringBuilder header = new(400);
header.Append("barTimeUtc,decisionId,symbol,strategy,ready,")
.Append("open,high,low,close,volume,takerBuyVolume,delta,trades,");
foreach (string name in names)
{
header.Append(name).Append(',');
}
header.Append("positionQty,positionEntry,positionPnl,barsHeld,positionStop,positionTarget,")
.Append("signal,signalStrength,signalStop,signalTarget,")
.Append("bid,ask,spreadPct,quoteAgeSec,equity,sessionOpen,halted,reason");
_decisions.WriteLine(header.ToString());
}
private void Add(double value, bool last = false)
{
if (double.IsFinite(value))
{
_row.Append(value.ToString("G10", CultureInfo.InvariantCulture));
}
if (!last)
{
_row.Append(',');
}
}
private void Add(long value, bool last = false)
{
_row.Append(value.ToString(CultureInfo.InvariantCulture));
if (!last)
{
_row.Append(',');
}
}
private void Add(string? value, bool last = false)
{
if (!string.IsNullOrEmpty(value))
{
// Quote only when necessary; a reason string routinely contains commas.
if (value.AsSpan().IndexOfAny(',', '"', '\n') >= 0)
{
_row.Append('"').Append(value.Replace("\"", "\"\"", StringComparison.Ordinal)).Append('"');
}
else
{
_row.Append(value);
}
}
if (!last)
{
_row.Append(',');
}
}
private void MaybeFlush()
{
if (DateTime.UtcNow - _lastFlush < TimeSpan.FromSeconds(5))
{
return;
}
_lastFlush = DateTime.UtcNow;
Flush();
}
public void Flush()
{
lock (_gate)
{
try
{
_decisions?.Flush();
_executions?.Flush();
}
catch (IOException ex)
{
Log.Warn($"analytics flush failed: {ex.Message}");
}
}
}
private static StreamWriter? Open(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
return new StreamWriter(
new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, 16384),
Encoding.UTF8)
{ AutoFlush = false };
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"cannot open analytics file {path}: {ex.Message}");
return null;
}
}
public void Dispose()
{
lock (_gate)
{
try
{
_decisions?.Flush();
_executions?.Flush();
}
catch (IOException)
{
// Best effort on shutdown.
}
_decisions?.Dispose();
_executions?.Dispose();
}
}
}
@@ -1,158 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace Encelado.Bot.Diagnostics;
/// <summary>
/// Fixed-bucket latency histogram. Recording is a single interlocked increment, so it
/// can sit directly on the decision path without perturbing what it measures.
/// </summary>
public sealed class LatencyHistogram(string name)
{
private static readonly long[] BoundsMicros =
[50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 1_000_000, long.MaxValue];
private readonly long[] _buckets = new long[BoundsMicros.Length];
private long _count;
private long _sumMicros;
private long _maxMicros;
public string Name { get; } = name;
public long Count => Interlocked.Read(ref _count);
public void Record(long micros)
{
if (micros < 0)
{
return;
}
int index = 0;
while (index < BoundsMicros.Length - 1 && micros > BoundsMicros[index])
{
index++;
}
Interlocked.Increment(ref _buckets[index]);
Interlocked.Increment(ref _count);
Interlocked.Add(ref _sumMicros, micros);
long observedMax = Interlocked.Read(ref _maxMicros);
while (micros > observedMax)
{
long previous = Interlocked.CompareExchange(ref _maxMicros, micros, observedMax);
if (previous == observedMax)
{
break;
}
observedMax = previous;
}
}
/// <summary>Records the elapsed time since a <see cref="Stopwatch.GetTimestamp"/> reading.</summary>
public void RecordSince(long startTimestamp) =>
Record((long)Stopwatch.GetElapsedTime(startTimestamp).TotalMicroseconds);
public string Summary()
{
long total = Interlocked.Read(ref _count);
if (total == 0)
{
return $"{Name}: no samples";
}
double mean = Interlocked.Read(ref _sumMicros) / (double)total;
return string.Create(CultureInfo.InvariantCulture,
$"{Name}: n={total} avg={Format(mean)} p50={Format(Percentile(0.50, total))} " +
$"p95={Format(Percentile(0.95, total))} p99={Format(Percentile(0.99, total))} " +
$"max={Format(Interlocked.Read(ref _maxMicros))}");
}
/// <summary>Upper bound of the bucket containing the requested percentile.</summary>
private double Percentile(double percentile, long total)
{
long target = (long)Math.Ceiling(percentile * total);
long running = 0;
for (int i = 0; i < _buckets.Length; i++)
{
running += Interlocked.Read(ref _buckets[i]);
if (running >= target)
{
return BoundsMicros[i] == long.MaxValue ? BoundsMicros[^2] : BoundsMicros[i];
}
}
return BoundsMicros[^2];
}
private static string Format(double micros) =>
micros >= 1000
? string.Create(CultureInfo.InvariantCulture, $"{micros / 1000:F1}ms")
: string.Create(CultureInfo.InvariantCulture, $"{micros:F0}us");
}
/// <summary>Process-wide counters and latency traces for the trading loop.</summary>
public sealed class Metrics
{
private long _trades;
private long _quotes;
private long _bars;
private long _signals;
private long _ordersSubmitted;
private long _ordersFilled;
private long _orderErrors;
private long _riskRejects;
private long _exits;
public LatencyHistogram BarToSignal { get; } = new("bar->signal");
public LatencyHistogram SignalToOrder { get; } = new("signal->order");
public long Trades => Interlocked.Read(ref _trades);
public long Quotes => Interlocked.Read(ref _quotes);
public long Bars => Interlocked.Read(ref _bars);
public long Signals => Interlocked.Read(ref _signals);
public long OrdersSubmitted => Interlocked.Read(ref _ordersSubmitted);
public long OrdersFilled => Interlocked.Read(ref _ordersFilled);
public long OrderErrors => Interlocked.Read(ref _orderErrors);
public long RiskRejects => Interlocked.Read(ref _riskRejects);
public long Exits => Interlocked.Read(ref _exits);
public void CountTrade() => Interlocked.Increment(ref _trades);
public void CountQuote() => Interlocked.Increment(ref _quotes);
public void CountBar() => Interlocked.Increment(ref _bars);
public void CountSignal() => Interlocked.Increment(ref _signals);
public void CountOrderSubmitted() => Interlocked.Increment(ref _ordersSubmitted);
public void CountOrderFilled() => Interlocked.Increment(ref _ordersFilled);
public void CountOrderError() => Interlocked.Increment(ref _orderErrors);
public void CountRiskReject() => Interlocked.Increment(ref _riskRejects);
public void CountExit() => Interlocked.Increment(ref _exits);
public string Summary()
{
StringBuilder sb = new(256);
sb.Append(CultureInfo.InvariantCulture, $"ticks={Trades} quotes={Quotes} bars={Bars} ")
.Append(CultureInfo.InvariantCulture, $"signals={Signals} orders={OrdersSubmitted} fills={OrdersFilled} ")
.Append(CultureInfo.InvariantCulture, $"exits={Exits} riskRejects={RiskRejects} errors={OrderErrors}");
return sb.ToString();
}
}
@@ -1,50 +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.Alpaca\Encelado.Alpaca.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>
<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>
@@ -1,68 +0,0 @@
using Encelado.Alpaca.Rest;
namespace Encelado.Bot.Engine;
/// <summary>
/// Latest known account snapshot, refreshed by the reconciler and read from the
/// order path. Fields are written as a unit under a lock and read without one, which
/// is fine: sizing only needs a recent value, not a transactionally consistent one.
/// </summary>
public sealed class AccountState
{
private double _equity;
private double _buyingPower;
private double _cash;
private int _daytradeCount;
private bool _patternDayTrader;
private bool _canTrade;
private bool _shortingEnabled;
// Broker-side detail the account page shows verbatim. None of it is on the decision
// path, so a reference swap under the same update is all the consistency needed.
private AlpacaAccount? _raw;
public double Equity => Volatile.Read(ref _equity);
public double BuyingPower => Volatile.Read(ref _buyingPower);
public double Cash => Volatile.Read(ref _cash);
public int DaytradeCount => Volatile.Read(ref _daytradeCount);
public bool PatternDayTrader => Volatile.Read(ref _patternDayTrader);
public bool CanTrade => Volatile.Read(ref _canTrade);
public bool ShortingEnabled => Volatile.Read(ref _shortingEnabled);
public DateTime LastUpdateUtc { get; private set; }
public bool HasData => Equity > 0;
/// <summary>
/// The last full account payload from the broker, or <see langword="null"/> before
/// the first reconcile. Dashboard only — the trading path reads the fields above.
/// </summary>
public AlpacaAccount? Raw => Volatile.Read(ref _raw);
public void Update(AlpacaAccount account)
{
ArgumentNullException.ThrowIfNull(account);
Volatile.Write(ref _raw, account);
Volatile.Write(ref _equity, (double)account.Equity);
Volatile.Write(ref _buyingPower, (double)account.BuyingPower);
Volatile.Write(ref _cash, (double)account.Cash);
Volatile.Write(ref _daytradeCount, account.DaytradeCount);
Volatile.Write(ref _patternDayTrader, account.PatternDayTrader);
Volatile.Write(ref _canTrade, account.CanTrade);
Volatile.Write(ref _shortingEnabled, account.ShortingEnabled);
LastUpdateUtc = DateTime.UtcNow;
}
/// <summary>
/// Under the PDT rule an account flagged as a pattern day trader with less than
/// $25 000 in equity cannot open a new day trade.
/// </summary>
public bool IsDayTradeBlocked => PatternDayTrader && Equity < 25_000;
}
@@ -1,83 +0,0 @@
using Encelado.Core.Market;
namespace Encelado.Bot.Engine;
/// <summary>
/// Folds Alpaca's one-minute stream bars into the strategy timeframe. A bucket is
/// emitted as soon as the first bar of the next bucket arrives, which is the earliest
/// moment the previous one is provably complete.
/// </summary>
public sealed class BarAggregator(int minutesPerBar)
{
private readonly long _bucketTicks = TimeSpan.TicksPerMinute * Math.Max(1, minutesPerBar);
private readonly bool _passthrough = minutesPerBar <= 1;
private Bar _current;
private long _bucket = -1;
private bool _has;
public bool IsPassthrough => _passthrough;
/// <summary>
/// Feeds a one-minute bar. Returns <see langword="true"/> when a higher-timeframe
/// bar closed, with the completed bar in <paramref name="closed"/>.
/// </summary>
public bool TryAdd(in Bar minuteBar, out Bar closed)
{
if (_passthrough)
{
closed = minuteBar;
return true;
}
long bucket = minuteBar.TimeUtc.Ticks / _bucketTicks;
if (!_has)
{
_current = minuteBar;
_bucket = bucket;
_has = true;
closed = default;
return false;
}
if (bucket != _bucket)
{
closed = _current;
_current = minuteBar;
_bucket = bucket;
return true;
}
_current = Merge(_current, minuteBar);
closed = default;
return false;
}
public void Reset()
{
_has = false;
_bucket = -1;
_current = default;
}
private static Bar Merge(in Bar acc, in Bar next)
{
double volume = acc.Volume + next.Volume;
double vwap = volume > 0
? ((acc.Vwap > 0 ? acc.Vwap : acc.TypicalPrice) * acc.Volume +
(next.Vwap > 0 ? next.Vwap : next.TypicalPrice) * next.Volume) / volume
: next.Close;
return new Bar(
acc.TimeUtc,
acc.Open,
Math.Max(acc.High, next.High),
Math.Min(acc.Low, next.Low),
next.Close,
volume,
vwap,
acc.TradeCount + next.TradeCount,
acc.TakerBuyVolume + next.TakerBuyVolume);
}
}
@@ -1,295 +0,0 @@
namespace Encelado.Bot.Engine;
public enum BotState
{
Stopped = 0,
Starting,
Running,
Stopping,
Faulted,
}
/// <summary>One open position, as the positions grid shows it.</summary>
public sealed record PositionRow(
string Symbol,
string Side,
double Quantity,
double EntryPrice,
double LastPrice,
double MarketValue,
double UnrealizedPnl,
double UnrealizedPnlPct,
double StopPrice,
double TargetPrice,
int BarsHeld,
DateTime OpenedAtUtc);
/// <summary>Per-symbol strategy state, including whatever the strategy chooses to expose.</summary>
public sealed record SymbolRow(
string Symbol,
string Strategy,
bool Ready,
int BarsSeen,
int WarmupBars,
double LastPrice,
double BidPrice,
double AskPrice,
double SpreadPct,
double QuoteAgeSeconds,
bool InPosition,
IReadOnlyList<MetricRow> Metrics)
{
public double WarmupProgress => WarmupBars > 0 ? Math.Min(1, BarsSeen / (double)WarmupBars) : 1;
/// <summary>The blended conviction, when the strategy publishes one.</summary>
public double? Score
{
get
{
foreach (MetricRow m in Metrics)
{
if (m.Name == "score")
{
return m.Value;
}
}
return null;
}
}
}
public sealed record MetricRow(string Name, double Value, string Format)
{
public string Display => Format switch
{
"P1" => (Value * 100).ToString("F1", System.Globalization.CultureInfo.CurrentCulture) + "%",
"F0" => Value.ToString("F0", System.Globalization.CultureInfo.CurrentCulture),
_ => Value.ToString("F2", System.Globalization.CultureInfo.CurrentCulture),
};
}
/// <summary>A point on the session equity curve.</summary>
public sealed record EquityPoint(DateTime TimeUtc, double Equity);
public sealed record EventRow(string Time, string Level, string Message);
/// <summary>
/// The broker's own view of the account, shown verbatim on the account page. Kept as a
/// separate record rather than folded into <see cref="BotSnapshot"/> so it can be
/// absent — before the first reconcile there is nothing truthful to display, and
/// showing zeros would look like a funded account that lost everything.
/// </summary>
public sealed record AccountRow(
string AccountNumber,
string Status,
string Currency,
double Equity,
double LastEquity,
double Cash,
double PortfolioValue,
double BuyingPower,
double DaytradingBuyingPower,
double Multiplier,
int DaytradeCount,
bool PatternDayTrader,
bool TradingBlocked,
bool AccountBlocked,
bool TransfersBlocked,
bool ShortingEnabled,
DateTime UpdatedUtc)
{
public double ChangeToday => Equity - LastEquity;
public double ChangeTodayPct => LastEquity > 0 ? (Equity - LastEquity) / LastEquity : 0;
/// <summary>Everything that would make the broker refuse an order, in one line.</summary>
public string Restrictions
{
get
{
List<string> issues = [];
if (TradingBlocked) { issues.Add("trading bloccato"); }
if (AccountBlocked) { issues.Add("conto bloccato"); }
if (TransfersBlocked) { issues.Add("trasferimenti bloccati"); }
if (PatternDayTrader && Equity < 25_000) { issues.Add("PDT sotto i 25.000"); }
return issues.Count == 0 ? "nessuna" : string.Join(", ", issues);
}
}
}
/// <summary>One row of the orders page.</summary>
public sealed record OrderRow(
string OrderId,
string Symbol,
string Side,
string Type,
string Status,
double Quantity,
double FilledQuantity,
double FilledAveragePrice,
double LimitPrice,
DateTime SubmittedUtc,
DateTime? FilledUtc)
{
public bool IsWorking { get; init; }
public double Notional => FilledQuantity > 0 && FilledAveragePrice > 0
? FilledQuantity * FilledAveragePrice
: Quantity * (double.IsFinite(LimitPrice) && LimitPrice > 0 ? LimitPrice : 0);
public string SubmittedLocal => SubmittedUtc.ToLocalTime().ToString("dd/MM HH:mm:ss",
System.Globalization.CultureInfo.CurrentCulture);
}
/// <summary>Price memory for one charted symbol.</summary>
public sealed record PriceSeriesRow(
string Symbol,
double LastPrice,
double SessionOpen,
double SessionHigh,
double SessionLow,
double SessionChangePct,
IReadOnlyList<double> BarCloses,
IReadOnlyList<double> BarHighs,
IReadOnlyList<double> BarLows,
IReadOnlyList<double> BarOpens,
IReadOnlyList<double> LivePrices)
{
public bool HasBars => BarCloses.Count > 1;
public bool HasLive => LivePrices.Count > 1;
}
/// <summary>
/// Everything the UI renders, in one immutable object built without holding any engine
/// lock. Handing off a value rather than exposing live state means repainting the
/// window can never perturb or block the trading path.
/// </summary>
public sealed record BotSnapshot
{
public required BotState State { get; init; }
public string? Error { get; init; }
public DateTime? StartedAtUtc { get; init; }
public TimeSpan Uptime { get; init; }
public required string Mode { get; init; }
public bool Paper { get; init; }
public bool DryRun { get; init; }
public required string AssetClass { get; init; }
public required string TimeFrame { get; init; }
public required string Endpoint { get; init; }
// ---- money -----------------------------------------------------------
public double Equity { get; init; }
public double Cash { get; init; }
public double BuyingPower { get; init; }
public double PnlToday { get; init; }
public double PnlTodayPct { get; init; }
public double PnlSession { get; init; }
public double PnlSessionPct { get; init; }
public double PnlAllTime { get; init; }
public double PnlAllTimePct { get; init; }
public bool HasAllTime { get; init; }
public double UnrealizedPnl { get; init; }
public double RealizedToday { get; init; }
public double GrossExposure { get; init; }
public double ExposurePct { get; init; }
// ---- session & risk ---------------------------------------------------
public required string SessionStatus { get; init; }
public bool MarketOpen { get; init; }
public bool Halted { get; init; }
public string? HaltReason { get; init; }
public int TradesToday { get; init; }
public int MaxTradesPerDay { get; init; }
public int OpenPositions { get; init; }
public int MaxOpenPositions { get; init; }
public double RiskPerTradePct { get; init; }
public double MaxDailyLossPct { get; init; }
// ---- plumbing ---------------------------------------------------------
public required string MarketDataState { get; init; }
public required string TradeStreamState { get; init; }
/// <summary>
/// Why a stream is being refused by the broker, when one is. A reconnect loop is
/// otherwise invisible from the window: the state just reads "disconnected" and the
/// explanation sits in the log file, which is the last place anyone looks.
/// </summary>
public string? StreamRejection { get; init; }
public int Reconnects { get; init; }
public long Ticks { get; init; }
public long Quotes { get; init; }
public long Bars { get; init; }
public long Signals { get; init; }
public long Orders { get; init; }
public long Fills { get; init; }
public long Exits { get; init; }
public long RiskRejects { get; init; }
public long Errors { get; init; }
public required string BarToSignal { get; init; }
public required string SignalToOrder { get; init; }
public IReadOnlyList<PositionRow> Positions { get; init; } = [];
public IReadOnlyList<SymbolRow> Symbols { get; init; } = [];
public IReadOnlyList<EquityPoint> EquityCurve { get; init; } = [];
public IReadOnlyList<EventRow> Events { get; init; } = [];
/// <summary>Null until the first successful reconcile against the broker.</summary>
public AccountRow? Account { get; init; }
/// <summary>Named to stay clear of <see cref="Orders"/>, which counts submissions.</summary>
public IReadOnlyList<OrderRow> OrderHistory { get; init; } = [];
public IReadOnlyList<PriceSeriesRow> Prices { get; init; } = [];
}
/// <summary>Result of a start/stop/close request from the UI.</summary>
public sealed record CommandResult(bool Ok, string Message);

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