Files
Encelado/README.md
T
Alby96andClaude Opus 5 2b4c53ec70 Unifica la catena di rilascio con Mimante/AutoBidder
La generazione del setup, il versionamento e il caricamento su Gitea passano
ora da un solo file MSBuild in build/Release.proj, com'e' gia' per AutoBidder,
al posto dello script PowerShell make-installer.ps1.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:26:34 +02:00

646 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Encelado
Applicazione desktop Windows per il trading automatico su **Alpaca**, scritta in
**C# / .NET 10 + WPF**. Nessuna dipendenza esterna: né NuGet a runtime, né browser,
né servizi da tenere vivi.
Un eseguibile, una finestra, un bottone.
![icona](src/Encelado.Bot/Assets/encelado.ico)
```
Encelado.exe
├── Stato prezzi, posizioni, P&L, equity, log recente — e AVVIA / FERMA
├── Conto il conto come lo vede Alpaca
├── Posizioni dettaglio e chiusura manuale
├── Ordini storico dal broker
├── Grafici candele e prezzo in diretta, anche in finestra separata
├── Log tutto il log, filtrabile e ricercabile
└── Impostazioni credenziali, cartella dei log, dimensionamento
```
**Un solo asset e una sola strategia, preimpostati.** BTC/USD, long sopra la media a 100
giorni e flat sotto. Non c'è un menu per sceglierla né uno per fare backtest: la scelta
è già stata fatta misurando su due dataset indipendenti, e il risultato è dentro la
configurazione.
Sui tredici anni di storia disponibili rende **121% annuo contro il 115% del comprare e
tenere**, con un drawdown massimo del 74% invece dell'87%. I numeri, i limiti e il
periodo in cui *perde* contro il buy & hold sono più sotto.
---
## Avvio
```powershell
dotnet build Encelado.slnx -c Release
```
Poi apri `src\Encelado.Bot\bin\Release\net10.0-windows\Encelado.exe`. Da VS Code: **F5**.
Al primo avvio compare la finestra delle credenziali: incolli le due chiavi Alpaca,
l'app le verifica contro il conto e ti chiede se salvarle. Non serve preparare nulla
prima — niente variabili d'ambiente, niente file da editare.
---
## Verifica, pacchetto e rilascio
**È la stessa catena di [Mimante/AutoBidder](http://192.168.30.23:3000/Alby96/Mimante):
un solo file MSBuild in `build/Release.proj`, nessuno script.** I dettagli stanno in
[`build/README.md`](build/README.md).
Da VS Code: **Terminale ▸ Esegui attività…**
| Attività | Cosa fa |
|---|---|
| `verifica` | Compila e lancia i test. |
| `backtest` | Rigioca una serie storica di prezzi. |
| `crea installatore` | Verifica, pubblica, esegue Inno Setup, crea il tag. |
| `crea installatore (senza rieseguire i test)` | Solo pubblicazione e installatore. |
| `rilascia su Gitea` | Tutto quanto sopra, più tag e release con i file allegati. |
```powershell
dotnet msbuild build/Release.proj -t:Verifica
dotnet msbuild build/Release.proj -t:Pacchetto
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=3.3.0
```
**La versione viene dal tag git, e da nient'altro.** Non viene mai riscritto un file:
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 `<Version>` in `Directory.Build.props` resta quello delle compilazioni
di sviluppo — è il numero che vedi nella finestra mentre lavori — e serve solo come
seme al primissimo rilascio, quando non esiste ancora nessun tag.
Il tag viene creato **in fondo**, a installatore esistente: un giro andato male non
lascia dietro un tag per una versione che non è mai stata costruita. E non viene creato
affatto se ci sono modifiche non committate, perché indicherebbe uno stato non
ricostruibile.
Il pacchetto esce in `bin/installer/`:
| | |
|---|---|
| `Encelado-<versione>-setup.exe` | L'installatore, ~47 MB |
| `Encelado-<versione>-portabile.zip` | La cartella pubblicata, per chi non vuole installare niente |
| Destinazione | `%LOCALAPPDATA%\Programs\Encelado` |
| UAC | nessuna richiesta |
| Runtime .NET | incorporato, non serve installarlo |
**Perché per utente e non in `Program Files`.** 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 bloccare il bot, te ne accorgeresti solo cercando i log per
capire cosa è successo. Per questo `PrivilegesRequired=lowest` non è sovrascrivibile.
**Aggiornamenti.** Reinstallare sopra una versione precedente **non** tocca il tuo
`encelado.json`: le modifiche fatte dalla scheda Impostazioni sopravvivono. Accanto
trovi sempre `encelado.default.json` aggiornato, per confrontare la tua configurazione
con i valori di fabbrica. Se l'applicazione è in esecuzione, l'installatore la chiude.
**Disinstallazione.** Rimuove programma e log. Le credenziali Alpaca stanno altrove
(`%LOCALAPPDATA%\Encelado`) e vengono cancellate solo se rispondi Sì alla domanda
esplicita — il default è No, così reinstallare non costringe a reinserire le chiavi.
In modalità silenziosa la domanda non viene posta e le credenziali restano.
---
## Cosa c'è nella finestra
Menu laterale a sinistra, una pagina alla volta a destra. Il bottone **AVVIA / FERMA**
sta in fondo al menu ed è quindi raggiungibile da qualunque pagina: non serve tornare
indietro per fermare il bot.
| Pagina | Cosa c'è |
|---|---|
| **Stato** | La pagina principale, quella su cui la finestra si apre. Riga KPI, prezzo in diretta, posizioni aperte, curva equity della sessione, i valori interni del modello (distanza dalla media, volatilità, order flow), contatori del motore, e una striscia con le ultime righe di log |
| **Conto** | Il conto come lo vede Alpaca: valore del portafoglio, variazione di oggi, liquidità, potere d'acquisto, leva, operazioni intraday, stato PDT, restrizioni. Sotto, i limiti che il bot si impone da sé — che sono un'altra cosa |
| **Posizioni** | Tabella completa con prezzo medio, valore, P&L in valuta e in percentuale, stop sorvegliato, barre in posizione, data di apertura, e **Chiudi** su ogni riga |
| **Ordini** | Lo storico come lo riporta il broker, non come lo ricorda il bot: dopo una riconnessione l'elenco di Alpaca è l'unico completo. Gli ordini ancora aperti sono evidenziati |
| **Grafici** | Candele delle barre su cui la strategia decide, oppure la linea del prezzo in diretta. Ogni asset si può staccare in una **finestra separata**, da mettere su un secondo monitor |
| **Log** | Tutto il log in memoria, colorato per livello, con filtro per severità e ricerca testuale. Da qui si apre anche il file su disco |
| **Impostazioni** | **Ogni valore su cui gira il bot, come campo modificabile.** Ognuno con una spiegazione dettagliata di cosa fa nella strategia e cosa succede se lo cambi. I valori fissati dalla strategia restano campi, con il lucchetto e il motivo. Salva scrivendo solo i valori cambiati in `encelado.json`, conservando commenti e formattazione |
Il prezzo in diretta è campionato dallo stream una volta al secondo. Sulle barre
giornaliere il modello decide una volta al giorno: il grafico in diretta serve a vedere
cosa succede *fra* una decisione e l'altra, non a suggerirne un'altra.
**La cornice della finestra è quella di Windows.** Nessun `WindowStyle="None"`, nessun
`WindowChrome`: barra del titolo, bordi, angoli, snap layout e i tre pulsanti sono di
sistema. L'unica cosa impostata è l'attributo DWM che chiede a Windows di disegnare *la
sua* barra in scuro invece che in chiaro, per non avere una striscia grigia sopra una
finestra quasi nera.
---
## La strategia, e cosa dice davvero il backtest
**Long sopra la media a 100 giorni, flat sotto.** Non c'è altro.
La semplicità non è pigrizia: è il risultato della misura. Il modello precedente
mescolava trend e mean-reversion, filtrava gli ingressi con un efficiency ratio e
trascinava uno stop a 5 ATR. Su tredici anni di BTC rendeva il **20,1% annuo** mentre
comprare e non fare niente rendeva il **114,8%**. Ogni singolo pezzo era difendibile; il
risultato era un sesto del mercato.
### L'aritmetica che decide la questione
Alpaca sulle crypto è solo spot: niente leva, il massimo è 1x. Una regola long/flat non
può quindi possedere più di quanto possieda il mercato, e ogni giorno passato fuori è un
giorno di capitalizzazione regalato. Contro un asset salito ventiduemila volte, una
regola del genere batte il comprare e tenere **solo se i giorni in cui sta fuori sono
sproporzionatamente quelli brutti**. Non può guadagnare più del mercato: può soltanto
evitarlo meglio.
Questo ribalta cosa sia un buon segnale. La variabile che conta non è la raffinatezza
del filtro, è il **tempo passato dentro**. Un modello che opera di rado e tiene poco è
strutturalmente incapace di stare al passo, per quanto buoni siano i suoi ingressi. La
furbizia che accorcia il periodo di detenzione non è gratis: su un asset così è la cosa
più cara del libro.
### La regola
```
distanza = (prezzo SMA100) / SMA100
flat → long quando distanza ≥ +2%
long → flat quando distanza ≤ 2%
```
La banda del 2% è **isteresi, non un filtro**: si entra sopra e si esce sotto, così un
prezzo appoggiato alla media non genera un'operazione ogni due giorni. Dimezza gli
scambi lasciando il rendimento dov'era.
Nessun trailing stop. Nessun target. Nessun time stop. Ognuna di quelle cose chiude la
posizione durante i movimenti che la strategia esiste per catturare. Lo stop al 35% c'è
solo come rete per un gap: la vera uscita è la media che cede.
Quando è dentro, è dentro **per intero** (`risk.stakePct: 1.0`). Qualunque frazione
inferiore perde la gara con il comprare e tenere in partenza.
### Risultati
Due dataset indipendenti, commissione 25 bps e slippage 8 bps per eseguito.
**Bitstamp, 2012-2025** — 4.756 barre giornaliere ripiegate da 6,8 milioni di barre da
un minuto:
| | Encelado | Compra e tieni |
|---|---|---|
| CAGR | **121,02%** | 114,89% |
| Drawdown massimo | **73,8%** | 86,7% |
| Calmar | **1,64** | 1,33 |
| Operazioni | 34 in 13 anni | 1 |
**Binance, 2017-2026** — dataset indipendente, exchange diverso, nessun parametro scelto
guardandolo:
| | Encelado | Compra e tieni |
|---|---|---|
| CAGR | **47,13%** | 35,24% |
| Drawdown massimo | **67,7%** | 83,5% |
| Calmar | **0,70** | 0,42 |
| Operazioni | 22 in 9 anni | 1 |
Batte il comprare e tenere **su entrambi i dataset, sia sul rendimento sia sul
drawdown**. Rispetto al modello precedente il CAGR passa da 20,1% a 121,0%.
Per periodo, su Bitstamp:
| | 2012-2018 | 2019-2025 |
|---|---|---|
| CAGR | 165,81% | 69,25% |
| Drawdown | 73,8% | **43,1%** |
| Calmar | 2,25 | 1,61 |
> **Il caveat onesto.** Nel periodo 2019-2025 il comprare e tenere ha reso il 73,8%
> contro il 69,25% della strategia: sul rendimento, negli ultimi sei anni, perde di
> poco. Quello che compra con quella rinuncia è il drawdown, 43% contro 76%. Il
> vantaggio sul rendimento arriva dai tredici anni pieni e dal dataset Binance, non
> dall'ultimo tratto.
### Perché 100 giorni
Non perché è il migliore su un file, ma perché è **l'unico che vince su entrambi**.
| Media | Bitstamp 13 anni | Binance 9 anni |
|---|---|---|
| SMA80 | +2,3 punti | +1,0 punti |
| **SMA100** | **+7,8 punti** | **+12,5 punti** |
| SMA120 | +18,6 punti | **2,2 punti** |
| SMA200 | 7,2 punti | 6,9 punti |
SMA120 rende molto di più su Bitstamp e **perde** su Binance: è taratura sul singolo
campione. La riga dei 100 giorni batte il comprare e tenere a **qualunque** banda su
entrambi i file, il che è la forma di un effetto reale invece che di un punto fortunato.
### Il filtro di order flow: misurato e scartato
Il documento insiste sul Cumulative Volume Delta. L'ho implementato e messo alla prova
sul file Binance, l'unico con il volume taker, sopra il filtro di prezzo:
| Soglia CVD | CAGR | Calmar |
|---|---|---|
| disattivato | 47,66% | **0,70** |
| ≥ 0,0 | 48,31% | 0,71 |
| ≥ 0,3 | 45,02% | 0,66 |
| ≥ 0,6 | 43,77% | 0,64 |
**Stringerlo peggiora, in modo monotono.** E il motivo è lo stesso di prima: il filtro
si era guadagnato il posto nel modello precedente, che operava di rado e poteva
permettersi di aspettare conferma. Qui ogni barra passata ad aspettare è una barra che
non compone.
Resta calcolato, registrato nei log e configurabile (`cvdThreshold`, default 0), perché
il valore è comunque informativo e un dataset futuro potrebbe dire altro. Ma non entra
in nessuna decisione.
Stessa sorte per il VWAP: aggiungerlo lascia il risultato identico a tre decimali,
perché un prezzo sopra la media a 100 giorni è quasi sempre anche sopra il VWAP a 20 —
il filtro non discrimina mai.
### Perché non è un bot ad alta frequenza, e perché cambiare exchange non aiuta
Stessi parametri, stesso file, solo barre più corte — e tre ipotesi di costo: Alpaca
(25 bps), Binance spot (10 bps) e **zero**, che nessun exchange al mondo può battere.
| Barre | Alpaca 25 bps | Binance 10 bps | Costo zero |
|---|---|---|---|
| **1 giorno** | **5,54** | **5,89** | **6,14** |
| 12 ore | 2,90 | 3,15 | 3,33 |
| 4 ore | 1,84 | 2,04 | 2,20 |
| 1 ora | 1,27 | 1,46 | 1,64 |
| 15 minuti | 1,03 | 1,13 | 1,25 |
| 5 minuti | **0,25** | **0,42** | **0,78** |
| 1 minuto | **0,28** | **0,28** | **0,30** |
*(profit factor: sopra 1 guadagna, sotto 1 perde)*
La colonna che risolve la questione è la terza. **Anche operando gratis, sotto i cinque
minuti si perde**: a 1 minuto il profit factor è 0,30 e il capitale scende del 99,7%.
Quindi il crollo **non è un problema di commissioni**, ed è per questo che cambiare
exchange non lo risolve. Commissioni più basse alzano ogni riga della tabella — Binance
migliorerebbe il giornaliero da 121,0% a 123,2% di CAGR — ma non spostano il punto in cui
il segno cambia. Sotto i quindici minuti il prezzo di BTC non contiene informazione
direzionale che questo modello sappia sfruttare: è rumore, e pagarlo meno non lo rende
segnale.
A questo si aggiunge un limite fisico. Da una connessione domestica in Italia il
round-trip verso i motori di matching di Binance (AWS Tokyo) è di **250-280 ms**: i
100 ms non sono raggiungibili senza un VPS in colocation in Asia. E a quella scala la
controparte è un market maker con hardware dedicato a pochi metri dal matching engine —
il lato sfavorito della negoziazione sarebbe il nostro, riempiti esattamente quando il
prezzo sta per muoversi contro.
**Il bot resta su Alpaca e su barre giornaliere**, che è dove i numeri sono migliori.
### Cosa del documento non è stato applicato, e perché
| Proposta | Esito |
|---|---|
| Scalping su Order Book Imbalance, micro-price, OFI | **Impossibile.** Servono i volumi per livello del book. Alpaca sulle crypto pubblica il top of book, non la profondità. Senza $V^{bid}$ e $V^{ask}$ per livello non c'è nulla da calcolare |
| Arbitraggio statistico BTC/ETH | **Impossibile.** La gamba corta richiede lo short, che Alpaca non consente sulle crypto. La versione "solo spot" del documento — ribilanciare i pesi invece di shortare — non è arbitraggio: è una scommessa direzionale sul rapporto, con il rischio di entrambe le gambe e la copertura di nessuna |
| Breakout su CVD/VWAP | **Implementato e misurato.** Vedi sopra: peggiora questa strategia |
| Rischio 1% per operazione, stop 0,7% | **Non applicabile.** Sono tarati su stop intraday dello 0,2-0,7%. Su barre giornaliere con uscite al 20-40% darebbero posizioni del 2-5% del conto e un CAGR sotto il 3% |
| Circuit breaker giornaliero | **Applicato**, al 25% invece che al 2%: su BTC un 20% in un giorno è successo più volte e non è una ragione per liquidare sul minimo |
| TPL Dataflow per la pipeline | **Equivalente già presente.** `System.Threading.Channels`, stessa semantica con meno allocazioni |
| SDK `Alpaca.Markets` | **Non adottato.** Il client REST e WebSocket è scritto a mano e non ha dipendenze NuGet a runtime |
---
## Perché 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 — i parametri di ETH erano copiati da quelli di BTC e non erano mai stati
validati su dati ETH.
Resta scelto per tre ragioni concrete:
- **Mercato aperto 24/7.** Nessun gap di apertura, nessun rischio overnight, nessuna
regola PDT.
- **Frazionabile.** Un conto da poche migliaia di euro può dimensionare con precisione.
- **Storia lunga e liquida.** Tredici anni di dati su più exchange indipendenti, che è
ciò che rende verificabile tutto quanto sopra.
---
## Credenziali
Risolte in quest'ordine, vince la prima che c'è:
1. variabili d'ambiente `APCA_API_KEY_ID` / `APCA_API_SECRET_KEY`
2. chiavi salvate dalla finestra di login
3. `alpaca.keyId` / `alpaca.secretKey` nel file di configurazione
| | |
|---|---|
| **Percorso** | `%LOCALAPPDATA%\Encelado\credentials.dat` (override con `ENCELADO_HOME`) |
| **Protezione** | Cifrate con **DPAPI**, legate al tuo account Windows: nessuna passphrase a ogni riavvio, e un altro utente sulla stessa macchina non può leggerle |
| **Nei log** | Mai. Solo la chiave mascherata (`PKVU************`); il secret non viene mai stampato |
Paper e live hanno chiavi diverse e vengono salvate separatamente. Le chiavi non
finiscono mai nel repository.
---
## Configurazione
**Dalla scheda Impostazioni**, che è la via normale: ogni valore è un campo, ogni campo
ha una spiegazione di cosa fa nella strategia e di cosa succede se lo cambi, e i valori
che la strategia fissa sono mostrati con il lucchetto e il motivo — non nascosti.
Il salvataggio riscrive **solo i valori cambiati**, lasciando intatto il resto del file
compresi i commenti. Prima di scrivere applica le modifiche a una copia temporanea e ci
fa girare i validatori veri: una combinazione che il bot rifiuterebbe all'avvio viene
respinta subito, con il motivo, e sul disco non finisce niente.
Il file resta [`config/encelado.json`](config/encelado.json), copiato accanto
all'eseguibile durante la build, ed è modificabile a mano per tutto ciò che la pagina
non copre. Le chiavi che iniziano con `_` sono commenti: JSON non ne ha, e un file pieno
di assunzioni di trading ne ha bisogno.
I parametri principali, con i valori consegnati:
| Chiave | Valore | Perché |
|---|---|---|
| `engine.timeFrame` | `1Day` | Sotto i 15 minuti il segnale non esiste, nemmeno a costo zero |
| `engine.warmupBars` | `220` | La media è a 100 giorni, il resto è margine |
| `parameters.period` | `100` | L'unica media che batte il buy & hold su **entrambi** i dataset |
| `parameters.band` | `0.02` | Isteresi: dimezza gli scambi senza toccare il rendimento |
| `parameters.stopPct` | `0.35` | Rete per un gap. La vera uscita è la media |
| `risk.stakePct` | `1.0` | Dentro per intero: qualsiasi frazione perde la gara col buy & hold |
| `risk.maxDailyLossPct` | `0.25` | Kill switch. Largo perché su BTC un 20% in un giorno non è una ragione per liquidare sul minimo |
**Nessun limite di frequenza.** `maxOpenPositions`, `maxTradesPerDay` e
`maxTradesPerSymbolPerDay` sono a `0`, che significa *nessun tetto*: a fermare il bot è
la strategia, non un contatore. Erano una rete contro un bug — un ciclo che riapre la
stessa posizione mille volte costa mille commissioni — e con `0` quella rete non c'è
più. Il kill switch sulla perdita giornaliera resta attivo e non è toccato da questi.
Con un solo simbolo il numero di posizioni contemporanee resta comunque 1: 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. Quel limite torna a
contare quando si aggiungono simboli.
Le modifiche hanno effetto al riavvio dell'applicazione.
### `risk.stakePct` e `risk.stakeAmount` — decidere quanto puntare
Di default la dimensione la sceglie il bot: rischia una frazione fissa dell'equity
(`maxRiskPerTradePct`) fra ingresso e stop, e ricava la quantità da lì. Con questo
criterio ogni operazione perde più o meno lo stesso importo quando va male, ma la
dimensione cambia da un'operazione all'altra.
Per fissarla tu:
| `stakePct` | `stakeAmount` | Cosa succede |
|---|---|---|
| `0` | `0` | **Default.** Decide il bot, come sopra. È la configurazione testata |
| `0.20` | `0` | Ogni ingresso impegna il 20% dell'equity |
| `0` | `2500` | Ogni ingresso impegna 2.500, qualunque sia il saldo |
| `0.20` | `2500` | Il 20% dell'equity, ma **mai più di 2.500** |
La percentuale è del saldo del conto e non può superare l'importo: è la regola che hai
chiesto. Se metti solo l'importo, quello diventa fisso.
Due conseguenze che vale la pena sapere prima di impostarli:
- **La convinzione della strategia non riduce più la dimensione.** Hai chiesto un
importo, e quello viene usato anche su un segnale debole.
- **Lo stop protegge ma non dimensiona più.** Un ingresso con stop al 40% e uno con
stop al 10% avranno la stessa dimensione, e il primo può perdere quattro volte tanto.
È esattamente ciò che il dimensionamento a rischio evitava.
`stakePct` più alto di `maxPositionNotionalPct` **non parte**: l'applicazione si ferma
con un errore invece di tagliare in silenzio la dimensione a un valore che non hai
chiesto.
### `logging` — il materiale per migliorare il bot
| Chiave | Default | Significato |
|---|---|---|
| `level` | `debug` | `trace` / `debug` / `info` / `warn` / `error` / `none`. Con `debug` finiscono nel log anche i segnali scartati e i rifiuti del risk engine, cioè il *perché il bot non ha fatto* qualcosa |
| `directory` | `logs` | **Dove salvare tutti gli output.** Relativa all'eseguibile, oppure assoluta (`D:\encelado-logs`). Si cambia anche da **Impostazioni → Log**, che verifica di potervi scrivere davvero prima di salvare, e scrive la scelta in `encelado.json` conservando ogni altra chiave e ogni commento |
| `file` | `encelado.log` | Log applicativo |
| `maxFileSizeMb` / `maxFiles` | `32` / `10` | Rotazione: `encelado.log``encelado.1.log` → … |
| `decisionLog` | `decisions.csv` | **Una riga per ogni barra valutata** |
| `executionLog` | `executions.csv` | **Una riga per ogni segnale arrivato agli ordini** |
| `tradeJournal` | `trades.jsonl` | Una riga JSON per evento d'ordine |
| `logMarketData` | `false` | Con `level: trace`, registra ogni quotazione e ogni print. File enormi |
| `statusLines` | `200` | Righe tenute dalla striscia **Attività** nella pagina Stato |
| `bufferedLines` | `5000` | Righe tenute dalla scheda **Log**. È il tetto di memoria del log in-app: qualche megabyte. Il file su disco resta completo comunque, e la scheda lo può aprire |
`decisions.csv` contiene, per ogni barra e per ogni strumento: OHLCV completo,
**volume taker-buy e delta**, ogni indicatore che la strategia pubblica (score, regime,
trend, reversion, cvd, volatilità, scalare di volatilità), la posizione al momento
(quantità, ingresso, P&L, barre in trade, stop, target), il segnale prodotto con forza
e motivazione, bid/ask/spread, età della quotazione, equity, e se la sessione era
aperta o il kill switch attivo.
`executions.csv` aggiunge, per i segnali che sono arrivati agli ordini: la fase
(`suppressed` / `risk` / `order`), l'esito, il motivo del rifiuto, la quantità
approvata, il controvalore, e la latenza in millisecondi.
I due file si uniscono su `decisionId`:
```python
import pandas as pd
d = pd.read_csv("logs/decisions.csv", parse_dates=["barTimeUtc"])
e = pd.read_csv("logs/executions.csv")
df = d.merge(e, on="decisionId", how="left", suffixes=("", "_exec"))
# Quali segnali sono stati bloccati, e da cosa?
df[df.signal != "None"].groupby(["signal", "riskReason"]).size()
# Il filtro di order flow sta scartando operazioni che sarebbero state buone?
df[(df.score > 0.25) & (df.cvd < 0.30)][["barTimeUtc", "close", "score", "cvd"]]
```
CSV e non JSON di proposito: si apre in Excel, si carica in una riga di pandas, e resta
leggibile quando una sessione lunga produce decine di migliaia di righe.
---
## Sicurezza operativa
- **Paper è il default.** Con `alpaca.paper = false` l'app chiede conferma esplicita
prima di avviare il motore su denaro reale.
- **Kill switch giornaliero** su `maxDailyLossPct`: chiude tutto e non riapre fino alla
sessione successiva.
- **Idempotenza**: ogni ordine porta un `client_order_id` univoco, quindi un retry di
rete non può duplicare una posizione.
- **Riconciliazione REST ogni 30 s**: lo stream WebSocket è la via veloce, non la
verità. Il book locale viene riallineato a `/v2/positions`.
- **Latch per simbolo**: un solo ingresso in volo per strumento.
- **Alpaca non supporta i bracket order sulle crypto**, quindi stop e target sono
gestiti dall'engine e controllati a ogni quotazione. Se il processo muore con una
posizione aperta, **quella posizione resta senza stop sul broker**. È il compromesso
principale della scelta crypto.
### Quanto spesso decide, davvero
Su barre giornaliere il modello valuta **una volta al giorno**, alla chiusura di ogni
barra, e cambia stato circa cinque volte l'anno. Due giorni senza operazioni sono
normali: la detenzione media è di **81 giorni**.
Fino alla 3.1.0 però c'era anche un difetto vero, ed era peggio di "opera di rado".
Le barre della strategia venivano costruite accumulando le barre da un minuto dello
stream: con 1440 minuti per barra, l'aggregatore chiudeva un secchiello **solo quando
arrivava un minuto appartenente al successivo**, cioè a mezzanotte UTC, e solo se il
processo era vivo in quell'istante. Avviato alle 10 e chiuso alle 18, il bot riceveva
migliaia di quotazioni, contava decine di barre e **non valutava la strategia nemmeno una
volta**. Nel log si vedeva così:
```
bars=40 signals=0 decisions.csv: 0 righe
```
Ora, a ogni riconciliazione, il bot chiede al broker le ultime barre **già chiuse** al
timeframe della strategia e valuta quelle che non ha ancora visto. Non dipende più da
quando è acceso, e decide sulle chiusure giornaliere vere invece che su una giornata
parziale ricostruita dai minuti in cui era connesso — che è anche ciò che il backtest
misura.
### Come si vede che sta lavorando
Un bot che decide una volta al giorno, visto da fuori, è indistinguibile da un bot
bloccato. Tre cose lo rendono distinguibile:
**Ogni barra in arrivo finisce nel registro** (`logging.logEveryBar`), non solo quelle
che chiudono una barra della strategia:
```
[BTC/USD] barra 14:32 O 96.410,22 H 96.480,00 L 96.388,10 C 96.455,70 vol 3,1420 (+45,48 +0,05%)
```
**Un battito ogni cinque secondi** (`engine.explainSeconds`) dice cosa farebbe adesso e
cosa aspetta — scritto solo quando cambia, quindi non riempie il registro di ripetizioni:
```
[BTC/USD] FERMO — prezzo 96.456 è 1,17% dalla media 97.600. Per comprare serve che
superi 99.552, cioè +3,21% da qui.
```
**Ogni decisione è al livello info**, non nascosta nel debug, e dice il perché in
entrambi i casi:
```
[BTC/USD] barra 1Day CHIUSA 2026-08-04 00:00 C 99.812,40 — valuto la strategia
[BTC/USD] COMPRO — prezzo sopra la media di +2,27%
```
### Se lo stream dati viene rifiutato
Alpaca consente **una sola connessione ai dati di mercato per conto**. Se un'altra
istanza di Encelado è viva, o se una è stata chiusa male e il server non ha ancora
liberato lo slot, il nuovo tentativo riceve `406 connection limit exceeded`.
Il bot ora se ne accorge, lo dice in cima alla pagina Stato invece di lasciarlo nel file
di log, chiude subito il socket rifiutato e aspetta oltre i dieci secondi che il server
impiega a liberare uno slot non autenticato.
Quest'ultimo punto è il motivo per cui prima non si riprendeva da solo. Riconnettendosi
ogni tre secondi, il client apriva il socket successivo **mentre quello rifiutato teneva
ancora occupato l'unico slot disponibile**: si negava il servizio da sé, indefinitamente.
A peggiorare le cose, il contatore dei fallimenti veniva azzerato all'apertura del
socket anziché quando la sessione andava davvero a buon fine, così il backoff
esponenziale non cresceva mai oltre il primo gradino.
Se lo vedi: chiudi le altre istanze e aspetta. Si riprende da solo.
---
## Struttura
```
src/
Encelado.Core/ modelli, indicatori, strategie, risk engine, portfolio, backtest
Encelado.Alpaca/ client REST e stream WebSocket scritti su misura
Encelado.Bot/ l'applicazione WPF
Ui/ view model, convertitori, grafici, tema
Pages/ le sette pagine del menu laterale
Theme.xaml tavolozza, stili e template dei controlli
Assets/ icona multi-risoluzione (16→256 px)
tests/
Encelado.Tests/ 309 test
tools/
Encelado.Backtest/ banco di prova, non fa parte dell'applicazione
config/
encelado.json la configurazione consegnata
build/
Release.proj verifica, pacchetto e rilascio — un file MSBuild, nessuno script
Encelado.iss script Inno Setup, lanciato da Release.proj
gitea.example.json modello per gitea.json, che resta fuori dal repository
```
### Il banco di prova
`tools/Encelado.Backtest` non finisce nell'installer e non ha un menu nel bot: serve a
rispondere alla sola domanda che conta prima di toccare una taratura, cioè *questa
modifica regge su dati che non ho usato per farla?*
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.
```powershell
dotnet build tools/Encelado.Backtest -c Release
$bt = "tools/Encelado.Backtest/bin/Release/net10.0/backtest.exe"
& $bt split --file dati.csv --tf 1d --boundary 2019-01-01 # taratura / verifica
& $bt walk --file dati.csv --tf 1d # un anno per riga
& $bt frequency --file dati.csv # 1d contro 15m
& $bt costs --file dati.csv --tf 1d # sensibilità ai costi
& $bt sweep --file dati.csv --tf 1d --boundary 2019-01-01 # griglia in parallelo
& $bt split --file dati.csv --set "minRegime=0.30,trailAtrMult=7.0"
```
Legge file da centinaia di megabyte perché ripiega le barre in streaming: un file da un
minuto di 342 MB sono 6,8 milioni di barre e mezzo gigabyte di oggetti vivi se caricato
com'è, meno di cinquemila barre se ripiegato in giorni mentre lo si legge.
```powershell
dotnet test Encelado.slnx
```
### Scelte tecniche
| Decisione | Motivo |
|---|---|
| **WPF, non un browser** | Applicazione desktop vera: una finestra, un processo, nessun server locale né porta aperta |
| **Zero dipendenze NuGet a runtime** | DPAPI è nel framework Windows Desktop; client REST e WebSocket sono scritti a mano |
| **Client Alpaca su misura** | Alpaca serializza i numeri come stringhe (`"qty": "10"`): il parsing manuale è più semplice *e* più veloce dell'SDK |
| **WebSocket, mai polling** | Barre e fill arrivano in push; il REST serve solo per ordini e riconciliazione |
| **Indicatori incrementali O(1)** | Aggiornano lo stato, non ricalcolano la finestra |
| **Backtest in `Encelado.Core`** | Non nella UI: è la stessa libreria che gira in produzione, quindi il replay misura il codice vero |
---
## Limiti noti
- **Su barre giornaliere il bot decide una volta al giorno.** È corretto — è ciò che
rende il sistema sostenibile rispetto ai costi — ma vederlo "lavorare" è poco
spettacolare: passano settimane fra un'operazione e l'altra.
- **Il backtest è su BTC.** I parametri di ETH sono gli stessi con un target di
volatilità più alto, ma non li ho validati su dati ETH: non ho il file.
- **Un backtest non è una previsione.** Nove anni sono 43 operazioni, di cui 7 fuori
campione: statisticamente pochissime. Il risultato fuori campione è coerente con
quello in campione, il che è incoraggiante, non probante.
- **Il filtro di order flow dipende dal campo `tks` del feed Alpaca.** Se in futuro
quel campo sparisse, il filtro si disattiverebbe silenziosamente e il modello
tornerebbe al comportamento precedente (peggiore, ma non rotto). Il log lo registra:
la colonna `cvd` in `decisions.csv` resterebbe a zero.
- Alpaca consente **una sola** connessione market data per account.
---
## Disclaimer
Software per trading automatico. Le strategie incluse sono state validate su dati
storici, il che non garantisce nulla sul futuro. Usa `paper` finché non capisci
esattamente cosa fa il bot con i tuoi parametri. Il rischio di perdita è reale e
interamente tuo.