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>
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.
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
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:
un solo file MSBuild in build/Release.proj, nessuno script. I dettagli stanno in
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. |
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'è:
- variabili d'ambiente
APCA_API_KEY_ID/APCA_API_SECRET_KEY - chiavi salvate dalla finestra di login
alpaca.keyId/alpaca.secretKeynel 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, 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:
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 = falsel'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_idunivoco, 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.
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.
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
tksdel 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 colonnacvdindecisions.csvresterebbe 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.