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>
This commit is contained in:
2026-09-23 11:14:06 +02:00
co-authored by Claude Fable 5.1
parent aaec965241
commit c5f2b7d852
17 changed files with 793 additions and 19 deletions
+8
View File
@@ -2,6 +2,14 @@
Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto. Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto.
## 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 ## 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`. - `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`.
+7
View File
@@ -79,6 +79,13 @@
"marginCallCloseRatio": 1.2 "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
},
"_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.", "_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.",
"baskets": [ "baskets": [
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" }, { "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
+2 -2
View File
@@ -78,7 +78,7 @@ src/Encelado.Core/Baskets/ matematica e logica pura, senza I/O:
SymbolSeries (barre + quote + qualità dati), BasketDecider (entrate/uscite/averaging di §5), CostGate, VolParitySizing, BasketExecutor (protocollo leg-risk con il registro), SymbolSeries (barre + quote + qualità dati), BasketDecider (entrate/uscite/averaging di §5), CostGate, VolParitySizing, BasketExecutor (protocollo leg-risk con il registro),
BasketPosition (macchina a stati, con PendingA/PendingB), PendingEntry (ingresso in sospeso), BasketStrategyConfig (strategy.json, preset), ExecutionMode (Paper | Demo | Live), BasketPosition (macchina a stati, con PendingA/PendingB), PendingEntry (ingresso in sospeso), BasketStrategyConfig (strategy.json, preset), ExecutionMode (Paper | Demo | Live),
OrderTracker (registro persistente degli ordini, risoluzione per orderId / riferimento / posizioni — ADR-0009), PositionClassifier (basket | orfana-bot | esterna), OrderTracker (registro persistente degli ordini, risoluzione per orderId / riferimento / posizioni — ADR-0009), PositionClassifier (basket | orfana-bot | esterna),
EquityTracker (picco al netto dei movimenti di cassa), FlattenProcedure (kill-switch in tre passi: annulla, chiudi, verifica la piattezza) EquityTracker (picco al netto dei movimenti di cassa), FlattenProcedure (kill-switch in tre passi: annulla, chiudi, verifica la piattezza), Heartbeat/HeartbeatFile, InstanceLock, RecoveryPlanner (barre trascorse, chiudi/tieni, rapporto)
src/Encelado.Core/Notifications/ INotifier, NullNotifier (dalla Fase 5 TelegramNotifier) src/Encelado.Core/Notifications/ INotifier, NullNotifier (dalla Fase 5 TelegramNotifier)
src/Encelado.Core/Baskets/History/ OrderRecord (riga di orders.jsonl); dalla Fase 7 PositionRecord, PeriodStats, HistoryBuilder src/Encelado.Core/Baskets/History/ OrderRecord (riga di orders.jsonl); dalla Fase 7 PositionRecord, PeriodStats, HistoryBuilder
src/Encelado.Core/Baskets/Data/ BidAskBar + CSV, TickToBars (tick MT5 → M15) src/Encelado.Core/Baskets/Data/ BidAskBar + CSV, TickToBars (tick MT5 → M15)
@@ -89,7 +89,7 @@ src/Encelado.Core/News/ parser puri: CalendarParser (JSON/XML FairEconom
src/Encelado.Core/Ml/, Statistics/ la statistica condivisa rimasta: Classification (AUC, Brier, log-loss, calibrazione), Pbo (CSCV), Performance (Sharpe, PSR, DSR, drawdown, momenti), Ols, Normal src/Encelado.Core/Ml/, Statistics/ la statistica condivisa rimasta: Classification (AUC, Brier, log-loss, calibrazione), Pbo (CSCV), Performance (Sharpe, PSR, DSR, drawdown, momenti), Ols, Normal
src/Encelado.Etoro/ EtoroOptions, EtoroHttp (HttpClient, x-api-key/x-user-key/x-request-id, limitatore per classe di quota, 429 con Retry-After, scarto orologio dall'header Date), EtoroBroker : IBroker src/Encelado.Etoro/ EtoroOptions, EtoroHttp (HttpClient, x-api-key/x-user-key/x-request-id, limitatore per classe di quota, 429 con Retry-After, scarto orologio dall'header Date), EtoroBroker : IBroker
src/Encelado.Bot/Baskets/ BasketEngine in file parziali: BasketEngine.cs (ciclo a thread singolo, polling quote, barre locali, decisioni, esecuzione), .Pending.cs (registro ordini: risoluzione e src/Encelado.Bot/Baskets/ BasketEngine in file parziali: BasketEngine.cs (ciclo a thread singolo, polling quote, barre locali, decisioni, esecuzione), .Pending.cs (registro ordini: risoluzione e
ripresa degli ingressi in sospeso), .Reconcile.cs (conto, classificazione delle posizioni, adozione delle orfane, movimenti di cassa, margin guard, equity stop), .Kill.cs (file STOP, kill-switch con verifica di piattezza e stato Halted-Residuo, chiusura dei residui, reset in cinque passi), ripresa degli ingressi in sospeso), .Reconcile.cs (conto, classificazione delle posizioni, adozione delle orfane, movimenti di cassa, margin guard, equity stop), .Kill.cs (file STOP, kill-switch con verifica di piattezza e stato Halted-Residuo, chiusura dei residui, reset in cinque passi), .Recovery.cs (lock di istanza, heartbeat, inattività, procedura di recupero),
.State.cs (baskets_state.json), .Commands.cs (comandi, bonifica), .Snapshot.cs (snapshot per finestra e console), .State.cs (baskets_state.json), .Commands.cs (comandi, bonifica), .Snapshot.cs (snapshot per finestra e console),
Ledger (decisions.jsonl e orders.jsonl append-only, baskets.csv, firme delle decisioni, rotazione mensile, scritture atomiche), Feeds (calendario + RSS con cache su disco, robots.txt, backoff), Ledger (decisions.jsonl e orders.jsonl append-only, baskets.csv, firme delle decisioni, rotazione mensile, scritture atomiche), Feeds (calendario + RSS con cache su disco, robots.txt, backoff),
LearningState (modello in ombra, bandit, ciclo settimanale, knowledge/), HeadlessRunner (--headless) LearningState (modello in ombra, bandit, ciclo settimanale, knowledge/), HeadlessRunner (--headless)
+1 -2
View File
@@ -27,13 +27,12 @@ Aggiornato: 2026-09-23. Una voce per limite, con lo stato. Quando un limite vien
## Bot ## Bot
- **Una sola istanza** per cartella di lavoro: non c'è un lock; due bot sullo stesso conto si contendono le posizioni. Documentato nel runbook, non imposto dal codice.
- Le posizioni salvate in `baskets_state.json` da una modalità diversa non vengono riprese (si riparte dalla riconciliazione del conto). - Le posizioni salvate in `baskets_state.json` da una modalità diversa non vengono riprese (si riparte dalla riconciliazione del conto).
- Il ledger delle sessioni Demo del 16-21/9 (le 65 righe `segnale_ingresso`/`rifiuto` del post-mortem) non è su questa macchina; l'analisi si basa sullo storico del conto letto via API (D-36). - Il ledger delle sessioni Demo del 16-21/9 (le 65 righe `segnale_ingresso`/`rifiuto` del post-mortem) non è su questa macchina; l'analisi si basa sullo storico del conto letto via API (D-36).
- La finestra WPF mostra i contatori nuovi (in attesa, orfane, esterne, P&L del conto) nei riquadri esistenti; la scheda Storico, la bonifica con pulsante e la navigazione a sinistra arrivano con la web UI (Fasi 6-7, dopo D-28). In attesa, la bonifica si lancia da headless (`--bonifica`). - La finestra WPF mostra i contatori nuovi (in attesa, orfane, esterne, P&L del conto) nei riquadri esistenti; la scheda Storico, la bonifica con pulsante e la navigazione a sinistra arrivano con la web UI (Fasi 6-7, dopo D-28). In attesa, la bonifica si lancia da headless (`--bonifica`).
- 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. - 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.
- Il ciclo settimanale gira solo mentre il bot è acceso la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni). - Il ciclo settimanale gira solo mentre il bot è acceso la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni).
- La finestra e l'headless usano lo stesso log e lo stesso ledger: se si avviano insieme le righe si mescolano. - La finestra e l'headless usano lo stesso log: il lock di istanza (5.0) impedisce che due motori girino sulla stessa cartella dati, ma la finestra aperta senza AVVIA scrive comunque nel log mentre gira l'headless.
## Codice ## Codice
+9 -1
View File
@@ -16,7 +16,7 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15
| `cross` | testo | cross sintetico (`EURCHF`) | | `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` | | `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` | | `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` | | `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`) |
| `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` | | `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` |
| `buy_cross` | bool | verso deciso (compra il cross = compra entrambe le gambe nei cinque basket) | | `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) | | `z`, `z_in_eff` | numero | z-score del cross e soglia effettiva (scalata dalla vol prevista) |
@@ -78,6 +78,14 @@ Il registro degli ordini: `savedUtc` e l'array `orders` con gli stessi campi di
`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`. `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) ## `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`. Una riga per orfana chiusa dalla bonifica: `ts;position_id;strumento;verso;unita;aperta_utc;pnl_realizzato;basket;motivazione`.
+3 -1
View File
@@ -38,7 +38,9 @@ Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e c
| 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 | | 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 | | 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 | | 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 |
| 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 15 minuti; rifiutato (`reset_rifiutato`) finché una posizione del bot resta sul conto | codice | 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 | | 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 | | 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 | | Ambiente visibile | badge `PAPER/DEMO/LIVE` nella barra, nel log e nel ledger (`mode`) | codice | nessuno |
+9 -3
View File
@@ -1,6 +1,6 @@
# Runbook # Runbook
Aggiornato: 2026-09-23 (5.0, Fase 3). Come si avvia, si ferma, si sblocca e si ripara il bot. I file dell'operatore stanno in `Documenti\Encelado\`; le chiavi in `%LOCALAPPDATA%\Encelado\etoro.dat`. Aggiornato: 2026-09-23 (5.0, Fase 4). Come si avvia, si ferma, si sblocca e si ripara il bot. I file dell'operatore stanno in `Documenti\Encelado\`; le chiavi in `%LOCALAPPDATA%\Encelado\etoro.dat`.
## Prima volta ## Prima volta
@@ -29,7 +29,7 @@ Encelado.exe --headless [--minutes 240] [--confirm-live "CONFERMO LIVE"]
Log sulla console e nel file; una riga di stato ogni `run.statusSeconds`. Comandi da tastiera: `status`, `close <basket>`, `kill`, `preset <nome>`, `reset <motivazione>`, `bonifica`, `stop`. Argomento `--bonifica`: parte senza chiudere le orfane e le propone una per una. Variabile `ENCELADO_EXECUTION_MODE` per forzare la modalità senza toccare il file. Log sulla console e nel file; una riga di stato ogni `run.statusSeconds`. Comandi da tastiera: `status`, `close <basket>`, `kill`, `preset <nome>`, `reset <motivazione>`, `bonifica`, `stop`. Argomento `--bonifica`: parte senza chiudere le orfane e le propone una per una. Variabile `ENCELADO_EXECUTION_MODE` per forzare la modalità senza toccare il file.
**Una sola istanza per cartella di lavoro**: due bot sullo stesso conto e sullo stesso ledger si contendono le posizioni. Prima di aprire la finestra mentre gira l'headless, fermalo. **Una sola istanza per cartella di lavoro**, imposta dal lock `data/state/instance.lock`: un secondo avvio (finestra o headless) sulla stessa cartella viene rifiutato con il pid del primo.
## Fermare ## Fermare
@@ -84,6 +84,12 @@ Ogni 20 secondi il bot rilegge conto e posizioni e **classifica ogni posizione**
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. 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.
## Recupero dopo inattività (riavvio, sospensione, 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.
**Una sola istanza** per cartella dati è imposta dal file `data/state/instance.lock`, tenuto aperto in esclusiva dal motore: un secondo avvio sulla stessa cartella si ferma con «un'altra istanza di Encelado sta usando questa cartella dati» e il pid della prima. Un crash rilascia il lock con il processo: non c'è mai un lock stantio da cancellare a mano.
## Bonifica delle gambe orfane ## Bonifica delle gambe orfane
Una tantum, dopo un'anomalia: avvia il bot con `--headless --bonifica`. Il motore parte **senza** chiudere le orfane da solo, le elenca con P&L e motivo della classificazione insieme alle posizioni esterne, e per ogni orfana chiede `chiudere? [s/N]`. 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. Lo stesso comando si lancia dalla console con `bonifica`. Al 2026-09-23 il conto demo è piatto: non c'è niente da bonificare. Una tantum, dopo un'anomalia: avvia il bot con `--headless --bonifica`. Il motore parte **senza** chiudere le orfane da solo, le elenca con P&L e motivo della classificazione insieme alle posizioni esterne, e per ogni orfana chiede `chiudere? [s/N]`. 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. Lo stesso comando si lancia dalla console con `bonifica`. Al 2026-09-23 il conto demo è piatto: non c'è niente da bonificare.
@@ -108,7 +114,7 @@ Calendario e notizie sono in cache su disco (`data/cache`) e vengono riletti ogn
|---|---| |---|---|
| log | `Documenti\Encelado\logs\encelado.log` (CSV `;`) | | log | `Documenti\Encelado\logs\encelado.log` (CSV `;`) |
| ledger | `data\ledger\decisions.jsonl`, `data\ledger\baskets.csv`, `data\ledger\orders.jsonl` | | ledger | `data\ledger\decisions.jsonl`, `data\ledger\baskets.csv`, `data\ledger\orders.jsonl` |
| stato | `data\state\baskets_state.json` (ripreso all'avvio), `data\state\pending_orders.json` (registro degli ordini) | | stato | `data\state\baskets_state.json` (ripreso all'avvio), `data\state\pending_orders.json` (registro degli ordini), `data\state\heartbeat.json`, `data\state\instance.lock` |
| barre | `data\market\candles_<SYMBOL>_M15.csv` | | barre | `data\market\candles_<SYMBOL>_M15.csv` |
| modelli | `data\models\` | | modelli | `data\models\` |
| conoscenza | `knowledge\` | | conoscenza | `knowledge\` |
+7 -7
View File
@@ -1,25 +1,25 @@
# Stato del lavoro # Stato del lavoro
Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-3 concluse). Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-4 concluse).
## Fase in corso ## Fase in corso
**Piano 5.0, Fase 4** (`docs/PIANO_5.0.md`). Le Fasi 0-3 sono committate: post-mortem, registro degli ordini, stati `PendingA`/`PendingB`, classificazione e chiusura delle orfane, picco al netto dei movimenti di cassa, bonifica, limiti di margine, kill-switch con verifica di piattezza e reset in cinque passi. La «correttezza dell'esecuzione» richiesta dalla regola finale del piano è coperta: il Demo **può ripartire** per le 24 ore di verifica (contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti, `sizing_bound = margin` sui primi ingressi), e la prova manuale del kill-switch con verifica di piattezza va fatta in quella sessione. **Piano 5.0, Fase 5** (`docs/PIANO_5.0.md`). Le Fasi 0-4 sono committate: post-mortem, registro degli ordini, stati `PendingA`/`PendingB`, classificazione e chiusura delle orfane, picco al netto dei movimenti di cassa, bonifica, limiti di margine, kill-switch con verifica di piattezza e reset in cinque passi. La «correttezza dell'esecuzione» richiesta dalla regola finale del piano è coperta: il Demo **può ripartire** per le 24 ore di verifica (contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti, `sizing_bound = margin` sui primi ingressi), e la prova manuale del kill-switch con verifica di piattezza va fatta in quella sessione.
## Fatto nell'ultima sessione (2026-09-23) ## Fatto nell'ultima sessione (2026-09-23)
- **Fase 0**: diagnosi verificata sul codice e sul conto demo via API (sola lettura): `referenceID` nullo sugli ordini v2 (ecco il 404), 21 gambe orfane del 16-21/9 chiuse a mano il 21/9, primi due ordini a 100 % del margine, poi ordini ridotti dal server a 2 000 USD di margine. Endpoint per `orderId` e cancellazione verificati (D-26, D-27). `docs/PIANO_5.0.md`, `docs/POSTMORTEM_ordini_pendenti.md`, `docs/QUESTIONS.md` D-26…D-37. - **Fase 0**: diagnosi verificata sul codice e sul conto demo via API (sola lettura): `referenceID` nullo sugli ordini v2 (ecco il 404), 21 gambe orfane del 16-21/9 chiuse a mano il 21/9, primi due ordini a 100 % del margine, poi ordini ridotti dal server a 2 000 USD di margine. Endpoint per `orderId` e cancellazione verificati (D-26, D-27). `docs/PIANO_5.0.md`, `docs/POSTMORTEM_ordini_pendenti.md`, `docs/QUESTIONS.md` D-26…D-37.
- **Fase 4**: heartbeat, lock di istanza, sezione `recovery`, procedura di recupero dopo inattività con rapporto `recupero_<run_id>.csv`; test (r); 211 verdi.
- **Fase 3**: `FlattenProcedure` (annulla, chiudi, verifica), kill-switch che chiude basket + gambe in attesa + orfane (esterne opzionali) e dichiara `Halted-Residuo` quando il conto non è piatto, comando `residuo`, reset in cinque passi rifiutato con residui, `INotifier`; test (v)-(x); 204 verdi. - **Fase 3**: `FlattenProcedure` (annulla, chiudi, verifica), kill-switch che chiude basket + gambe in attesa + orfane (esterne opzionali) e dichiara `Halted-Residuo` quando il conto non è piatto, comando `residuo`, reset in cinque passi rifiutato con residui, `INotifier`; test (v)-(x); 204 verdi.
- **Fase 2**: sezione `risk`, sizing = min(rischio, margine) con `sizing_bound`/`marginUsd` nel ledger, ricontrollo del disponibile prima della gamba B, esecuzione dei segnali per |z| con rilettura del conto, margin guard, backtest con gli stessi limiti; test (y), (z); 196 verdi. - **Fase 2**: sezione `risk`, sizing = min(rischio, margine) con `sizing_bound`/`marginUsd` nel ledger, ricontrollo del disponibile prima della gamba B, esecuzione dei segnali per |z| con rilettura del conto, margin guard, backtest con gli stessi limiti; test (y), (z); 196 verdi.
- **Fase 1**: `OrderTracker` + `pending_orders.json` + `orders.jsonl`; `EtoroBroker.OpenAsync` per `orderId` con riconoscimento dalla posizione; `BasketExecutor` con `ResumeAfterAAsync`/`CompleteAfterBAsync`/`UnwindLegAsync`; stati `PendingA`/`PendingB` persistiti; `PositionClassifier` e chiusura delle orfane; `EquityTracker`; contatori e P&L del conto nello snapshot e in dashboard; `--bonifica`; bandit senza applicazione automatica; `BasketEngine` in sei file parziali; ADR-0009; 18 test nuovi, 190 verdi. - **Fase 1**: `OrderTracker` + `pending_orders.json` + `orders.jsonl`; `EtoroBroker.OpenAsync` per `orderId` con riconoscimento dalla posizione; `BasketExecutor` con `ResumeAfterAAsync`/`CompleteAfterBAsync`/`UnwindLegAsync`; stati `PendingA`/`PendingB` persistiti; `PositionClassifier` e chiusura delle orfane; `EquityTracker`; contatori e P&L del conto nello snapshot e in dashboard; `--bonifica`; bandit senza applicazione automatica; `BasketEngine` in sei file parziali; ADR-0009; 18 test nuovi, 190 verdi.
## Prossimi passi ## Prossimi passi
1. **Fase 4recupero dopo inattività**6): heartbeat, sezione `recovery` in `strategy.json`, procedura di recupero, lock di istanza, rapporto `recupero_<run_id>.csv`. Test (r). 1. **Fase 5Telegram**7): `TelegramNotifier`, stato orario, eventi, riepilogo giornaliero, comandi in ingresso. Test (s)-(u).
2. **Fase 5 — Telegram** (§7). Test (s)-(u). 2. **Dopo la risposta a D-28/D-29**: Fasi 6-9 (Engine/Server, web UI M3, Docker, Unraid, skill, 5.0.0).
3. **Dopo la risposta a D-28/D-29**: Fasi 6-9 (Engine/Server, web UI M3, Docker, Unraid, skill, 5.0.0). 3. Riaccendere il Demo per 24 ore di verifica (vedi sopra), provare il kill-switch a mano con la verifica di piattezza e fermare il bot un'ora con un basket aperto per vedere il recupero.
4. Riaccendere il Demo per 24 ore di verifica (vedi sopra) e provare il kill-switch a mano con la verifica di piattezza. 4. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`.
5. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`.
## Problemi aperti ## Problemi aperti
@@ -15,8 +15,8 @@ namespace Encelado.Bot.Baskets;
/// </summary> /// </summary>
public sealed partial class BasketEngine public sealed partial class BasketEngine
{ {
/// <summary>How long the entries stay blocked after a reset, while the quotes settle (from <c>recovery.warmupMinutes</c> in Phase 4).</summary> /// <summary>How long the entries stay blocked after a reset, while the quotes settle (<c>recovery.warmupMinutes</c>).</summary>
private static readonly TimeSpan ResetWarmup = TimeSpan.FromMinutes(15); private TimeSpan ResetWarmup => TimeSpan.FromMinutes(_strategy.Recovery.WarmupMinutes);
private List<PositionInfo> _haltResidue = []; private List<PositionInfo> _haltResidue = [];
private DateTime? _entriesBlockedUntilUtc; private DateTime? _entriesBlockedUntilUtc;
@@ -0,0 +1,235 @@
using System.Globalization;
using System.Text;
using Encelado.Bot.Logging;
using Encelado.Core.Baskets;
using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets;
/// <summary>
/// Heartbeat, instance lock and the recovery after an inactivity (§6 of the 5.0 plan):
/// a restart, a suspended computer, a frozen container. Whatever the cause, when more
/// than <c>recovery.thresholdMinutes</c> went by without the bot looking at the market,
/// every open position is judged again as at an ordinary bar close — with the bars that
/// passed — before any new entry is allowed.
/// </summary>
public sealed partial class BasketEngine
{
private static readonly TimeSpan BarInterval = TimeSpan.FromMinutes(15);
private InstanceLock? _instanceLock;
private DateTime _lastHeartbeatUtc;
private DateTime _lastTickUtc;
private bool _recoveryPending;
private TimeSpan _recoveryInactivity;
private string _recoveryCause = string.Empty;
private DateTime _recoveryDeferredWarnUtc;
private string HeartbeatPath => Path.Combine(_dataDir, "state", "heartbeat.json");
/// <summary>One engine per data folder; refused with the holder's pid when another one is running.</summary>
private void AcquireInstanceLock()
{
_instanceLock = InstanceLock.Acquire(Path.Combine(_dataDir, "state", "instance.lock"), _runId);
}
private void ReleaseInstanceLock()
{
_instanceLock?.Dispose();
_instanceLock = null;
}
private void WriteHeartbeat(DateTime now, string note = "", bool force = false)
{
if (!force && now - _lastHeartbeatUtc < TimeSpan.FromSeconds(_strategy.Recovery.HeartbeatSeconds))
{
return;
}
_lastHeartbeatUtc = now;
try
{
HeartbeatFile.Write(HeartbeatPath, new Heartbeat(now, _runId, ModeLabel, _slots.Count(static s => s.Position is not null), _slots.Count(static s => s.State.IsPending()), Environment.ProcessId, note));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"heartbeat non scritto: {ex.Message}");
}
}
/// <summary>At startup: the previous heartbeat says how long the bot was away.</summary>
private void CheckInactivityAtStartup()
{
Heartbeat? previous = HeartbeatFile.Read(HeartbeatPath);
if (previous is null)
{
Log.Info("nessun heartbeat precedente: primo avvio su questa cartella dati");
return;
}
TimeSpan gap = previous.Inactivity(DateTime.UtcNow);
Log.Info(string.Create(CultureInfo.InvariantCulture, $"ultimo heartbeat {previous.Utc:yyyy-MM-dd HH:mm:ss} UTC (run {previous.RunId}, {previous.OpenBaskets} basket aperti, {previous.PendingBaskets} in attesa): {gap.TotalMinutes:0} minuti fa"));
if (gap > TimeSpan.FromMinutes(_strategy.Recovery.ThresholdMinutes))
{
ScheduleRecovery(gap, $"riavvio dopo {gap.TotalMinutes:0} minuti senza heartbeat");
}
}
/// <summary>On every tick: a gap between two ticks is a suspended computer or a frozen process.</summary>
private void CheckInactivityOnTick(DateTime now)
{
if (_lastTickUtc != default)
{
TimeSpan gap = now - _lastTickUtc;
if (gap > TimeSpan.FromMinutes(_strategy.Recovery.ThresholdMinutes))
{
ScheduleRecovery(gap, $"il ciclo si è fermato per {gap.TotalMinutes:0} minuti (sospensione o blocco del processo)");
}
}
_lastTickUtc = now;
}
private void ScheduleRecovery(TimeSpan inactivity, string cause)
{
if (_recoveryPending)
{
_recoveryInactivity = inactivity > _recoveryInactivity ? inactivity : _recoveryInactivity;
return;
}
_recoveryPending = true;
_recoveryInactivity = inactivity;
_recoveryCause = cause;
_entriesBlocked ??= "recupero dopo inattività in corso";
Log.Warn($"RECUPERO programmato: {cause}; nessuna nuova entrata finché non è concluso");
_ledger.Note(_runId, "recupero_avviato", string.Empty, cause, w =>
{
w.WriteNumber("inattivita_min", Math.Round(inactivity.TotalMinutes, 1));
w.WriteNumber("barre", RecoveryPlanner.BarsElapsed(inactivity, BarInterval));
});
}
/// <summary>
/// Runs the recovery once the quotes are fresh. With the market closed (a weekend)
/// nothing irreversible happens: the procedure waits for the first live quotes and
/// says so every ten minutes.
/// </summary>
private async Task RunRecoveryIfDueAsync(DateTime now, CancellationToken ct)
{
if (!_recoveryPending)
{
return;
}
bool needsQuotes = _slots.Any(static s => s.Position is not null || s.State.IsPending());
bool quotesFresh = !needsQuotes || _slots.Where(static s => s.Position is not null || s.State.IsPending())
.All(s => s.A.HasQuote && s.B.HasQuote && (now - s.A.QuoteSeenUtc) < TimeSpan.FromSeconds(60) && (now - s.B.QuoteSeenUtc) < TimeSpan.FromSeconds(60));
if (!quotesFresh)
{
if (now - _recoveryDeferredWarnUtc > TimeSpan.FromMinutes(10))
{
_recoveryDeferredWarnUtc = now;
Log.Warn("recupero in attesa di quotazioni fresche (mercato chiuso o feed fermo): niente di irreversibile finché non riaprono");
}
return;
}
_recoveryPending = false;
List<RecoveryRow> rows = [];
int barsElapsed = RecoveryPlanner.BarsElapsed(_recoveryInactivity, BarInterval);
try
{
// 2. Orders without an outcome, then the account.
await ResolvePendingOrdersAsync(ct, force: true).ConfigureAwait(false);
await ReconcileAsync(ct).ConfigureAwait(false);
// 3. The bars that went by: from the venue into the series, and into the holding time of every open basket.
await WarmupAsync(ct).ConfigureAwait(false);
foreach (BasketSlot slot in _slots)
{
if (slot.Position is { } p && barsElapsed > 0)
{
p.BarsHeld += barsElapsed;
}
}
// 4. Every open basket judged as at an ordinary bar close.
foreach (BasketSlot slot in _slots)
{
if (slot.Position is not { } p || slot.Busy || !slot.A.HasQuote || !slot.B.HasQuote)
{
continue;
}
BasketContext ctx = await BuildContextAsync(slot, now, isBarClose: true, ct).ConfigureAwait(false);
BasketDecision d = _decider.Evaluate(ctx);
slot.LastDecision = d;
slot.LastEvaluation = d.Evaluation;
RecoveryRow row = RecoveryPlanner.ForBasket(slot.Name, slot.PositionBasketId, d, p.BarsHeld);
rows.Add(row);
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, d.Kind == DecisionKind.Exit ? "segnale_uscita" : "posizione", slot.PositionBasketId, "valutazione di recupero dopo inattività");
if (d.Kind == DecisionKind.Exit)
{
Log.Warn($"[{slot.Name}] recupero: CHIUDO — {d.Motivazione}");
await ExecuteExitAsync(slot, ctx, d, d.Motivazione, d.ReasonCodes.FirstOrDefault() ?? "exit", ct).ConfigureAwait(false);
}
else
{
slot.Intent = "TENUTO dopo il recupero — " + d.Motivazione;
Log.Info($"[{slot.Name}] recupero: TENGO — {d.Motivazione}");
}
}
// 5. Orphans were closed by the reconciliation; strangers are only reported.
foreach (ClassifiedPosition c in _classified)
{
if (c.Origin == PositionOrigin.OrphanBot)
{
rows.Add(RecoveryPlanner.ForOrphan(c));
}
else if (c.Origin == PositionOrigin.Foreign)
{
rows.Add(RecoveryPlanner.ForForeign(c));
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Error("recupero dopo inattività fallito: entrate bloccate finché un reset non lo chiude", ex);
_entriesBlocked = $"recupero fallito: {ex.Message}";
return;
}
// 7. The report, the ledger, the notification; entries blocked for the warm-up.
string summary = RecoveryPlanner.Summary(_recoveryInactivity, rows);
WriteRecoveryReport(rows);
_ledger.Note(_runId, "recupero_concluso", string.Empty, $"{_recoveryCause}: {summary}", w =>
{
w.WriteNumber("inattivita_min", Math.Round(_recoveryInactivity.TotalMinutes, 1));
w.WriteNumber("barre", barsElapsed);
w.WriteNumber("chiusi", rows.Count(static r => r.Decisione == "chiudi"));
w.WriteNumber("tenuti", rows.Count(static r => r.Decisione == "tieni"));
});
_entriesBlockedUntilUtc = now + TimeSpan.FromMinutes(_strategy.Recovery.WarmupMinutes);
_entriesBlocked = string.Create(CultureInfo.InvariantCulture, $"riscaldamento dopo il recupero fino alle {_entriesBlockedUntilUtc:HH:mm} UTC");
Log.Warn($"RECUPERO concluso: {summary}; entrate bloccate per {_strategy.Recovery.WarmupMinutes} minuti");
_notifier.Notify(NotificationKind.Alert, "Recupero dopo inattività", $"{_recoveryCause}\n{summary}\n" + string.Join('\n', rows.Select(static r => $"{r.Decisione} {(r.Basket.Length > 0 ? r.Basket : r.Position)} ({r.Motivo})")));
SaveState();
}
private void WriteRecoveryReport(IReadOnlyList<RecoveryRow> rows)
{
try
{
Directory.CreateDirectory(_config.Run.ReportsPath);
string path = Path.Combine(_config.Run.ReportsPath, $"recupero_{_runId}.csv");
File.WriteAllText(path, RecoveryPlanner.Csv(rows), new UTF8Encoding(false));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"rapporto di recupero non scritto: {ex.Message}");
}
}
}
@@ -278,6 +278,8 @@ public sealed partial class BasketEngine : IEngine
{ {
Log.Info($"motore basket: {_mode} su {_broker.Name}, preset {PresetLabel}, strategia {_configHash}, run {_runId}"); Log.Info($"motore basket: {_mode} su {_broker.Name}, preset {PresetLabel}, strategia {_configHash}, run {_runId}");
AcquireInstanceLock();
CheckInactivityAtStartup();
await StartupChecksAsync(ct).ConfigureAwait(false); await StartupChecksAsync(ct).ConfigureAwait(false);
await LoadInstrumentsAsync(ct).ConfigureAwait(false); await LoadInstrumentsAsync(ct).ConfigureAwait(false);
await WarmupAsync(ct).ConfigureAwait(false); await WarmupAsync(ct).ConfigureAwait(false);
@@ -303,6 +305,7 @@ public sealed partial class BasketEngine : IEngine
} }
SaveState(); SaveState();
WriteHeartbeat(DateTime.UtcNow, "avvio", force: true);
try try
{ {
@@ -506,6 +509,8 @@ public sealed partial class BasketEngine : IEngine
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{ {
DateTime now = DateTime.UtcNow; DateTime now = DateTime.UtcNow;
CheckInactivityOnTick(now);
WriteHeartbeat(now);
await DrainCommandsAsync(ct).ConfigureAwait(false); await DrainCommandsAsync(ct).ConfigureAwait(false);
RollSessionIfNeeded(now); RollSessionIfNeeded(now);
ExpireWarmupBlock(now); ExpireWarmupBlock(now);
@@ -526,6 +531,8 @@ public sealed partial class BasketEngine : IEngine
await PollQuotesAsync(now, ct).ConfigureAwait(false); await PollQuotesAsync(now, ct).ConfigureAwait(false);
} }
await RunRecoveryIfDueAsync(now, ct).ConfigureAwait(false);
if (now - _lastReconcileUtc >= reconcile) if (now - _lastReconcileUtc >= reconcile)
{ {
_lastReconcileUtc = now; _lastReconcileUtc = now;
@@ -573,6 +580,7 @@ public sealed partial class BasketEngine : IEngine
finally finally
{ {
SaveState(); SaveState();
WriteHeartbeat(DateTime.UtcNow, "arresto", force: true);
} }
} }
@@ -673,7 +681,7 @@ public sealed partial class BasketEngine : IEngine
} }
} }
if (entries.Count > 0) if (entries.Count > 0 && !_recoveryPending)
{ {
await ExecuteEntriesInOrderAsync(entries, ct).ConfigureAwait(false); await ExecuteEntriesInOrderAsync(entries, ct).ConfigureAwait(false);
} }
@@ -1315,6 +1323,7 @@ public sealed partial class BasketEngine : IEngine
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
SaveState(); SaveState();
ReleaseInstanceLock();
_learning.Dispose(); _learning.Dispose();
_ledger.Dispose(); _ledger.Dispose();
if (_paper is not null) if (_paper is not null)
@@ -99,6 +99,7 @@ public static class BasketTrials
MarginCallBlockRatio = c.Risk.MarginCallBlockRatio, MarginCallBlockRatio = c.Risk.MarginCallBlockRatio,
MarginCallCloseRatio = c.Risk.MarginCallCloseRatio, MarginCallCloseRatio = c.Risk.MarginCallCloseRatio,
}; };
copy.Recovery = new RecoveryOptions { ThresholdMinutes = c.Recovery.ThresholdMinutes, WarmupMinutes = c.Recovery.WarmupMinutes, HeartbeatSeconds = c.Recovery.HeartbeatSeconds };
copy.Preset = c.Preset; copy.Preset = c.Preset;
copy.SignalMode = c.SignalMode; copy.SignalMode = c.SignalMode;
copy.ExitMode = c.ExitMode; copy.ExitMode = c.ExitMode;
@@ -89,6 +89,26 @@ public sealed class RiskOptions
} }
} }
/// <summary>The recovery after an inactivity (§6 of the 5.0 plan, <c>strategy.json</c> → <c>recovery</c>).</summary>
public sealed class RecoveryOptions
{
/// <summary>More than this without a heartbeat or a tick: the recovery procedure runs before any entry.</summary>
public int ThresholdMinutes { get; set; } = 10;
/// <summary>Entries stay blocked this long after a recovery or a reset, while the quotes settle.</summary>
public int WarmupMinutes { get; set; } = 15;
/// <summary>How often <c>data/state/heartbeat.json</c> is written.</summary>
public int HeartbeatSeconds { get; set; } = 30;
public void Validate()
{
if (ThresholdMinutes is < 1 or > 1440) { throw new InvalidOperationException("strategy.json: 'recovery.thresholdMinutes' deve essere fra 1 e 1440."); }
if (WarmupMinutes is < 0 or > 1440) { throw new InvalidOperationException("strategy.json: 'recovery.warmupMinutes' deve essere fra 0 e 1440."); }
if (HeartbeatSeconds is < 5 or > 600) { throw new InvalidOperationException("strategy.json: 'recovery.heartbeatSeconds' deve essere fra 5 e 600."); }
}
}
/// <summary>One basket: two pairs. The synthetic cross and the leg signs are derived, never configured.</summary> /// <summary>One basket: two pairs. The synthetic cross and the leg signs are derived, never configured.</summary>
public sealed class BasketDefinition public sealed class BasketDefinition
{ {
@@ -221,6 +241,9 @@ public sealed class BasketStrategyConfig
/// <summary>The margin rules (§10 of the 5.0 plan).</summary> /// <summary>The margin rules (§10 of the 5.0 plan).</summary>
public RiskOptions Risk { get; set; } = new(); public RiskOptions Risk { get; set; } = new();
/// <summary>The recovery after an inactivity (§6 of the 5.0 plan).</summary>
public RecoveryOptions Recovery { get; set; } = new();
// ---- overrides of the preset (NaN / 0 = take the preset's value) ---- // ---- overrides of the preset (NaN / 0 = take the preset's value) ----
public double ZInOverride { get; set; } = double.NaN; public double ZInOverride { get; set; } = double.NaN;
@@ -301,6 +324,7 @@ public sealed class BasketStrategyConfig
if (LotMultiplier is < 1 or > 1.5) { throw Bad("lotMultiplier", "fra 1,0 e 1,5"); } if (LotMultiplier is < 1 or > 1.5) { throw Bad("lotMultiplier", "fra 1,0 e 1,5"); }
if (Baskets.Count == 0) { throw Bad("baskets", "almeno un basket"); } if (Baskets.Count == 0) { throw Bad("baskets", "almeno un basket"); }
Risk.Validate(); Risk.Validate();
Recovery.Validate();
foreach (BasketDefinition b in Baskets) foreach (BasketDefinition b in Baskets)
{ {
@@ -346,6 +370,7 @@ public sealed class BasketStrategyConfig
sb.Append(CultureInfo.InvariantCulture, $"cost={CostMultiple};spreadMed={SpreadMedianMultiple};slip={SlippagePipsPerLeg};on={OvernightPipsPerDay};blackout={BlackoutBeforeMin}/{BlackoutAfterMin};fri={FridayCutoffUtcHour};open={OpenDelayMinutes};"); sb.Append(CultureInfo.InvariantCulture, $"cost={CostMultiple};spreadMed={SpreadMedianMultiple};slip={SlippagePipsPerLeg};on={OvernightPipsPerDay};blackout={BlackoutBeforeMin}/{BlackoutAfterMin};fri={FridayCutoffUtcHour};open={OpenDelayMinutes};");
sb.Append(CultureInfo.InvariantCulture, $"lev={MaxEffectiveLeverage}/{OrderLeverage};vol={VolScaleMin}-{VolScaleMax}/{VolAverageDays};pMin={MlMinProbability};eqStop={EquityStopPct};daily={DailyLossPct};"); sb.Append(CultureInfo.InvariantCulture, $"lev={MaxEffectiveLeverage}/{OrderLeverage};vol={VolScaleMin}-{VolScaleMax}/{VolAverageDays};pMin={MlMinProbability};eqStop={EquityStopPct};daily={DailyLossPct};");
sb.Append(CultureInfo.InvariantCulture, $"margin={Risk.MaxMarginUsePct}/{Risk.MaxMarginPerBasketPct}/{Risk.MarginBufferPct};mcall={Risk.MarginCallBlockRatio}/{Risk.MarginCallCloseRatio};"); sb.Append(CultureInfo.InvariantCulture, $"margin={Risk.MaxMarginUsePct}/{Risk.MaxMarginPerBasketPct}/{Risk.MarginBufferPct};mcall={Risk.MarginCallBlockRatio}/{Risk.MarginCallCloseRatio};");
sb.Append(CultureInfo.InvariantCulture, $"recovery={Recovery.ThresholdMinutes}/{Recovery.WarmupMinutes};");
foreach (BasketDefinition b in Baskets) foreach (BasketDefinition b in Baskets)
{ {
sb.Append(CultureInfo.InvariantCulture, $"{b.A}/{b.B}={(b.Enabled ? 1 : 0)};"); sb.Append(CultureInfo.InvariantCulture, $"{b.A}/{b.B}={(b.Enabled ? 1 : 0)};");
@@ -448,6 +473,7 @@ public sealed class BasketStrategyConfig
case "zstop": c.ZStopOverride = Num(p); break; case "zstop": c.ZStopOverride = Num(p); break;
case "baskets": c.Baskets = ReadBaskets(p.Value, warnings); break; case "baskets": c.Baskets = ReadBaskets(p.Value, warnings); break;
case "risk": c.Risk = ReadRisk(p.Value, warnings); break; case "risk": c.Risk = ReadRisk(p.Value, warnings); break;
case "recovery": c.Recovery = ReadRecovery(p.Value, warnings); break;
default: warnings.Add($"chiave sconosciuta '{p.Name}' in strategy.json"); break; default: warnings.Add($"chiave sconosciuta '{p.Name}' in strategy.json"); break;
} }
} }
@@ -531,6 +557,34 @@ public sealed class BasketStrategyConfig
return r; return r;
} }
private static RecoveryOptions ReadRecovery(JsonElement e, List<string> warnings)
{
RecoveryOptions r = new();
if (e.ValueKind != JsonValueKind.Object)
{
warnings.Add("'recovery' deve essere un oggetto: uso i valori di fabbrica");
return r;
}
foreach (JsonProperty p in e.EnumerateObject())
{
if (p.Name.StartsWith('_'))
{
continue;
}
switch (p.Name.ToLowerInvariant())
{
case "thresholdminutes": r.ThresholdMinutes = Int(p); break;
case "warmupminutes": r.WarmupMinutes = Int(p); break;
case "heartbeatseconds": r.HeartbeatSeconds = Int(p); break;
default: warnings.Add($"chiave sconosciuta 'recovery.{p.Name}' in strategy.json"); break;
}
}
return r;
}
private static List<(int, int)> ReadSessions(JsonElement e, List<string> warnings) private static List<(int, int)> ReadSessions(JsonElement e, List<string> warnings)
{ {
List<(int, int)> list = []; List<(int, int)> list = [];
@@ -660,6 +714,13 @@ public sealed class BasketStrategyConfig
"marginCallCloseRatio": 1.2 "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
},
"_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.", "_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.",
"baskets": [ "baskets": [
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" }, { "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
@@ -0,0 +1,73 @@
using System.Globalization;
using System.Text.Json;
namespace Encelado.Core.Baskets;
/// <summary>What the bot writes every few seconds to say it is alive (§6 of the 5.0 plan).</summary>
public sealed record Heartbeat(DateTime Utc, string RunId, string Mode, int OpenBaskets, int PendingBaskets, int Pid, string Note = "")
{
/// <summary>The idle time since this heartbeat: what a restart, a suspend or a frozen container left uncovered.</summary>
public TimeSpan Inactivity(DateTime nowUtc) => nowUtc - Utc;
}
/// <summary><c>data/state/heartbeat.json</c>: written atomically, read once at startup.</summary>
public static class HeartbeatFile
{
public static void Write(string path, Heartbeat hb)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(hb);
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
{
w.WriteStartObject();
w.WriteString("utc", hb.Utc.ToString("O", CultureInfo.InvariantCulture));
w.WriteString("run_id", hb.RunId);
w.WriteString("mode", hb.Mode);
w.WriteNumber("openBaskets", hb.OpenBaskets);
w.WriteNumber("pendingBaskets", hb.PendingBaskets);
w.WriteNumber("pid", hb.Pid);
w.WriteString("note", hb.Note);
w.WriteEndObject();
}
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllBytes(path + ".tmp", ms.ToArray());
File.Move(path + ".tmp", path, overwrite: true);
}
public static Heartbeat? Read(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
try
{
using JsonDocument doc = JsonDocument.Parse(File.ReadAllBytes(path));
JsonElement r = doc.RootElement;
if (!r.TryGetProperty("utc", out JsonElement u) || !DateTime.TryParse(u.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime utc))
{
return null;
}
return new Heartbeat(utc,
r.TryGetProperty("run_id", out JsonElement id) ? id.GetString() ?? string.Empty : string.Empty,
r.TryGetProperty("mode", out JsonElement m) ? m.GetString() ?? string.Empty : string.Empty,
r.TryGetProperty("openBaskets", out JsonElement ob) ? ob.GetInt32() : 0,
r.TryGetProperty("pendingBaskets", out JsonElement pb) ? pb.GetInt32() : 0,
r.TryGetProperty("pid", out JsonElement pid) ? pid.GetInt32() : 0,
r.TryGetProperty("note", out JsonElement n) ? n.GetString() ?? string.Empty : string.Empty);
}
catch (Exception ex) when (ex is IOException or JsonException or FormatException)
{
return null;
}
}
}
@@ -0,0 +1,90 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace Encelado.Core.Baskets;
/// <summary>
/// One bot per data folder (§12 of the 5.0 plan, and a known issue since 4.0.0). The
/// lock is the file itself, held open with no sharing for the life of the engine: a
/// second instance cannot open it and stops with the first one's pid and start time; a
/// crash releases it with the process, so there is never a stale lock to delete by hand.
/// </summary>
public sealed class InstanceLock : IDisposable
{
private readonly FileStream _stream;
private InstanceLock(FileStream stream, string path)
{
_stream = stream;
Path = path;
}
public string Path { get; }
/// <summary>Takes the lock or throws <see cref="InvalidOperationException"/> naming the holder.</summary>
public static InstanceLock Acquire(string path, string runId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string? dir = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
FileStream stream;
try
{
stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
}
catch (IOException ex)
{
throw new InvalidOperationException($"un'altra istanza di Encelado sta usando questa cartella dati ({Describe(path)}): fermala prima di avviarne un'altra", ex);
}
try
{
stream.SetLength(0);
byte[] body = Encoding.UTF8.GetBytes(string.Create(CultureInfo.InvariantCulture,
$"{{\"pid\":{Environment.ProcessId},\"runId\":\"{runId}\",\"sinceUtc\":\"{DateTime.UtcNow:O}\",\"machine\":\"{Environment.MachineName}\"}}"));
stream.Write(body, 0, body.Length);
stream.Flush(flushToDisk: true);
}
catch (IOException)
{
stream.Dispose();
throw;
}
return new InstanceLock(stream, path);
}
/// <summary>What the lock file says about its holder, for the message of a refused start.</summary>
private static string Describe(string path)
{
try
{
using FileStream s = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using StreamReader r = new(s, Encoding.UTF8);
string text = r.ReadToEnd();
return text.Length > 0 ? text : "contenuto non leggibile";
}
catch (IOException)
{
return "file bloccato";
}
}
public void Dispose()
{
try
{
_stream.Dispose();
File.Delete(Path);
}
catch (IOException)
{
// The next start overwrites it.
}
}
}
@@ -0,0 +1,79 @@
using System.Globalization;
using System.Text;
namespace Encelado.Core.Baskets;
/// <summary>One line of <c>reports/recupero_&lt;run_id&gt;.csv</c>: what the recovery decided for one position.</summary>
public sealed record RecoveryRow(string Position, string Basket, string Decisione, string Motivo, double Z, double Rho, int BarsHeld, double Pnl, string Motivazione)
{
public const string Header = "posizione;basket;decisione;motivo;z;rho;barsHeld;pnl;motivazione";
public string ToCsv() => string.Join(';',
[
Position, Basket, Decisione, Motivo, N(Z), N(Rho), BarsHeld.ToString(CultureInfo.InvariantCulture), N(Pnl),
Motivazione.Replace(';', ',').Replace('\n', ' ').Replace('\r', ' '),
]);
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.####", CultureInfo.InvariantCulture) : string.Empty;
}
/// <summary>
/// The pure part of the recovery after an inactivity (§6 of the 5.0 plan): how many
/// bars went by, what to do with each position given the decider's verdict on the
/// refreshed context, and the report. The engine does the I/O around it.
/// </summary>
public static class RecoveryPlanner
{
/// <summary>Whole bars of the timeframe that passed while the bot was not looking.</summary>
public static int BarsElapsed(TimeSpan inactivity, TimeSpan barInterval) =>
inactivity <= TimeSpan.Zero || barInterval <= TimeSpan.Zero ? 0 : (int)Math.Floor(inactivity.Ticks / (double)barInterval.Ticks);
/// <summary>An open basket re-evaluated as at an ordinary bar close: the decider's exit is a <c>chiudi</c>, anything else a <c>tieni</c>.</summary>
public static RecoveryRow ForBasket(string basket, string basketId, BasketDecision decision, int barsHeld)
{
ArgumentNullException.ThrowIfNull(decision);
bool close = decision.Kind == DecisionKind.Exit;
string reason = close ? decision.ReasonCodes.FirstOrDefault() ?? "exit" : "entro le soglie";
return new RecoveryRow(basketId.Length > 0 ? basketId : basket, basket, close ? "chiudi" : "tieni", reason,
decision.Evaluation.Z, decision.Evaluation.RhoW, barsHeld, decision.Evaluation.PnlOpenUsd, decision.Motivazione);
}
/// <summary>A leg of ours without a basket: closed, whatever the market says.</summary>
public static RecoveryRow ForOrphan(ClassifiedPosition orphan)
{
ArgumentNullException.ThrowIfNull(orphan);
return new RecoveryRow(orphan.Position.PositionId.ToString(CultureInfo.InvariantCulture), orphan.Basket, "chiudi", "orfana_bot",
double.NaN, double.NaN, 0, orphan.Position.UnrealizedPnl, orphan.Reason);
}
/// <summary>A stranger: reported, untouched.</summary>
public static RecoveryRow ForForeign(ClassifiedPosition foreign)
{
ArgumentNullException.ThrowIfNull(foreign);
return new RecoveryRow(foreign.Position.PositionId.ToString(CultureInfo.InvariantCulture), string.Empty, "rapporto", "esterna",
double.NaN, double.NaN, 0, foreign.Position.UnrealizedPnl, foreign.Reason);
}
public static string Csv(IEnumerable<RecoveryRow> rows)
{
ArgumentNullException.ThrowIfNull(rows);
StringBuilder sb = new();
sb.AppendLine(RecoveryRow.Header);
foreach (RecoveryRow r in rows)
{
sb.AppendLine(r.ToCsv());
}
return sb.ToString();
}
/// <summary>One line for the ledger and the notification.</summary>
public static string Summary(TimeSpan inactivity, IReadOnlyList<RecoveryRow> rows)
{
ArgumentNullException.ThrowIfNull(rows);
int close = rows.Count(static r => r.Decisione == "chiudi");
int keep = rows.Count(static r => r.Decisione == "tieni");
int report = rows.Count(static r => r.Decisione == "rapporto");
return string.Create(CultureInfo.InvariantCulture, $"inattività di {inactivity.TotalMinutes:0} minuti ({BarsElapsed(inactivity, TimeSpan.FromMinutes(15))} barre M15): {close} da chiudere, {keep} da tenere, {report} esterne solo riportate");
}
}
@@ -0,0 +1,196 @@
using Encelado.Core.Baskets;
using Encelado.Core.Baskets.Data;
using Encelado.Core.Broker;
namespace Encelado.Tests;
/// <summary>(r) Six hours away: a basket past its stop is closed, one within the thresholds is kept, an orphan is closed.</summary>
public class RecoveryTests
{
private static readonly Instrument EurUsd = new(1, "EURUSD", "EUR/USD", "Forex", 0.0001, 5, 0.01, 2_000_000, 1000, [1, 2, 5, 10], true, true, 0, 50, "");
private static readonly Instrument UsdChf = new(6, "USDCHF", "USD/CHF", "Forex", 0.0001, 5, 0.01, 2_000_000, 1000, [1, 2, 5, 10], true, true, 0, 50, "");
/// <summary>Two quiet series of 130 bars, then a final move of the cross of <paramref name="lastMovePct"/> on leg A.</summary>
private static (SymbolSeries A, SymbolSeries B, DateTime Now) Series(double lastMovePct)
{
SymbolSeries a = new(EurUsd, TimeSpan.FromMinutes(15));
SymbolSeries b = new(UsdChf, TimeSpan.FromMinutes(15));
Random rng = new(42);
DateTime t0 = new(2026, 9, 21, 0, 0, 0, DateTimeKind.Utc);
double pa = 1.10, pb = 0.90;
for (int i = 0; i < 130; i++)
{
double na = (rng.NextDouble() - 0.5) * 0.0004;
pa += na;
pb -= na * 0.8 + (rng.NextDouble() - 0.5) * 0.0001; // negatively correlated, as the baskets expect
if (i == 129)
{
pa *= 1 + lastMovePct;
}
DateTime t = t0.AddMinutes(15 * i);
a.Append(new BidAskBar(t, pa - 0.00005, pa + 0.0001, pa - 0.0002, pa - 0.00005, pa + 0.00005, pa + 0.0002, pa - 0.0001, pa + 0.00005, 0.0001, 20, "test"));
b.Append(new BidAskBar(t, pb - 0.00005, pb + 0.0001, pb - 0.0002, pb - 0.00005, pb + 0.00005, pb + 0.0002, pb - 0.0001, pb + 0.00005, 0.0001, 20, "test"));
}
DateTime now = t0.AddMinutes(15 * 130);
a.OnQuote(new QuoteSnapshot(1, now, pa - 0.00005, pa + 0.00005, true), now);
b.OnQuote(new QuoteSnapshot(6, now, pb - 0.00005, pb + 0.00005, true), now);
return (a, b, now);
}
private static BasketContext Context(SymbolSeries a, SymbolSeries b, DateTime now, BasketPosition p) => new()
{
TimeUtc = now,
BasketId = "EURUSD/USDCHF",
Name = "EURUSD/USDCHF",
Cross = SyntheticCross.Derive("EURUSD", "USDCHF"),
A = a,
B = b,
Equity = 10_000,
IsBarClose = true,
PipValueUsdA = 0.0001,
PipValueUsdB = 0.0001 / 0.9,
UsdPerQuoteA = 1,
UsdPerQuoteB = 1 / 0.9,
Mid = s => s == "EURUSD" ? a.Mid : s == "USDCHF" ? b.Mid : null,
Position = p,
};
private static BasketPosition Position(bool buyCross, double entryA, double entryB, int barsHeld, double maxLoss) => new()
{
BasketId = "B1",
Name = "EURUSD/USDCHF",
BuyCross = buyCross,
A = new BasketLeg { Symbol = "EURUSD", InstrumentId = 1, IsBuy = buyCross, Units = 10_000, EntryPrice = entryA, PositionId = 1, OpenedUtc = DateTime.UtcNow.AddHours(-8) },
B = new BasketLeg { Symbol = "USDCHF", InstrumentId = 6, IsBuy = buyCross, Units = 9_000, EntryPrice = entryB, PositionId = 2, OpenedUtc = DateTime.UtcNow.AddHours(-8) },
OpenedUtc = DateTime.UtcNow.AddHours(-8),
EntryZ = buyCross ? -2.1 : 2.1,
LastAddZ = 2.1,
BarsHeld = barsHeld,
TpPips = 10,
MaxLossUsd = maxLoss,
EquityAtEntry = 10_000,
};
[Fact]
public void SixHoursAwayAreTwentyFourBars() =>
Assert.Equal(24, RecoveryPlanner.BarsElapsed(TimeSpan.FromHours(6), TimeSpan.FromMinutes(15)));
[Fact]
public void ABasketThatPassedTheTimeStopWhileAwayIsClosed()
{
(SymbolSeries a, SymbolSeries b, DateTime now) = Series(0);
BasketStrategyConfig cfg = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _);
BasketDecider decider = new(cfg);
BasketPosition p = Position(true, a.Mid, b.Mid, barsHeld: 80, maxLoss: 150);
p.BarsHeld += RecoveryPlanner.BarsElapsed(TimeSpan.FromHours(6), TimeSpan.FromMinutes(15));
BasketDecision d = decider.Evaluate(Context(a, b, now, p));
RecoveryRow row = RecoveryPlanner.ForBasket("EURUSD/USDCHF", "B1", d, p.BarsHeld);
Assert.Equal(DecisionKind.Exit, d.Kind);
Assert.Equal("chiudi", row.Decisione);
Assert.Equal("time_stop", row.Motivo);
Assert.Equal(104, row.BarsHeld);
}
[Fact]
public void ABasketThatCrossedTheZStopWhileAwayIsClosed()
{
// Sold the cross; while away leg A jumped 2 %: z far beyond the stop.
(SymbolSeries a, SymbolSeries b, DateTime now) = Series(0.02);
BasketStrategyConfig cfg = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _);
BasketDecider decider = new(cfg);
BasketPosition p = Position(false, 1.10, 0.90, barsHeld: 10, maxLoss: 100_000);
p.BarsHeld += 24;
BasketDecision d = decider.Evaluate(Context(a, b, now, p));
RecoveryRow row = RecoveryPlanner.ForBasket("EURUSD/USDCHF", "B1", d, p.BarsHeld);
Assert.Equal(DecisionKind.Exit, d.Kind);
Assert.Equal("chiudi", row.Decisione);
Assert.Equal("stop_z", row.Motivo);
Assert.True(row.Z > 3.5, $"z {row.Z}");
}
[Fact]
public void ABasketStillWithinTheThresholdsIsKeptAndRearmed()
{
(SymbolSeries a, SymbolSeries b, DateTime now) = Series(0);
BasketStrategyConfig cfg = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _);
BasketDecider decider = new(cfg);
BasketPosition p = Position(true, a.Mid, b.Mid, barsHeld: 10, maxLoss: 150);
p.BarsHeld += 24;
BasketDecision d = decider.Evaluate(Context(a, b, now, p));
RecoveryRow row = RecoveryPlanner.ForBasket("EURUSD/USDCHF", "B1", d, p.BarsHeld);
Assert.Equal(DecisionKind.Hold, d.Kind);
Assert.Equal("tieni", row.Decisione);
Assert.Equal(34, row.BarsHeld);
}
[Fact]
public void AnOrphanIsClosedAndAStrangerOnlyReported()
{
BrokerPosition orphan = new(11, 3, false, 34_883, 0.5733, DateTime.UtcNow.AddHours(-7), 0, 0, 10, 2000, 17.8, 0, 0.5728);
BrokerPosition stranger = new(13, 1531, true, 12.1, 41.22, DateTime.UtcNow.AddDays(-100), 0, 0, 1, 500, -69.9, 0, 35.45);
List<ClassifiedPosition> classified = PositionClassifier.Classify([orphan, stranger], new Dictionary<long, string>(), new Dictionary<long, string> { [11] = "NZDUSD/EURNZD" }, []);
RecoveryRow o = RecoveryPlanner.ForOrphan(classified[0]);
RecoveryRow f = RecoveryPlanner.ForForeign(classified[1]);
string csv = RecoveryPlanner.Csv([o, f]);
Assert.Equal("chiudi", o.Decisione);
Assert.Equal("rapporto", f.Decisione);
Assert.StartsWith(RecoveryRow.Header, csv, StringComparison.Ordinal);
Assert.Contains("11;NZDUSD/EURNZD;chiudi;orfana_bot", csv, StringComparison.Ordinal);
Assert.Contains("2 da chiudere", RecoveryPlanner.Summary(TimeSpan.FromHours(6), [o, f, RecoveryPlanner.ForBasket("x", "B", new BasketDecision(DecisionKind.Exit, false, null, ["stop_z"], "m", new BasketEvaluation(), null), 30)]), StringComparison.Ordinal);
}
}
/// <summary>The heartbeat file and the one-instance lock.</summary>
public sealed class HeartbeatAndLockTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"encelado-hb-{Guid.NewGuid():N}");
public void Dispose()
{
if (Directory.Exists(_dir))
{
Directory.Delete(_dir, recursive: true);
}
}
[Fact]
public void TheHeartbeatRoundTripsAndMeasuresTheInactivity()
{
string path = Path.Combine(_dir, "heartbeat.json");
DateTime t = new(2026, 9, 23, 8, 0, 0, DateTimeKind.Utc);
HeartbeatFile.Write(path, new Heartbeat(t, "run1", "Demo", 2, 1, 4242, "avvio"));
Heartbeat? back = HeartbeatFile.Read(path);
Assert.NotNull(back);
Assert.Equal(t, back!.Utc);
Assert.Equal("run1", back.RunId);
Assert.Equal(2, back.OpenBaskets);
Assert.Equal(TimeSpan.FromHours(6), back.Inactivity(t.AddHours(6)));
Assert.Null(HeartbeatFile.Read(Path.Combine(_dir, "manca.json")));
}
[Fact]
public void ASecondInstanceOnTheSameFolderIsRefusedUntilTheFirstReleases()
{
string path = Path.Combine(_dir, "instance.lock");
using (InstanceLock first = InstanceLock.Acquire(path, "run1"))
{
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => InstanceLock.Acquire(path, "run2"));
Assert.Contains("altra istanza", ex.Message, StringComparison.Ordinal);
}
using InstanceLock second = InstanceLock.Acquire(path, "run2");
Assert.True(File.Exists(path));
}
}