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:
@@ -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_<run_id>.csv`, righe `recupero_avviato`/`recupero_concluso`, notifica, entrate riaperte dopo `recovery.warmupMinutes` (15). A mercato chiuso aspetta le quotazioni.
|
||||
- Sezione `recovery` in `strategy.json`; il reset usa lo stesso riscaldamento.
|
||||
- `data/state/instance.lock` tenuto in esclusiva: un secondo bot sulla stessa cartella dati non parte (problema noto dalla 4.0.0, chiuso).
|
||||
- `RecoveryPlanner`, `HeartbeatFile`, `InstanceLock` nel Core; test (r) e altri sei: 211 verdi.
|
||||
|
||||
## 2026-09-23 — 5.0, Fase 3: kill-switch che chiude davvero, ripristino in cinque passi
|
||||
|
||||
- `FlattenProcedure` nel Core: annullamento degli ordini senza esito (con attesa della risoluzione), chiusura delle posizioni, verifica di piattezza sul conto; stato `Halted` o `Halted-Residuo`.
|
||||
|
||||
@@ -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_<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": [
|
||||
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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_<run_id>.csv` (dalla 5.0)
|
||||
|
||||
Una riga per posizione trovata dal recupero: `posizione;basket;decisione;motivo;z;rho;barsHeld;pnl;motivazione`, con `decisione` ∈ {`chiudi`, `tieni`, `rapporto`} e `motivo` = codice di uscita del decisore (`stop_z`, `stop_max_loss`, `time_stop`, `rho_break`, `spread_anomaly`…), `entro le soglie`, `orfana_bot`, `esterna`.
|
||||
|
||||
## `reports/bonifica_YYYYMMDD.csv` (dalla 5.0)
|
||||
|
||||
Una riga per orfana chiusa dalla bonifica: `ts;position_id;strumento;verso;unita;aperta_utc;pnl_realizzato;basket;motivazione`.
|
||||
|
||||
@@ -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_<run_id>.csv`; entrate riaperte dopo `recovery.warmupMinutes` (15); a mercato chiuso niente di irreversibile | `strategy.json` → `recovery` | operatore |
|
||||
| Una sola istanza | `data/state/instance.lock` tenuto in esclusiva: un secondo bot sulla stessa cartella dati non parte | codice | nessuno |
|
||||
| Ripristino | procedura in cinque passi: stato, rimozione del file `STOP`, motivazione ≥ 10 caratteri (`correzione`), riconciliazione + riscaldamento + picco di equity al netto dei movimenti di cassa, ripartenza con entrate bloccate per `recovery.warmupMinutes`; rifiutato (`reset_rifiutato`) finché una posizione del bot resta sul conto | codice | operatore |
|
||||
| Posizioni esterne | posizioni senza la firma del bot: segnalate una volta nel log, contate in dashboard, **mai toccate** (dalla Fase 3: chiuse dal kill-switch solo con `risk.closeForeignOnKill` o con la spunta esplicita) | codice | operatore |
|
||||
| Chiavi API | solo `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o `ETORO_API_KEY`/`ETORO_USER_KEY`; mai nel repo (`.gitignore`: `*.local.json`, `.env`) | codice | operatore |
|
||||
| Ambiente visibile | badge `PAPER/DEMO/LIVE` nella barra, nel log e nel ledger (`mode`) | codice | nessuno |
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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_<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
|
||||
|
||||
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_<SYMBOL>_M15.csv` |
|
||||
| modelli | `data\models\` |
|
||||
| conoscenza | `knowledge\` |
|
||||
|
||||
@@ -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_<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 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_<run_id>.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
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ namespace Encelado.Bot.Baskets;
|
||||
/// </summary>
|
||||
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>
|
||||
private static readonly TimeSpan ResetWarmup = TimeSpan.FromMinutes(15);
|
||||
/// <summary>How long the entries stay blocked after a reset, while the quotes settle (<c>recovery.warmupMinutes</c>).</summary>
|
||||
private TimeSpan ResetWarmup => TimeSpan.FromMinutes(_strategy.Recovery.WarmupMinutes);
|
||||
|
||||
private List<PositionInfo> _haltResidue = [];
|
||||
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}");
|
||||
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
public sealed class BasketDefinition
|
||||
{
|
||||
@@ -221,6 +241,9 @@ public sealed class BasketStrategyConfig
|
||||
/// <summary>The margin rules (§10 of the 5.0 plan).</summary>
|
||||
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) ----
|
||||
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<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)
|
||||
{
|
||||
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_<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": [
|
||||
{ "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_<run_id>.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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user