diff --git a/Encelado/CHANGELOG.md b/Encelado/CHANGELOG.md index 2b6a8ea..fafdb66 100644 --- a/Encelado/CHANGELOG.md +++ b/Encelado/CHANGELOG.md @@ -2,6 +2,14 @@ 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_.csv`, righe `recupero_avviato`/`recupero_concluso`, notifica, entrate riaperte dopo `recovery.warmupMinutes` (15). A mercato chiuso aspetta le quotazioni. +- Sezione `recovery` in `strategy.json`; il reset usa lo stesso riscaldamento. +- `data/state/instance.lock` tenuto in esclusiva: un secondo bot sulla stessa cartella dati non parte (problema noto dalla 4.0.0, chiuso). +- `RecoveryPlanner`, `HeartbeatFile`, `InstanceLock` nel Core; test (r) e altri sei: 211 verdi. + ## 2026-09-23 — 5.0, Fase 3: kill-switch che chiude davvero, ripristino in cinque passi - `FlattenProcedure` nel Core: annullamento degli ordini senza esito (con attesa della risoluzione), chiusura delle posizioni, verifica di piattezza sul conto; stato `Halted` o `Halted-Residuo`. diff --git a/Encelado/config/strategy.json b/Encelado/config/strategy.json index 6ab80d4..1a5d7c3 100644 --- a/Encelado/config/strategy.json +++ b/Encelado/config/strategy.json @@ -79,6 +79,13 @@ "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_.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": [ { "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" }, diff --git a/Encelado/docs/ARCHITECTURE.md b/Encelado/docs/ARCHITECTURE.md index bd7623b..8dc9ecb 100644 --- a/Encelado/docs/ARCHITECTURE.md +++ b/Encelado/docs/ARCHITECTURE.md @@ -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), 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), - 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/Baskets/History/ OrderRecord (riga di orders.jsonl); dalla Fase 7 PositionRecord, PeriodStats, HistoryBuilder 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.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 - 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), 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) diff --git a/Encelado/docs/KNOWN_ISSUES.md b/Encelado/docs/KNOWN_ISSUES.md index c3dbb94..e413d5c 100644 --- a/Encelado/docs/KNOWN_ISSUES.md +++ b/Encelado/docs/KNOWN_ISSUES.md @@ -27,13 +27,12 @@ Aggiornato: 2026-09-23. Una voce per limite, con lo stato. Quando un limite vien ## Bot -- **Una sola istanza** per cartella di lavoro: non c'è un lock; due bot sullo stesso conto si contendono le posizioni. Documentato nel runbook, non imposto dal codice. - Le posizioni salvate in `baskets_state.json` da una modalità diversa non vengono riprese (si riparte dalla riconciliazione del conto). - 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`). - 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). -- 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 diff --git a/Encelado/docs/LEDGER_SCHEMA.md b/Encelado/docs/LEDGER_SCHEMA.md index 257cce5..ac2cf45 100644 --- a/Encelado/docs/LEDGER_SCHEMA.md +++ b/Encelado/docs/LEDGER_SCHEMA.md @@ -16,7 +16,7 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15 | `cross` | testo | cross sintetico (`EURCHF`) | | `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` | | `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` | -| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; 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` | | `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) | @@ -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`. +## `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_.csv` (dalla 5.0) + +Una riga per posizione trovata dal recupero: `posizione;basket;decisione;motivo;z;rho;barsHeld;pnl;motivazione`, con `decisione` ∈ {`chiudi`, `tieni`, `rapporto`} e `motivo` = codice di uscita del decisore (`stop_z`, `stop_max_loss`, `time_stop`, `rho_break`, `spread_anomaly`…), `entro le soglie`, `orfana_bot`, `esterna`. + ## `reports/bonifica_YYYYMMDD.csv` (dalla 5.0) Una riga per orfana chiusa dalla bonifica: `ts;position_id;strumento;verso;unita;aperta_utc;pnl_realizzato;basket;motivazione`. diff --git a/Encelado/docs/RISK_RULES.md b/Encelado/docs/RISK_RULES.md index 58c5b7e..5618fdd 100644 --- a/Encelado/docs/RISK_RULES.md +++ b/Encelado/docs/RISK_RULES.md @@ -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 | | 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 | -| 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_.csv`; entrate riaperte dopo `recovery.warmupMinutes` (15); a mercato chiuso niente di irreversibile | `strategy.json` → `recovery` | operatore | +| Una sola istanza | `data/state/instance.lock` tenuto in esclusiva: un secondo bot sulla stessa cartella dati non parte | codice | nessuno | +| Ripristino | procedura in cinque passi: stato, rimozione del file `STOP`, motivazione ≥ 10 caratteri (`correzione`), riconciliazione + riscaldamento + picco di equity al netto dei movimenti di cassa, ripartenza con entrate bloccate per `recovery.warmupMinutes`; rifiutato (`reset_rifiutato`) finché una posizione del bot resta sul conto | codice | operatore | | Posizioni esterne | posizioni senza la firma del bot: segnalate una volta nel log, contate in dashboard, **mai toccate** (dalla Fase 3: chiuse dal kill-switch solo con `risk.closeForeignOnKill` o con la spunta esplicita) | codice | operatore | | Chiavi API | solo `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o `ETORO_API_KEY`/`ETORO_USER_KEY`; mai nel repo (`.gitignore`: `*.local.json`, `.env`) | codice | operatore | | Ambiente visibile | badge `PAPER/DEMO/LIVE` nella barra, nel log e nel ledger (`mode`) | codice | nessuno | diff --git a/Encelado/docs/RUNBOOK.md b/Encelado/docs/RUNBOOK.md index 6a66b00..d0555f7 100644 --- a/Encelado/docs/RUNBOOK.md +++ b/Encelado/docs/RUNBOOK.md @@ -1,6 +1,6 @@ # 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 @@ -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 `, `kill`, `preset `, `reset `, `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 @@ -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. +## 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_.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 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 `;`) | | 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__M15.csv` | | modelli | `data\models\` | | conoscenza | `knowledge\` | diff --git a/Encelado/docs/STATE.md b/Encelado/docs/STATE.md index 904d166..d9123b1 100644 --- a/Encelado/docs/STATE.md +++ b/Encelado/docs/STATE.md @@ -1,25 +1,25 @@ # 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 -**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) - **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_.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 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. ## Prossimi passi -1. **Fase 4 — recupero dopo inattività** (§6): heartbeat, sezione `recovery` in `strategy.json`, procedura di recupero, lock di istanza, rapporto `recupero_.csv`. Test (r). -2. **Fase 5 — Telegram** (§7). Test (s)-(u). -3. **Dopo la risposta a D-28/D-29**: Fasi 6-9 (Engine/Server, web UI M3, Docker, Unraid, skill, 5.0.0). -4. Riaccendere il Demo per 24 ore di verifica (vedi sopra) e provare il kill-switch a mano con la verifica di piattezza. -5. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`. +1. **Fase 5 — Telegram** (§7): `TelegramNotifier`, stato orario, eventi, riepilogo giornaliero, comandi in ingresso. 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. 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. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`. ## Problemi aperti diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Kill.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Kill.cs index 6c39310..7902ead 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Kill.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Kill.cs @@ -15,8 +15,8 @@ namespace Encelado.Bot.Baskets; /// public sealed partial class BasketEngine { - /// How long the entries stay blocked after a reset, while the quotes settle (from recovery.warmupMinutes in Phase 4). - private static readonly TimeSpan ResetWarmup = TimeSpan.FromMinutes(15); + /// How long the entries stay blocked after a reset, while the quotes settle (recovery.warmupMinutes). + private TimeSpan ResetWarmup => TimeSpan.FromMinutes(_strategy.Recovery.WarmupMinutes); private List _haltResidue = []; private DateTime? _entriesBlockedUntilUtc; diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Recovery.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Recovery.cs new file mode 100644 index 0000000..130fe0b --- /dev/null +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Recovery.cs @@ -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; + +/// +/// 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 recovery.thresholdMinutes 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. +/// +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"); + + /// One engine per data folder; refused with the holder's pid when another one is running. + 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}"); + } + } + + /// At startup: the previous heartbeat says how long the bot was away. + 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"); + } + } + + /// On every tick: a gap between two ticks is a suspended computer or a frozen process. + 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)); + }); + } + + /// + /// 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. + /// + 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 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 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}"); + } + } +} diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs index aef2e77..0336709 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs @@ -278,6 +278,8 @@ public sealed partial class BasketEngine : IEngine { Log.Info($"motore basket: {_mode} su {_broker.Name}, preset {PresetLabel}, strategia {_configHash}, run {_runId}"); + AcquireInstanceLock(); + CheckInactivityAtStartup(); await StartupChecksAsync(ct).ConfigureAwait(false); await LoadInstrumentsAsync(ct).ConfigureAwait(false); await WarmupAsync(ct).ConfigureAwait(false); @@ -303,6 +305,7 @@ public sealed partial class BasketEngine : IEngine } SaveState(); + WriteHeartbeat(DateTime.UtcNow, "avvio", force: true); try { @@ -506,6 +509,8 @@ public sealed partial class BasketEngine : IEngine while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) { DateTime now = DateTime.UtcNow; + CheckInactivityOnTick(now); + WriteHeartbeat(now); await DrainCommandsAsync(ct).ConfigureAwait(false); RollSessionIfNeeded(now); ExpireWarmupBlock(now); @@ -526,6 +531,8 @@ public sealed partial class BasketEngine : IEngine await PollQuotesAsync(now, ct).ConfigureAwait(false); } + await RunRecoveryIfDueAsync(now, ct).ConfigureAwait(false); + if (now - _lastReconcileUtc >= reconcile) { _lastReconcileUtc = now; @@ -573,6 +580,7 @@ public sealed partial class BasketEngine : IEngine finally { 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); } @@ -1315,6 +1323,7 @@ public sealed partial class BasketEngine : IEngine public async ValueTask DisposeAsync() { SaveState(); + ReleaseInstanceLock(); _learning.Dispose(); _ledger.Dispose(); if (_paper is not null) diff --git a/Encelado/src/Encelado.Core/Baskets/Backtest/BasketTrials.cs b/Encelado/src/Encelado.Core/Baskets/Backtest/BasketTrials.cs index 674136c..f534ee0 100644 --- a/Encelado/src/Encelado.Core/Baskets/Backtest/BasketTrials.cs +++ b/Encelado/src/Encelado.Core/Baskets/Backtest/BasketTrials.cs @@ -99,6 +99,7 @@ public static class BasketTrials MarginCallBlockRatio = c.Risk.MarginCallBlockRatio, MarginCallCloseRatio = c.Risk.MarginCallCloseRatio, }; + copy.Recovery = new RecoveryOptions { ThresholdMinutes = c.Recovery.ThresholdMinutes, WarmupMinutes = c.Recovery.WarmupMinutes, HeartbeatSeconds = c.Recovery.HeartbeatSeconds }; copy.Preset = c.Preset; copy.SignalMode = c.SignalMode; copy.ExitMode = c.ExitMode; diff --git a/Encelado/src/Encelado.Core/Baskets/BasketStrategyConfig.cs b/Encelado/src/Encelado.Core/Baskets/BasketStrategyConfig.cs index 28af572..9c8e269 100644 --- a/Encelado/src/Encelado.Core/Baskets/BasketStrategyConfig.cs +++ b/Encelado/src/Encelado.Core/Baskets/BasketStrategyConfig.cs @@ -89,6 +89,26 @@ public sealed class RiskOptions } } +/// The recovery after an inactivity (§6 of the 5.0 plan, strategy.jsonrecovery). +public sealed class RecoveryOptions +{ + /// More than this without a heartbeat or a tick: the recovery procedure runs before any entry. + public int ThresholdMinutes { get; set; } = 10; + + /// Entries stay blocked this long after a recovery or a reset, while the quotes settle. + public int WarmupMinutes { get; set; } = 15; + + /// How often data/state/heartbeat.json is written. + 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."); } + } +} + /// One basket: two pairs. The synthetic cross and the leg signs are derived, never configured. public sealed class BasketDefinition { @@ -221,6 +241,9 @@ public sealed class BasketStrategyConfig /// The margin rules (§10 of the 5.0 plan). public RiskOptions Risk { get; set; } = new(); + /// The recovery after an inactivity (§6 of the 5.0 plan). + public RecoveryOptions Recovery { get; set; } = new(); + // ---- overrides of the preset (NaN / 0 = take the preset's value) ---- 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 (Baskets.Count == 0) { throw Bad("baskets", "almeno un basket"); } Risk.Validate(); + Recovery.Validate(); 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, $"lev={MaxEffectiveLeverage}/{OrderLeverage};vol={VolScaleMin}-{VolScaleMax}/{VolAverageDays};pMin={MlMinProbability};eqStop={EquityStopPct};daily={DailyLossPct};"); sb.Append(CultureInfo.InvariantCulture, $"margin={Risk.MaxMarginUsePct}/{Risk.MaxMarginPerBasketPct}/{Risk.MarginBufferPct};mcall={Risk.MarginCallBlockRatio}/{Risk.MarginCallCloseRatio};"); + sb.Append(CultureInfo.InvariantCulture, $"recovery={Recovery.ThresholdMinutes}/{Recovery.WarmupMinutes};"); foreach (BasketDefinition b in Baskets) { 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 "baskets": c.Baskets = ReadBaskets(p.Value, warnings); break; case "risk": c.Risk = ReadRisk(p.Value, warnings); break; + case "recovery": c.Recovery = ReadRecovery(p.Value, warnings); break; default: warnings.Add($"chiave sconosciuta '{p.Name}' in strategy.json"); break; } } @@ -531,6 +557,34 @@ public sealed class BasketStrategyConfig return r; } + private static RecoveryOptions ReadRecovery(JsonElement e, List 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 warnings) { List<(int, int)> list = []; @@ -660,6 +714,13 @@ public sealed class BasketStrategyConfig "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_.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": [ { "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" }, diff --git a/Encelado/src/Encelado.Core/Baskets/Heartbeat.cs b/Encelado/src/Encelado.Core/Baskets/Heartbeat.cs new file mode 100644 index 0000000..51c353d --- /dev/null +++ b/Encelado/src/Encelado.Core/Baskets/Heartbeat.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Text.Json; + +namespace Encelado.Core.Baskets; + +/// What the bot writes every few seconds to say it is alive (§6 of the 5.0 plan). +public sealed record Heartbeat(DateTime Utc, string RunId, string Mode, int OpenBaskets, int PendingBaskets, int Pid, string Note = "") +{ + /// The idle time since this heartbeat: what a restart, a suspend or a frozen container left uncovered. + public TimeSpan Inactivity(DateTime nowUtc) => nowUtc - Utc; +} + +/// data/state/heartbeat.json: written atomically, read once at startup. +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; + } + } +} diff --git a/Encelado/src/Encelado.Core/Baskets/InstanceLock.cs b/Encelado/src/Encelado.Core/Baskets/InstanceLock.cs new file mode 100644 index 0000000..5748e0e --- /dev/null +++ b/Encelado/src/Encelado.Core/Baskets/InstanceLock.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace Encelado.Core.Baskets; + +/// +/// 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. +/// +public sealed class InstanceLock : IDisposable +{ + private readonly FileStream _stream; + + private InstanceLock(FileStream stream, string path) + { + _stream = stream; + Path = path; + } + + public string Path { get; } + + /// Takes the lock or throws naming the holder. + 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); + } + + /// What the lock file says about its holder, for the message of a refused start. + 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. + } + } +} diff --git a/Encelado/src/Encelado.Core/Baskets/RecoveryPlanner.cs b/Encelado/src/Encelado.Core/Baskets/RecoveryPlanner.cs new file mode 100644 index 0000000..1cd96a9 --- /dev/null +++ b/Encelado/src/Encelado.Core/Baskets/RecoveryPlanner.cs @@ -0,0 +1,79 @@ +using System.Globalization; +using System.Text; + +namespace Encelado.Core.Baskets; + +/// One line of reports/recupero_<run_id>.csv: what the recovery decided for one position. +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; +} + +/// +/// 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. +/// +public static class RecoveryPlanner +{ + /// Whole bars of the timeframe that passed while the bot was not looking. + public static int BarsElapsed(TimeSpan inactivity, TimeSpan barInterval) => + inactivity <= TimeSpan.Zero || barInterval <= TimeSpan.Zero ? 0 : (int)Math.Floor(inactivity.Ticks / (double)barInterval.Ticks); + + /// An open basket re-evaluated as at an ordinary bar close: the decider's exit is a chiudi, anything else a tieni. + 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); + } + + /// A leg of ours without a basket: closed, whatever the market says. + 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); + } + + /// A stranger: reported, untouched. + 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 rows) + { + ArgumentNullException.ThrowIfNull(rows); + StringBuilder sb = new(); + sb.AppendLine(RecoveryRow.Header); + foreach (RecoveryRow r in rows) + { + sb.AppendLine(r.ToCsv()); + } + + return sb.ToString(); + } + + /// One line for the ledger and the notification. + public static string Summary(TimeSpan inactivity, IReadOnlyList 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"); + } +} diff --git a/Encelado/tests/Encelado.Tests/RecoveryTests.cs b/Encelado/tests/Encelado.Tests/RecoveryTests.cs new file mode 100644 index 0000000..0b2eefc --- /dev/null +++ b/Encelado/tests/Encelado.Tests/RecoveryTests.cs @@ -0,0 +1,196 @@ +using Encelado.Core.Baskets; +using Encelado.Core.Baskets.Data; +using Encelado.Core.Broker; + +namespace Encelado.Tests; + +/// (r) Six hours away: a basket past its stop is closed, one within the thresholds is kept, an orphan is closed. +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, ""); + + /// Two quiet series of 130 bars, then a final move of the cross of on leg A. + 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 classified = PositionClassifier.Classify([orphan, stranger], new Dictionary(), new Dictionary { [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); + } +} + +/// The heartbeat file and the one-instance lock. +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(() => InstanceLock.Acquire(path, "run2")); + Assert.Contains("altra istanza", ex.Message, StringComparison.Ordinal); + } + + using InstanceLock second = InstanceLock.Acquire(path, "run2"); + Assert.True(File.Exists(path)); + } +}