diff --git a/Encelado/CHANGELOG.md b/Encelado/CHANGELOG.md index 178fae8..2b6a8ea 100644 --- a/Encelado/CHANGELOG.md +++ b/Encelado/CHANGELOG.md @@ -2,6 +2,15 @@ Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto. +## 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`. +- Il kill-switch (pulsante, `kill`, file `STOP`) chiude basket, gambe in attesa e orfane; le posizioni esterne solo su richiesta esplicita o con `risk.closeForeignOnKill`; niente è dichiarato chiuso senza la rilettura del conto; i residui finiscono nel banner, nello stato salvato e nel ledger (`kill_switch_concluso`). +- Comando `CloseResidue` (`residuo` da console, «chiudi ora» dalla finestra) per riprovare sui residui. +- Reset in cinque passi (stato, file `STOP`, motivazione, riconciliazione + riscaldamento + picco, ripartenza con 15 minuti di entrate bloccate); rifiutato con `reset_rifiutato` finché una posizione del bot resta sul conto. +- `INotifier` e `NullNotifier`: il motore notifica kill-switch e reset; il canale Telegram arriva con la Fase 5. +- Test (v), (w), (x): 204 verdi. + ## 2026-09-23 — 5.0, Fase 2: il margine come vincolo di primo livello - Sezione `risk` in `strategy.json` (`maxMarginUsePct` 40, `maxMarginPerBasketPct` 12, `marginBufferPct` 25, `closeForeignOnKill` false, `marginCallBlockRatio` 1,5, `marginCallCloseRatio` 1,2), letta, validata e nell'hash della configurazione. diff --git a/Encelado/docs/ARCHITECTURE.md b/Encelado/docs/ARCHITECTURE.md index f98c139..bd7623b 100644 --- a/Encelado/docs/ARCHITECTURE.md +++ b/Encelado/docs/ARCHITECTURE.md @@ -78,7 +78,8 @@ 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) + EquityTracker (picco al netto dei movimenti di cassa), FlattenProcedure (kill-switch in tre passi: annulla, chiudi, verifica la piattezza) +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) src/Encelado.Core/Baskets/Learning/ livelli 0-3: CalibrationTables, OnlineLogistic (SGD+L2, standardizzazione rolling), SmallMlp (16 ReLU, Adam, early stopping, gradient check), @@ -88,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, equity stop, kill-switch, file STOP), + 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), .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) @@ -139,14 +140,14 @@ Idle ──(segnale + cancelli)──► Entering ──(A e B eseguite)── Error (blocco nuove entrate finché non risolto) ``` -Negli stati `PendingA`/`PendingB` il basket non viene valutato e non manda ordini; il registro degli ordini (`OrderTracker`) chiede l'esito al server e, quando manca, lo ricostruisce dalla posizione comparsa sul conto. Gli ingressi in sospeso sopravvivono a un riavvio (`pendingEntries` in `baskets_state.json`) e vengono risolti all'avvio prima di qualsiasi decisione. +Stati del motore (non del basket): `Halted` dopo un kill-switch o un equity stop con il conto piatto per le posizioni del bot; **`Halted-Residuo`** quando la verifica di piattezza trova ancora posizioni del bot sul conto (banner rosso con l'elenco, reset rifiutato finché restano). Negli stati `PendingA`/`PendingB` il basket non viene valutato e non manda ordini; il registro degli ordini (`OrderTracker`) chiede l'esito al server e, quando manca, lo ricostruisce dalla posizione comparsa sul conto. Gli ingressi in sospeso sopravvivono a un riavvio (`pendingEntries` in `baskets_state.json`) e vengono risolti all'avvio prima di qualsiasi decisione. ### 2.4 Interfacce - `IBroker`: `Environment`, `GetInstrumentsAsync`, `GetQuotesAsync(ids)`, `GetCandlesAsync(id, interval, count)`, `GetAccountAsync`, `GetPositionsAsync`, `OpenAsync(OrderRequest)` (esito per `orderId`, poi per posizione comparsa; mai un esito inventato), `LookupOrderAsync(clientRef)`, `LookupOrderByIdAsync(orderId)`, `CancelOrderAsync(orderId)`, `CloseAsync(positionId, instrumentId)`, `UpdateStopsAsync(positionId, sl, tp)`, `GetCostAsync(OrderRequest)`, `GetClosedTradesAsync`, `ClockSkew`. - `IContextProvider` (Bot): calendario, notizie e sentiment per basket (`FeedContextProvider`; `EmptyContextProvider` nei test). - `IModel`: `Predict(features)`, `Update(features, label)`, JSON, implementato da `OnlineLogistic` e `SmallMlp`. -- `IEngine` (Bot): `RunAsync`, `CloseAllAsync`, `ExecuteAsync(EngineCommand)` con `Close`, `KillSwitch`, `SetPreset`, `ResetEquityStop(motivazione)`, `Snapshot()`. +- `IEngine` (Bot): `RunAsync`, `CloseAllAsync`, `ExecuteAsync(EngineCommand)` con `Close`, `KillSwitch` (argomento `esterne` per chiudere anche le posizioni esterne), `SetPreset`, `ResetEquityStop(motivazione)` (la procedura in cinque passi), `CloseResidue`, `Bonifica`, `Snapshot()`. - `IUiActions` (Bot): ciò che le pagine possono chiedere alla finestra (chiudi basket, kill-switch, preset, reset, chiavi, file). ### 2.5 Vincoli e limiti scoperti in Fase 0 diff --git a/Encelado/docs/LEDGER_SCHEMA.md b/Encelado/docs/LEDGER_SCHEMA.md index 72a8356..257cce5 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` | +| `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` | | `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) | @@ -76,7 +76,7 @@ Il registro degli ordini: `savedUtc` e l'array `orders` con gli stessi campi di ## `data/state/baskets_state.json` (campi aggiunti dalla 5.0) -`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). `peakEquity` resta per compatibilità e vale `peakNetEquity + cumulativeCashFlow`. +`peakNetEquity` (picco dell'equity al netto dei movimenti di cassa), `cumulativeCashFlow`, `lastBalance` e `lastBalanceUtc` (per riconoscere un deposito avvenuto a bot spento), `pendingEntries` (un elemento per basket in `PendingA`/`PendingB`: `name`, `state`, `basketId` e il piano `pending` con unità, TP, stop, riferimenti cliente e la gamba A eseguita), `haltResidue` (le posizioni del bot rimaste sul conto dopo un kill-switch: `positionId`, `symbol`, `isBuy`, `units`, `openedUtc`, `origin`, `basket`, `reason`). `peakEquity` resta per compatibilità e vale `peakNetEquity + cumulativeCashFlow`. ## `reports/bonifica_YYYYMMDD.csv` (dalla 5.0) diff --git a/Encelado/docs/RISK_RULES.md b/Encelado/docs/RISK_RULES.md index fc8ab28..58c5b7e 100644 --- a/Encelado/docs/RISK_RULES.md +++ b/Encelado/docs/RISK_RULES.md @@ -37,7 +37,8 @@ Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e c | Movimenti di cassa | un salto del saldo non spiegato dalle chiusure (oltre 10 USD e 0,25 %) è un deposito o un prelievo: scritto nel ledger come `movimento_di_cassa`, escluso dal P&L, dal picco di equity e dal drawdown | codice (`EquityTracker`) | nessuno | | Posizioni non riconciliate | se il P&L aperto del conto e la somma delle posizioni non tornano (oltre 5 USD e 1 %) per più di 60 s, o un basket ha una gamba che il conto non mostra: avviso «posizioni non riconciliate» (banner giallo, riga di stato) | codice | nessuno | | Chiusura incompleta | una gamba non chiude dopo 3 tentativi → stato `Error`, entrate bloccate, allarme | codice | nessuno; si risolve a mano sul conto e con la riconciliazione | -| Kill-switch | pulsante con conferma; file `STOP` in `Documenti\Encelado` (controllato ogni 5 s) | codice | operatore; il reset richiede di rimuovere il file e una motivazione | +| 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 | | 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 c7e3947..6a66b00 100644 --- a/Encelado/docs/RUNBOOK.md +++ b/Encelado/docs/RUNBOOK.md @@ -1,6 +1,6 @@ # Runbook -Aggiornato: 2026-09-23 (5.0, Fase 1). 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 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`. ## Prima volta @@ -38,13 +38,41 @@ Log sulla console e nel file; una riga di stato ogni `run.statusSeconds`. Comand ## Kill-switch -Chiude tutte le gambe a mercato e blocca le nuove entrate. Tre modi: il pulsante **KILL-SWITCH** nella dashboard (chiede conferma), `kill` in headless, oppure un file chiamato `STOP` nella cartella `Documenti\Encelado` (controllato a ogni ciclo; utile da remoto). Il blocco resta finché non fai un **reset**. +Tre modi: il pulsante **KILL-SWITCH** nella dashboard (chiede conferma e, se ci sono posizioni esterne, se chiudere anche quelle: default no), `kill` in headless (stessa domanda), oppure un file chiamato `STOP` nella cartella `Documenti\Encelado` (controllato ogni 5 s; utile da remoto). Dalla 5.0 la procedura è la stessa per i tre e **non dichiara niente chiuso senza averlo riletto dal conto**: -## Equity stop e reset +1. blocco immediato di ogni nuova entrata, riga `kill_switch_avviato` nel ledger; +2. annullamento degli ordini nel registro senza esito (`DELETE …/orders/{id}`), poi attesa della loro risoluzione fino a 30 s: un ordine eseguito nel frattempo diventa una posizione da chiudere; +3. chiusura di **tutte le posizioni del bot**: gambe dei basket (tre tentativi per gamba con verifica sul conto), gambe in attesa, orfane-bot; le posizioni esterne solo se lo hai chiesto o se `risk.closeForeignOnKill = true`; +4. verifica di piattezza: il conto viene riletto finché le posizioni del bot non sono sparite (fino a 120 s); +5. riga `kill_switch_concluso` con l'esito, stato salvato, notifica. -Quando l'equity scende del 9 % dal picco (`equityStopPct`) il bot chiude tutto e si blocca: banner rosso nella dashboard, riga `equity_stop` nel ledger. Per ripartire: **Sblocca…** nel banner, oppure `reset ` in headless. La motivazione (almeno dieci caratteri) finisce nel ledger come riga `correzione`; il picco riparte dall'equity corrente. Non si sblocca senza scrivere perché. +Se qualcosa resta sul conto lo stato è **`Halted-Residuo`**: banner rosso con l'elenco, riga di stato con `RESIDUO`, il reset è rifiutato finché non è piatto. Per riprovare: **Sblocca…** (che prima offre di chiudere i residui), `residuo` in headless (`residuo ` per una sola posizione), oppure chiusura a mano su eToro; la riconciliazione se ne accorge. Il blocco resta finché non fai un **reset**. -La perdita giornaliera del 3 % (`dailyLossPct`) blocca solo le nuove entrate fino alla mezzanotte UTC e non richiede reset. +## Equity stop + +Quando l'equity (al netto dei movimenti di cassa) scende del 9 % dal picco (`equityStopPct`) il bot chiude tutto e si blocca: banner rosso nella dashboard, riga `equity_stop` nel ledger. Per ripartire serve il reset. La perdita giornaliera del 3 % (`dailyLossPct`) blocca solo le nuove entrate fino alla mezzanotte UTC e non richiede reset. + +## Ripristino (reset) in cinque passi + +**Sblocca…** nel banner, `reset ` in headless. Dalla 5.0 il reset è una procedura, non un interruttore; ogni passo viene riportato nel messaggio di esito e nel ledger: + +| Passo | Che cosa fa | Se fallisce | +|---|---|---| +| 1. stato | riporta il motivo del blocco, i residui, gli ordini senza esito, le entrate bloccate | — | +| 2. file STOP | lo rimuove da solo; se ricompare il kill-switch riparte | «file STOP non rimovibile»: permessi della cartella | +| 3. motivazione | almeno dieci caratteri, scritta nel ledger come `correzione` | «motivazione mancante»: riscrivila | +| 4. riconciliazione | rilegge il conto, verifica che nessuna posizione del bot sia rimasta, riscalda le serie dall'API, riporta il picco di equity all'equity corrente (al netto dei movimenti di cassa) | «residui ancora sul conto» → resta `Halted-Residuo` (riga `reset_rifiutato`): chiudili con «chiudi ora» / `residuo` / a mano e ripeti | +| 5. ripartenza | toglie il blocco; **entrate bloccate per 15 minuti** di riscaldamento (`riscaldamento dopo il reset` nel banner giallo), poi il bot riprende da solo | — | + +Sintomo → passo: + +| Sintomo | Passo da guardare | +|---|---| +| banner «KILL-SWITCH CON RESIDUO» | 4: chiudi i residui, poi ripeti il reset | +| «rimuovi prima il file STOP» / «file STOP non rimovibile» | 2: la cartella `Documenti\Encelado` non è scrivibile o il file viene ricreato da un altro processo | +| «motivazione mancante» | 3 | +| dopo il reset il bot non apre per un quarto d'ora | 5: è il riscaldamento voluto | +| dopo il reset compare «posizioni non riconciliate» | 4 non è andato a buon fine sul conto: confronta `pending_orders.json` e le posizioni su eToro | ## Ordini senza esito diff --git a/Encelado/docs/STATE.md b/Encelado/docs/STATE.md index 25abc60..904d166 100644 --- a/Encelado/docs/STATE.md +++ b/Encelado/docs/STATE.md @@ -1,23 +1,25 @@ # Stato del lavoro -Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-2 concluse). +Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-3 concluse). ## Fase in corso -**Piano 5.0, Fase 3** (`docs/PIANO_5.0.md`). Le Fasi 0-2 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. Il forward test in Demo **non è ripartito**: la regola finale del piano dice che nessuna nuova funzione va in Demo finché un ordine dall'esito ignoto può restare sul conto senza padrone; le Fasi 1-2 chiudono quella falla e quella del margine, la Fase 3 (kill-switch reale) va finita prima di riaccendere il bot. +**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. ## 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 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 3 — kill-switch e ripristino** (§9): cancellazione dei pendenti, chiusura di basket + orfane, esterne opzionali, verifica di piattezza, `Halted-Residuo`; procedura di ripristino in cinque passi. Test (v)-(x). -2. Poi Fasi 4-5 (recupero dopo inattività, Telegram) e, **dopo la risposta a D-28/D-29**, le Fasi 6-9 (Engine/Server, web UI, Docker, Unraid, skill). -3. Riaccendere il Demo solo dopo la Fase 3, per 24 ore di verifica: contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti, `sizing_bound = margin` sui primi ingressi. -4. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`. +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`. ## Problemi aperti diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Commands.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Commands.cs index 68d4bed..4bebe83 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Commands.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Commands.cs @@ -60,8 +60,11 @@ public sealed partial class BasketEngine } case EngineCommandKind.KillSwitch: - await KillAsync(c.Reason.Length > 0 ? c.Reason : "comando", ct).ConfigureAwait(false); - return new CommandResult(true, "kill-switch eseguito: tutto chiuso, nuove entrate bloccate"); + { + bool includeForeign = c.Argument.Contains("estern", StringComparison.OrdinalIgnoreCase) || c.Argument.Contains("foreign", StringComparison.OrdinalIgnoreCase); + FlattenReport report = await KillAsync(c.Reason.Length > 0 ? c.Reason : "comando", includeForeign, ct).ConfigureAwait(false); + return new CommandResult(report.Flat, report.Flat ? $"kill-switch eseguito: {report.Summary}; nuove entrate bloccate fino al reset" : $"kill-switch con RESIDUO: {report.Summary}") { Payload = _haltResidue }; + } case EngineCommandKind.SetPreset: if (!BasketPresets.TryParse(c.Argument, out PresetName preset)) @@ -75,29 +78,10 @@ public sealed partial class BasketEngine return new CommandResult(true, $"preset {preset.ToString().ToUpperInvariant()} attivo"); case EngineCommandKind.ResetEquityStop: - if (!_equityStopped && !_killSwitched) - { - return new CommandResult(false, "nessun blocco attivo"); - } + return await ResetAsync(c.Reason, ct).ConfigureAwait(false); - if (c.Reason.Trim().Length < 10) - { - return new CommandResult(false, "serve una motivazione scritta (almeno dieci caratteri)"); - } - - if (File.Exists(_stopFile)) - { - return new CommandResult(false, $"rimuovi prima il file {_stopFile}"); - } - - _equityStopped = false; - _killSwitched = false; - _haltReason = null; - _equity.ResetPeak(_account.Equity); - _ledger.Correction(_runId, string.Empty, $"reset del blocco: {c.Reason}"); - Log.Warn($"blocco rimosso dall'operatore: {c.Reason}. Nuovo picco di equity {_equity.PeakEquity:F2}"); - SaveState(); - return new CommandResult(true, "blocco rimosso; il picco di equity riparte da adesso"); + case EngineCommandKind.CloseResidue: + return await CloseResidueAsync(c.Argument, ct).ConfigureAwait(false); case EngineCommandKind.Bonifica: return await BonificaAsync(c.Argument, ct).ConfigureAwait(false); diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Kill.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Kill.cs new file mode 100644 index 0000000..6c39310 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Kill.cs @@ -0,0 +1,346 @@ +using System.Globalization; +using System.Text; +using Encelado.Bot.Engine; +using Encelado.Bot.Logging; +using Encelado.Core.Baskets; +using Encelado.Core.Broker; +using Encelado.Core.Notifications; + +namespace Encelado.Bot.Baskets; + +/// +/// The kill-switch that closes for real (§9 of the 5.0 plan) and the guided reset. Both +/// are idempotent: a second kill re-runs the procedure on whatever is left; a reset that +/// finds residues stays halted and says so. +/// +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); + + private List _haltResidue = []; + private DateTime? _entriesBlockedUntilUtc; + + /// True after a kill-switch that could not flatten the bot's positions. + private bool HaltedWithResidue => _killSwitched && _haltResidue.Count > 0; + + private async Task CheckStopFileAsync(CancellationToken ct) + { + bool present = File.Exists(_stopFile); + if (present && !_killSwitched) + { + Log.Warn("file STOP trovato: KILL-SWITCH"); + await KillAsync("file STOP", includeForeign: false, ct).ConfigureAwait(false); + } + } + + /// + /// The kill-switch: halt, cancel the pending orders, close every basket leg and every + /// orphan of ours (and the foreign positions when asked or configured), verify on + /// the position list that they are gone, persist, notify. Nothing is declared closed + /// without the verification; what remains is the residue and the state is + /// Halted-Residuo. + /// + private async Task KillAsync(string reason, bool includeForeign, CancellationToken ct) + { + _killSwitched = true; + _haltReason = $"kill-switch ({reason})"; + includeForeign |= _strategy.Risk.CloseForeignOnKill; + _ledger.Note(_runId, "kill_switch_avviato", string.Empty, $"kill-switch: {reason}{(includeForeign ? " (anche le posizioni esterne)" : string.Empty)}"); + Log.Warn($"KILL-SWITCH ({reason}): blocco le entrate, annullo gli ordini pendenti, chiudo basket e orfane{(includeForeign ? " e le posizioni esterne" : string.Empty)}"); + + FlattenProcedure procedure = new(_broker, _tracker, static m => Log.Warn(m)); + + // 1. Pending orders: cancel, then follow until they resolve. A leg that filled + // meanwhile is closed with everything else. + (List cancelled, List filledMeanwhile) = await procedure.CancelPendingAsync(ct).ConfigureAwait(false); + foreach (BrokerPosition p in filledMeanwhile) + { + _knownPositions.Add(p.PositionId); + } + + // 2. Baskets and pending legs through the executor's own exit, so baskets.csv gets + // its rows; then the orphans and, if asked, the strangers. + await CloseAllAsync(_haltReason, ct).ConfigureAwait(false); + + List targets = []; + foreach (BasketSlot slot in _slots) + { + foreach (long id in slot.LegPositionIds()) + { + targets.Add(new FlattenTarget(id, 0, slot.Name, false, 0, slot.Name, "gamba di basket non chiusa")); + } + } + + foreach (ClassifiedPosition c in _classified) + { + if (c.Origin == PositionOrigin.OrphanBot || (includeForeign && c.Origin == PositionOrigin.Foreign)) + { + targets.Add(new FlattenTarget(c.Position.PositionId, c.Position.InstrumentId, SymbolOf(c.Position.InstrumentId), c.Position.IsBuy, c.Position.Units, c.Basket, c.Origin == PositionOrigin.Foreign ? "esterna" : "orfana-bot")); + } + } + + foreach (BrokerPosition p in filledMeanwhile) + { + if (targets.All(t => t.PositionId != p.PositionId)) + { + targets.Add(new FlattenTarget(p.PositionId, p.InstrumentId, SymbolOf(p.InstrumentId), p.IsBuy, p.Units, string.Empty, "orfana-bot")); + } + } + + (List closed, List failed, double realized) = await FlattenProcedure.CloseTargetsAsync( + targets.Where(static t => t.InstrumentId > 0).ToList(), + (t, token) => _executor.ClosePositionAsync(new BrokerPosition(t.PositionId, t.InstrumentId, t.IsBuy, t.Units, 0, DateTime.UtcNow, 0, 0, 1, 0, 0, 0, 0), t.Symbol, t.Basket, $"kill-switch: {reason}", token), + static m => Log.Warn(m), ct).ConfigureAwait(false); + _todayRealized += realized; + foreach (long id in closed) + { + _knownPositions.Remove(id); + _orphanAttempts.Remove(id); + } + + // 3. Flatness: every position the bot owns must be gone from the account. + HashSet mustBeGone = [.. targets.Select(static t => t.PositionId)]; + foreach (BasketSlot slot in _slots) + { + mustBeGone.UnionWith(slot.LegPositionIds()); + } + + List residue = await procedure.VerifyFlatAsync(mustBeGone, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + foreach (ClassifiedPosition c in _classified) + { + if (c.Origin == PositionOrigin.OrphanBot && !residue.Contains(c.Position.PositionId)) + { + residue.Add(c.Position.PositionId); + } + } + + _haltResidue = [.. residue.Select(id => + { + ClassifiedPosition? c = _classified.FirstOrDefault(x => x.Position.PositionId == id); + BrokerPosition? p = c?.Position; + return new PositionInfo(id, p is null ? "?" : SymbolOf(p.InstrumentId), p?.IsBuy ?? false, p?.Units ?? 0, p?.OpenedUtc ?? default, p?.UnrealizedPnl ?? 0, + c?.Origin == PositionOrigin.Foreign ? "esterna" : c?.Origin == PositionOrigin.Basket ? "basket" : "orfana-bot", c?.Basket ?? string.Empty, c?.Reason ?? "non più classificabile"); + })]; + + bool flat = residue.Count == 0; + string summary = string.Create(CultureInfo.InvariantCulture, + $"{closed.Count + failed.Count} posizioni trattate oltre ai basket ({realized:+0.00;-0.00} USD), {cancelled.Count} ordini annullati, {filledMeanwhile.Count} eseguiti nel frattempo, {_tracker.PendingCount} ancora senza esito; {(flat ? "conto piatto per le posizioni del bot" : $"RESIDUO: {string.Join(", ", residue)}")}"); + FlattenReport report = new(cancelled, [.. filledMeanwhile.Select(static p => p.PositionId)], closed, residue, realized, flat, summary); + + if (!flat) + { + _haltReason = $"kill-switch ({reason}) con RESIDUO: {residue.Count} posizioni ancora sul conto"; + Log.Error($"KILL-SWITCH: {summary}", null); + } + else + { + Log.Warn($"KILL-SWITCH concluso: {summary}"); + } + + _ledger.Note(_runId, "kill_switch_concluso", string.Empty, summary, w => + { + w.WriteString("stato", report.State); + w.WriteNumber("chiuse", closed.Count); + w.WriteNumber("residuo", residue.Count); + w.WriteNumber("annullati", cancelled.Count); + w.WriteNumber("pnl", Math.Round(realized, 2)); + }); + SaveState(); + _notifier.Notify(NotificationKind.Alert, "Kill-switch", $"{_haltReason}\n{summary}"); + return report; + } + + /// Closes every basket, every pending leg and every orphan of ours; foreign positions are left alone. No flatness check: the kill-switch adds it. + public async Task CloseAllAsync(string reason, CancellationToken ct) + { + string code = reason.Contains("equity", StringComparison.OrdinalIgnoreCase) ? "equity_stop" : "kill_switch"; + foreach (BasketSlot slot in _slots) + { + if (slot.Position is not null && slot.A.HasQuote && slot.B.HasQuote) + { + BasketContext ctx = await BuildContextAsync(slot, DateTime.UtcNow, false, ct).ConfigureAwait(false); + await ExecuteExitAsync(slot, ctx, null, reason, code, ct).ConfigureAwait(false); + } + + if (slot.State.IsPending() && slot.Pending?.LegA is { PositionId: > 0 } legA) + { + CloseOutcome undo = await _executor.UnwindLegAsync(legA, slot.Name, slot.Pending.BasketId, reason, ct).ConfigureAwait(false); + if (undo.Closed) + { + _todayRealized += undo.RealizedPnl; + _knownPositions.Remove(legA.PositionId); + RecordLoneLegClose(slot.Name, slot.Pending.BasketId, legA, undo, code, reason); + slot.Pending = null; + slot.PositionBasketId = string.Empty; + Transition(slot, BasketState.Idle); + } + } + } + + foreach (ClassifiedPosition c in _classified.Where(static c => c.Origin == PositionOrigin.OrphanBot).ToList()) + { + await CloseOrphanAsync(c, code, reason, ct).ConfigureAwait(false); + } + } + + /// Closes the residue of a kill-switch (one position, or all) and re-verifies. + private async Task CloseResidueAsync(string argument, CancellationToken ct) + { + if (_haltResidue.Count == 0) + { + return new CommandResult(false, "nessun residuo da chiudere"); + } + + List chosen = long.TryParse(argument, NumberStyles.Integer, CultureInfo.InvariantCulture, out long one) + ? [.. _haltResidue.Where(r => r.PositionId == one)] + : [.. _haltResidue]; + if (chosen.Count == 0) + { + return new CommandResult(false, $"posizione {argument} non è fra i residui"); + } + + List targets = [.. chosen.Select(r => new FlattenTarget(r.PositionId, _seriesById.Values.FirstOrDefault(s => s.Symbol == r.Symbol)?.Instrument.Id ?? 0, r.Symbol, r.IsBuy, r.Units, r.Basket, r.Origin))]; + foreach (FlattenTarget t in targets.Where(static t => t.InstrumentId == 0).ToList()) + { + // The instrument id comes from the account when the symbol is not one of ours. + ClassifiedPosition? c = _classified.FirstOrDefault(x => x.Position.PositionId == t.PositionId); + targets.Remove(t); + if (c is not null) + { + targets.Add(t with { InstrumentId = c.Position.InstrumentId }); + } + } + + (List closed, List failed, double realized) = await FlattenProcedure.CloseTargetsAsync(targets, + (t, token) => _executor.ClosePositionAsync(new BrokerPosition(t.PositionId, t.InstrumentId, t.IsBuy, t.Units, 0, DateTime.UtcNow, 0, 0, 1, 0, 0, 0, 0), t.Symbol, t.Basket, "chiusura del residuo dopo il kill-switch", token), + static m => Log.Warn(m), ct).ConfigureAwait(false); + _todayRealized += realized; + FlattenProcedure procedure = new(_broker, _tracker, static m => Log.Warn(m)) { FlatnessTimeout = TimeSpan.FromSeconds(30) }; + List residue = await procedure.VerifyFlatAsync([.. _haltResidue.Select(static r => r.PositionId)], ct).ConfigureAwait(false); + _haltResidue = [.. _haltResidue.Where(r => residue.Contains(r.PositionId))]; + foreach (long id in closed) + { + _knownPositions.Remove(id); + } + + if (_haltResidue.Count == 0) + { + _haltReason = _haltReason?.Replace(" con RESIDUO", string.Empty, StringComparison.Ordinal); + } + + _ledger.Note(_runId, "residuo_chiuso", string.Empty, string.Create(CultureInfo.InvariantCulture, $"{closed.Count} chiuse, {failed.Count} fallite, {residue.Count} ancora sul conto ({realized:+0.00;-0.00} USD)")); + SaveState(); + return new CommandResult(failed.Count == 0 && residue.Count == 0, string.Create(CultureInfo.InvariantCulture, $"{closed.Count} chiuse, {residue.Count} residui")); + } + + /// + /// The guided reset (§9): (1) the state, (2) the STOP file, (3) the written reason, + /// (4) reconciliation, warm-up and the peak of equity, (5) restart with entries + /// blocked for the warm-up. A reset that finds the bot's positions still on the + /// account stays halted with the residue and says which. + /// + private async Task ResetAsync(string reason, CancellationToken ct) + { + List steps = []; + if (!_equityStopped && !_killSwitched) + { + return new CommandResult(false, "nessun blocco attivo"); + } + + // 1. The state. + steps.Add(string.Create(CultureInfo.InvariantCulture, $"1. stato: {(_haltReason ?? "blocco")}; residui {_haltResidue.Count}, ordini senza esito {_tracker.PendingCount}, entrate bloccate: {_entriesBlocked ?? "no"}")); + + // 2. The STOP file. + if (File.Exists(_stopFile)) + { + try + { + File.Delete(_stopFile); + steps.Add($"2. file STOP rimosso ({_stopFile})"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + steps.Add($"2. file STOP NON rimovibile: {ex.Message}"); + return new CommandResult(false, string.Join('\n', steps)) { Payload = steps }; + } + } + else + { + steps.Add("2. nessun file STOP"); + } + + // 3. The reason. + if (!FlattenProcedure.IsValidResetReason(reason)) + { + steps.Add("3. motivazione mancante: servono almeno dieci caratteri"); + return new CommandResult(false, string.Join('\n', steps)) { Payload = steps }; + } + + _ledger.Correction(_runId, string.Empty, $"reset del blocco: {reason.Trim()}"); + steps.Add("3. motivazione scritta nel ledger"); + + // 4. Reconciliation, warm-up, the peak. + await ReconcileAsync(ct).ConfigureAwait(false); + if (_haltResidue.Count > 0) + { + List residue = await new FlattenProcedure(_broker, _tracker, static m => Log.Warn(m)) { FlatnessTimeout = TimeSpan.FromSeconds(5) } + .VerifyFlatAsync([.. _haltResidue.Select(static r => r.PositionId)], ct).ConfigureAwait(false); + _haltResidue = [.. _haltResidue.Where(r => residue.Contains(r.PositionId))]; + } + + if (_haltResidue.Count > 0 || _classified.Any(static c => c.Origin == PositionOrigin.OrphanBot)) + { + steps.Add(string.Create(CultureInfo.InvariantCulture, $"4. riconciliazione: {_haltResidue.Count} residui e {_orphanCount} orfane ancora sul conto: il blocco resta (Halted-Residuo). Chiudile con «chiudi ora» o a mano su eToro, poi ripeti il reset")); + _ledger.Note(_runId, "reset_rifiutato", string.Empty, steps[^1]); + SaveState(); + return new CommandResult(false, string.Join('\n', steps)) { Payload = steps }; + } + + try + { + await WarmupAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Log.Warn($"riscaldamento dopo il reset non completato: {ex.Message}"); + } + + _equity.ResetPeak(_account.Equity); + steps.Add(string.Create(CultureInfo.InvariantCulture, $"4. riconciliazione completa, serie riscaldate, picco di equity riportato a {_equity.PeakEquity:F2} (al netto dei movimenti di cassa)")); + + // 5. Restart, entries blocked for the warm-up. + _equityStopped = false; + _killSwitched = false; + _haltReason = null; + _haltResidue = []; + foreach (BasketSlot slot in _slots.Where(static s => s.State == BasketState.Error && s.Position is null)) + { + Transition(slot, BasketState.Idle); + } + + _entriesBlockedUntilUtc = DateTime.UtcNow + ResetWarmup; + _entriesBlocked = string.Create(CultureInfo.InvariantCulture, $"riscaldamento dopo il reset fino alle {_entriesBlockedUntilUtc:HH:mm} UTC"); + steps.Add($"5. bot ripartito; entrate bloccate per {ResetWarmup.TotalMinutes:0} minuti"); + _ledger.Note(_runId, "reset_concluso", string.Empty, string.Join(" | ", steps)); + Log.Warn($"RESET concluso dall'operatore: {reason.Trim()}"); + _notifier.Notify(NotificationKind.Alert, "Reset", string.Join('\n', steps)); + SaveState(); + return new CommandResult(true, string.Join('\n', steps)) { Payload = steps }; + } + + /// Lifts the warm-up block once its time has passed. + private void ExpireWarmupBlock(DateTime now) + { + if (_entriesBlockedUntilUtc is { } until && now >= until) + { + _entriesBlockedUntilUtc = null; + if (_entriesBlocked is not null && _entriesBlocked.StartsWith("riscaldamento", StringComparison.Ordinal)) + { + _entriesBlocked = null; + Log.Info("riscaldamento concluso: entrate riabilitate"); + } + } + } +} diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs index 84ab7ae..41bf55b 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs @@ -467,56 +467,4 @@ public sealed partial class BasketEngine SaveState(); } } - - private async Task CheckStopFileAsync(CancellationToken ct) - { - bool present = File.Exists(_stopFile); - if (present && !_killSwitched) - { - Log.Warn("file STOP trovato: KILL-SWITCH"); - await KillAsync("file STOP", ct).ConfigureAwait(false); - } - } - - private async Task KillAsync(string reason, CancellationToken ct) - { - _killSwitched = true; - _haltReason = $"kill-switch ({reason})"; - _ledger.Note(_runId, "kill_switch_avviato", string.Empty, $"kill-switch: {reason}"); - await CloseAllAsync(_haltReason, ct).ConfigureAwait(false); - SaveState(); - } - - /// Closes every basket and every orphan of ours; foreign positions are left alone. - public async Task CloseAllAsync(string reason, CancellationToken ct) - { - string code = reason.Contains("equity", StringComparison.OrdinalIgnoreCase) ? "equity_stop" : "kill_switch"; - foreach (BasketSlot slot in _slots) - { - if (slot.Position is not null && slot.A.HasQuote && slot.B.HasQuote) - { - BasketContext ctx = await BuildContextAsync(slot, DateTime.UtcNow, false, ct).ConfigureAwait(false); - await ExecuteExitAsync(slot, ctx, null, reason, code, ct).ConfigureAwait(false); - } - - if (slot.State.IsPending() && slot.Pending?.LegA is { PositionId: > 0 } legA) - { - CloseOutcome undo = await _executor.UnwindLegAsync(legA, slot.Name, slot.Pending.BasketId, reason, ct).ConfigureAwait(false); - if (undo.Closed) - { - _todayRealized += undo.RealizedPnl; - _knownPositions.Remove(legA.PositionId); - RecordLoneLegClose(slot.Name, slot.Pending.BasketId, legA, undo, code, reason); - slot.Pending = null; - slot.PositionBasketId = string.Empty; - Transition(slot, BasketState.Idle); - } - } - } - - foreach (ClassifiedPosition c in _classified.Where(static c => c.Origin == PositionOrigin.OrphanBot).ToList()) - { - await CloseOrphanAsync(c, code, reason, ct).ConfigureAwait(false); - } - } } diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Snapshot.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Snapshot.cs index 19a8948..2d43a42 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Snapshot.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Snapshot.cs @@ -81,6 +81,7 @@ public sealed partial class BasketEngine UnreconciledReason = _unreconciledReason, Halted = _killSwitched || _equityStopped, HaltReason = _haltReason, + HaltResidue = _haltResidue, EquityStopped = _equityStopped, KillSwitched = _killSwitched, EntriesBlockedReason = _entriesBlocked, diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.State.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.State.cs index e25243b..946ce7e 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.State.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.State.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text.Json; +using Encelado.Bot.Engine; using Encelado.Bot.Logging; using Encelado.Core.Baskets; @@ -34,6 +35,22 @@ public sealed partial class BasketEngine w.WriteBoolean("killSwitched", _killSwitched); w.WriteBoolean("equityStopped", _equityStopped); w.WriteString("haltReason", _haltReason ?? string.Empty); + w.WriteStartArray("haltResidue"); + foreach (PositionInfo r in _haltResidue) + { + w.WriteStartObject(); + w.WriteNumber("positionId", r.PositionId); + w.WriteString("symbol", r.Symbol); + w.WriteBoolean("isBuy", r.IsBuy); + w.WriteNumber("units", r.Units); + w.WriteString("openedUtc", r.OpenedUtc == default ? string.Empty : r.OpenedUtc.ToString("O", CultureInfo.InvariantCulture)); + w.WriteString("origin", r.Origin); + w.WriteString("basket", r.Basket); + w.WriteString("reason", r.Reason); + w.WriteEndObject(); + } + + w.WriteEndArray(); w.WriteStartArray("baskets"); foreach (BasketSlot s in _slots) { @@ -131,6 +148,22 @@ public sealed partial class BasketEngine Log.Warn($"equity stop ancora attivo dal run precedente: {_haltReason}"); } + if (root.TryGetProperty("haltResidue", out JsonElement residue)) + { + _haltResidue = []; + foreach (JsonElement r in residue.EnumerateArray()) + { + DateTime opened = r.TryGetProperty("openedUtc", out JsonElement ou) && DateTime.TryParse(ou.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t2) ? t2 : default; + _haltResidue.Add(new PositionInfo(r.GetProperty("positionId").GetInt64(), r.GetProperty("symbol").GetString() ?? "?", r.GetProperty("isBuy").GetBoolean(), r.GetProperty("units").GetDouble(), opened, 0, + r.GetProperty("origin").GetString() ?? "orfana-bot", r.GetProperty("basket").GetString() ?? string.Empty, r.GetProperty("reason").GetString() ?? string.Empty)); + } + + if (_haltResidue.Count > 0) + { + Log.Warn($"kill-switch con residuo dal run precedente: {_haltResidue.Count} posizioni da chiudere ({string.Join(", ", _haltResidue.Select(static r => r.PositionId))})"); + } + } + if (root.TryGetProperty("baskets", out JsonElement arr)) { foreach (JsonElement e in arr.EnumerateArray()) diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs index 8b6e513..aef2e77 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs @@ -11,6 +11,7 @@ using Encelado.Core.Baskets.Data; using Encelado.Core.Baskets.History; using Encelado.Core.Baskets.Learning; using Encelado.Core.Broker; +using Encelado.Core.Notifications; using Encelado.Etoro; namespace Encelado.Bot.Baskets; @@ -67,6 +68,7 @@ public sealed partial class BasketEngine : IEngine private readonly BasketDecider _decider; private readonly BasketExecutor _executor; private readonly OrderTracker _tracker; + private readonly INotifier _notifier; private readonly EquityTracker _equity = new(); private readonly Ledger _ledger; private readonly LearningState _learning; @@ -173,11 +175,12 @@ public sealed partial class BasketEngine : IEngine } } - public BasketEngine(BotConfig config, bool startConfirmed, IContextProvider? context = null) + public BasketEngine(BotConfig config, bool startConfirmed, IContextProvider? context = null, INotifier? notifier = null) { ArgumentNullException.ThrowIfNull(config); _config = config.Validate(); _mode = config.Run.Mode; + _notifier = notifier ?? NullNotifier.Instance; if (_mode.IsLive() && !startConfirmed) { @@ -505,6 +508,7 @@ public sealed partial class BasketEngine : IEngine DateTime now = DateTime.UtcNow; await DrainCommandsAsync(ct).ConfigureAwait(false); RollSessionIfNeeded(now); + ExpireWarmupBlock(now); // Orders without an outcome come first: their resolution changes what the // reconciliation and the decisions below see. diff --git a/Encelado/src/Encelado.Bot/Baskets/HeadlessRunner.cs b/Encelado/src/Encelado.Bot/Baskets/HeadlessRunner.cs index 4a3db3e..81394d9 100644 --- a/Encelado/src/Encelado.Bot/Baskets/HeadlessRunner.cs +++ b/Encelado/src/Encelado.Bot/Baskets/HeadlessRunner.cs @@ -99,7 +99,7 @@ public static class HeadlessRunner return 5; } - Log.Info($"bot avviato in {mode}. Comandi: status, close , kill, preset , reset , bonifica, stop"); + Log.Info($"bot avviato in {mode}. Comandi: status, close , kill, residuo [id], preset , reset , bonifica, stop"); if (minutes > 0) { Log.Info($"arresto automatico fra {minutes} minuti"); @@ -200,7 +200,30 @@ public static class HeadlessRunner result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Close, arg, "chiusura manuale da console"), CancellationToken.None).ConfigureAwait(false); break; case "kill": - result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, string.Empty, "kill-switch da console"), CancellationToken.None).ConfigureAwait(false); + { + BotSnapshot now = supervisor.Snapshot(); + bool foreign = false; + if (now.ForeignPositions > 0) + { + Console.Write($"sul conto ci sono {now.ForeignPositions} posizioni esterne: chiudere anche quelle? [s/N] "); + string? answer = await Console.In.ReadLineAsync(stopping.Token).ConfigureAwait(false); + foreign = answer?.Trim().ToLowerInvariant() is "s" or "si" or "sì" or "y" or "yes"; + } + + result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, foreign ? "esterne" : string.Empty, "kill-switch da console"), CancellationToken.None).ConfigureAwait(false); + if (result.Payload is IReadOnlyList { Count: > 0 } residue) + { + foreach (PositionInfo r in residue) + { + Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" RESIDUO {r.PositionId} {r.Symbol} {(r.IsBuy ? "long" : "short")} {r.Units:0.##} ({r.Origin}): 'residuo' per riprovare a chiuderli")); + } + } + + break; + } + + case "residuo": + result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.CloseResidue, arg.Length > 0 ? arg : "all", "chiusura dei residui da console"), CancellationToken.None).ConfigureAwait(false); break; case "preset": result = await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.SetPreset, arg, "cambio preset da console"), CancellationToken.None).ConfigureAwait(false); @@ -212,7 +235,7 @@ public static class HeadlessRunner await BonificaAsync(supervisor, stopping.Token).ConfigureAwait(false); continue; default: - Console.WriteLine("comandi: status, close , kill, preset , reset , bonifica, stop"); + Console.WriteLine("comandi: status, close , kill, residuo [id], preset , reset , bonifica, stop"); continue; } diff --git a/Encelado/src/Encelado.Bot/Engine/BotSnapshot.cs b/Encelado/src/Encelado.Bot/Engine/BotSnapshot.cs index 3fdd142..90f19af 100644 --- a/Encelado/src/Encelado.Bot/Engine/BotSnapshot.cs +++ b/Encelado/src/Encelado.Bot/Engine/BotSnapshot.cs @@ -214,6 +214,11 @@ public sealed record BotSnapshot public string? HaltReason { get; init; } + /// After a kill-switch that could not flatten: the bot's positions still on the account. + public IReadOnlyList HaltResidue { get; init; } = []; + + public bool HaltedWithResidue => Halted && HaltResidue.Count > 0; + public bool EquityStopped { get; init; } public bool KillSwitched { get; init; } diff --git a/Encelado/src/Encelado.Bot/Engine/IEngine.cs b/Encelado/src/Encelado.Bot/Engine/IEngine.cs index b0ee644..00189a1 100644 --- a/Encelado/src/Encelado.Bot/Engine/IEngine.cs +++ b/Encelado/src/Encelado.Bot/Engine/IEngine.cs @@ -6,15 +6,23 @@ public enum EngineCommandKind /// Close one basket at market. Argument = basket name. Close = 0, - /// Close everything now, block new entries until reset. + /// Close everything now, block new entries until reset. Argument = esterne to close the foreign positions too. KillSwitch, /// Change the style preset at runtime. Argument = Conservative | Moderate | Aggressive. SetPreset, - /// Lift the equity stop or the kill-switch. Reason is written to the ledger and must not be empty. + /// + /// The guided reset (§9 of the 5.0 plan): state, STOP file, written reason (at least + /// ten characters, in Reason), reconciliation with warm-up and a fresh peak, + /// restart with entries blocked for the warm-up. Refused, with the list, while the + /// bot's positions are still on the account. + /// ResetEquityStop, + /// Closes what a kill-switch left on the account: Argument = a position id, or all. + CloseResidue, + /// /// The one-off clean-up of orphan legs (§5.5 of the 5.0 plan). Argument: /// list returns the orphans and the foreign positions as diff --git a/Encelado/src/Encelado.Bot/MainWindow.xaml.cs b/Encelado/src/Encelado.Bot/MainWindow.xaml.cs index da1618d..5e802d7 100644 --- a/Encelado/src/Encelado.Bot/MainWindow.xaml.cs +++ b/Encelado/src/Encelado.Bot/MainWindow.xaml.cs @@ -316,8 +316,16 @@ public partial class MainWindow : Window, IUiActions return; } - Log.Warn("KILL-SWITCH richiesto dalla finestra"); - Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, string.Empty, "kill-switch dalla finestra"), CancellationToken.None).ConfigureAwait(true)); + bool foreign = false; + if (_vm.ForeignPositions > 0) + { + foreign = MessageBox.Show(this, + $"Sul conto ci sono {_vm.ForeignPositions} posizioni ESTERNE, non aperte dal bot.\n\nChiudere anche quelle? (No = restano aperte, come da regola.)", + "Kill-switch: posizioni esterne", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) == MessageBoxResult.Yes; + } + + Log.Warn($"KILL-SWITCH richiesto dalla finestra{(foreign ? " (anche le posizioni esterne)" : string.Empty)}"); + Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, foreign ? "esterne" : string.Empty, "kill-switch dalla finestra"), CancellationToken.None).ConfigureAwait(true)); } public async Task SetPresetAsync(string preset) @@ -333,6 +341,24 @@ public partial class MainWindow : Window, IUiActions public async Task ResetEquityStopAsync() { + BotSnapshot current = _supervisor.Snapshot(); + if (current.HaltedWithResidue) + { + if (MessageBox.Show(this, + $"Il kill-switch ha lasciato {current.HaltResidue.Count} posizioni del bot sul conto:\n\n" + + string.Join("\n", current.HaltResidue.Select(static r => $" {r.PositionId} {r.Symbol} {(r.IsBuy ? "long" : "short")} {r.Units:0.##} ({r.Origin})")) + + "\n\nChiuderle ora? Il reset non può partire finché restano sul conto.", + "Residui del kill-switch", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No) == MessageBoxResult.Yes) + { + Report(await _supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.CloseResidue, "all", "chiusura dei residui dalla finestra"), CancellationToken.None).ConfigureAwait(true)); + } + + if (_supervisor.Snapshot().HaltedWithResidue) + { + return; + } + } + PromptWindow prompt = new( "Reset del blocco", "Il bot ha chiuso tutto e si è bloccato (equity stop o kill-switch). Prima di ripartire scrivi perché ritieni di poterlo fare: la motivazione finisce nel ledger.", diff --git a/Encelado/src/Encelado.Bot/Ui/MainViewModel.cs b/Encelado/src/Encelado.Bot/Ui/MainViewModel.cs index 4b66e84..43affe7 100644 --- a/Encelado/src/Encelado.Bot/Ui/MainViewModel.cs +++ b/Encelado/src/Encelado.Bot/Ui/MainViewModel.cs @@ -297,6 +297,14 @@ public sealed class MainViewModel : INotifyPropertyChanged return; } + if (s.HaltedWithResidue) + { + Banner = $"KILL-SWITCH CON RESIDUO — {s.HaltReason}. Posizioni ancora sul conto: {string.Join(", ", s.HaltResidue.Select(static r => $"{r.PositionId} {r.Symbol} {(r.IsBuy ? "long" : "short")} ({r.Origin})"))}. Chiudile con «Sblocca…» (chiusura dei residui) o a mano su eToro."; + HasBanner = true; + BannerIsWarning = false; + return; + } + if (s.Halted) { Banner = $"OPERATIVITÀ SOSPESA — {s.HaltReason}"; diff --git a/Encelado/src/Encelado.Core/Baskets/FlattenProcedure.cs b/Encelado/src/Encelado.Core/Baskets/FlattenProcedure.cs new file mode 100644 index 0000000..9cf9e21 --- /dev/null +++ b/Encelado/src/Encelado.Core/Baskets/FlattenProcedure.cs @@ -0,0 +1,210 @@ +using System.Globalization; +using Encelado.Core.Broker; + +namespace Encelado.Core.Baskets; + +/// A position the kill-switch must close, with what it is for the report. +public sealed record FlattenTarget(long PositionId, long InstrumentId, string Symbol, bool IsBuy, double Units, string Basket, string Kind); + +/// What the kill-switch did and what is left. +public sealed record FlattenReport( + IReadOnlyList CancelledOrders, + IReadOnlyList OrdersFilledMeanwhile, + IReadOnlyList Closed, + IReadOnlyList Residue, + double RealizedPnlUsd, + bool Flat, + string Summary) +{ + /// The engine's state after the procedure: Halted when flat, Halted-Residuo otherwise. + public string State => Flat ? "Halted" : "Halted-Residuo"; +} + +/// +/// The kill-switch of §9 of the 5.0 plan, as three steps any engine can compose and a +/// test can drive over a fake venue: +/// +/// : every order in the register without an outcome +/// is cancelled (when the venue has a cancel route) and then followed until it resolves or +/// the wait runs out; one that filled meanwhile becomes a position to close. +/// : every target is closed through the caller's closer +/// (three attempts and a check on the position list live in the executor). +/// : the position list is re-read until none of the ids +/// that must be gone is there, or the timeout passes. Nothing is declared closed without +/// this reading; what remains is the residue and the state is Halted-Residuo. +/// +/// +public sealed class FlattenProcedure(IBroker broker, OrderTracker tracker, Action log) +{ + private readonly IBroker _broker = broker ?? throw new ArgumentNullException(nameof(broker)); + private readonly OrderTracker _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); + private readonly Action _log = log ?? (static _ => { }); + + /// How long to wait for a cancelled order to resolve. + public TimeSpan CancelWait { get; init; } = TimeSpan.FromSeconds(30); + + /// How long the position list may still show a target before it is a residue. + public TimeSpan FlatnessTimeout { get; init; } = TimeSpan.FromSeconds(120); + + /// How often the position list is re-read while waiting. + public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2); + + /// Step 1. Returns the orders cancelled and the positions of the orders that filled anyway. + public async Task<(List Cancelled, List FilledMeanwhile)> CancelPendingAsync(CancellationToken ct) + { + List cancelled = []; + List filled = []; + IReadOnlyList pending = _tracker.Pending; + if (pending.Count == 0) + { + return (cancelled, filled); + } + + foreach (TrackedOrder o in pending) + { + if (o.OrderId <= 0) + { + continue; + } + + try + { + if (await _broker.CancelOrderAsync(o.OrderId, ct).ConfigureAwait(false)) + { + _log($"kill-switch: richiesto l'annullamento dell'ordine {o.OrderId} ({o.Describe()})"); + } + } + catch (BrokerException ex) + { + _log($"kill-switch: annullamento dell'ordine {o.OrderId} non riuscito ({ex.Message}): attendo la risoluzione"); + } + } + + DateTime deadline = DateTime.UtcNow + CancelWait; + while (_tracker.PendingCount > 0 && DateTime.UtcNow < deadline) + { + foreach (TrackedOrder o in _tracker.Pending) + { + o.LastCheckUtc = default; + } + + List<(TrackedOrder Order, OrderOutcome Outcome)> resolved = await _tracker.ResolveAsync(_broker, DateTime.UtcNow, null, ct).ConfigureAwait(false); + foreach ((TrackedOrder order, OrderOutcome outcome) in resolved) + { + if (outcome.Filled && outcome.PositionId > 0) + { + _log($"kill-switch: l'ordine {order.OrderId} è stato eseguito nel frattempo (posizione {outcome.PositionId}): la chiudo"); + filled.Add(new BrokerPosition(outcome.PositionId, order.InstrumentId, order.IsBuy, outcome.Units, outcome.FillRate, outcome.TimeUtc, 0, 0, 1, 0, 0, 0, outcome.FillRate)); + } + else + { + cancelled.Add(order.OrderId); + } + } + + if (_tracker.PendingCount > 0) + { + await Task.Delay(PollInterval, ct).ConfigureAwait(false); + } + } + + foreach (TrackedOrder o in _tracker.Pending) + { + _log($"kill-switch: {o.Describe()} ancora senza esito dopo {CancelWait.TotalSeconds:0} s: resta nel registro e verrà chiuso quando comparirà"); + } + + return (cancelled, filled); + } + + /// Step 2. Closes every target through ; returns the ids closed, the ids that refused, and the realised result. + public static async Task<(List Closed, List Failed, double Realized)> CloseTargetsAsync( + IReadOnlyList targets, Func> closer, Action log, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(closer); + ArgumentNullException.ThrowIfNull(log); + List closed = []; + List failed = []; + double realized = 0; + foreach (FlattenTarget t in targets) + { + CloseOutcome c; + try + { + c = await closer(t, ct).ConfigureAwait(false); + } + catch (BrokerException ex) + { + c = new CloseOutcome(false, false, 0, 0, 0, DateTime.UtcNow, 0, ex.Message); + } + + if (c.Closed) + { + closed.Add(t.PositionId); + realized += c.RealizedPnl; + log(string.Create(CultureInfo.InvariantCulture, $"kill-switch: chiusa {t.Kind} {t.PositionId} {t.Symbol} {(t.IsBuy ? "long" : "short")} {t.Units:0.##} ({c.RealizedPnl:+0.00;-0.00} USD)")); + } + else + { + failed.Add(t.PositionId); + log($"kill-switch: {t.Kind} {t.PositionId} {t.Symbol} NON chiusa: {c.Error}"); + } + } + + return (closed, failed, realized); + } + + /// Step 3. Re-reads the positions until none of is there, or the timeout passes. Returns what is left. + public async Task> VerifyFlatAsync(IReadOnlyCollection mustBeGone, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(mustBeGone); + DateTime deadline = DateTime.UtcNow + FlatnessTimeout; + List residue = [.. mustBeGone]; + while (true) + { + try + { + IReadOnlyList positions = await _broker.GetPositionsAsync(ct).ConfigureAwait(false); + HashSet onVenue = [.. positions.Select(static p => p.PositionId)]; + residue = [.. mustBeGone.Where(onVenue.Contains)]; + } + catch (BrokerException ex) + { + _log($"kill-switch: verifica di piattezza non riuscita ({ex.Message}): riprovo"); + } + + if (residue.Count == 0 || DateTime.UtcNow >= deadline) + { + return residue; + } + + await Task.Delay(PollInterval, ct).ConfigureAwait(false); + } + } + + /// The whole procedure over a set of targets: cancel, close, verify. + public async Task RunAsync(IReadOnlyList targets, Func> closer, Func? symbolOf, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(closer); + (List cancelled, List filledMeanwhile) = await CancelPendingAsync(ct).ConfigureAwait(false); + List all = [.. targets]; + foreach (BrokerPosition p in filledMeanwhile) + { + if (all.All(t => t.PositionId != p.PositionId)) + { + all.Add(new FlattenTarget(p.PositionId, p.InstrumentId, symbolOf?.Invoke(p.InstrumentId) ?? p.InstrumentId.ToString(CultureInfo.InvariantCulture), p.IsBuy, p.Units, string.Empty, "orfana-bot")); + } + } + + (List closed, List failed, double realized) = await CloseTargetsAsync(all, closer, _log, ct).ConfigureAwait(false); + List residue = await VerifyFlatAsync([.. all.Select(static t => t.PositionId)], ct).ConfigureAwait(false); + bool flat = residue.Count == 0; + string summary = string.Create(CultureInfo.InvariantCulture, + $"{closed.Count} posizioni chiuse su {all.Count} ({realized:+0.00;-0.00} USD), {cancelled.Count} ordini annullati, {filledMeanwhile.Count} eseguiti nel frattempo, {_tracker.PendingCount} ancora senza esito; {(flat ? "conto piatto per le posizioni del bot" : $"RESIDUO: {string.Join(", ", residue)}")}"); + return new FlattenReport(cancelled, [.. filledMeanwhile.Select(static p => p.PositionId)], closed, residue, realized, flat, summary); + } + + /// The reset's first gate: a written reason of at least ten characters. + public static bool IsValidResetReason(string? reason) => (reason ?? string.Empty).Trim().Length >= 10; +} diff --git a/Encelado/src/Encelado.Core/Notifications/INotifier.cs b/Encelado/src/Encelado.Core/Notifications/INotifier.cs new file mode 100644 index 0000000..f86a34b --- /dev/null +++ b/Encelado/src/Encelado.Core/Notifications/INotifier.cs @@ -0,0 +1,44 @@ +namespace Encelado.Core.Notifications; + +/// What kind of message a notification is, so a channel can route or throttle it. +public enum NotificationKind +{ + /// Something happened: a basket opened or closed, an order resolved, the bot started. + Event = 0, + + /// Something needs a person: kill-switch, equity stop, residues, persistent errors. + Alert, + + /// The hourly status. + Hourly, + + /// The daily summary. + Daily, +} + +/// +/// Where the engine tells the outside world what it did (§7 of the 5.0 plan). The +/// engine never waits on a channel and never fails because of one: an implementation +/// queues and reports its own state through . +/// +public interface INotifier +{ + /// One line for the dashboard: last delivery, queue length, last error. + string Status { get; } + + /// Queues a message. Returns at once; delivery is the channel's business. + void Notify(NotificationKind kind, string title, string text); +} + +/// No channel configured: everything is dropped, the status says so. +public sealed class NullNotifier : INotifier +{ + public static readonly NullNotifier Instance = new(); + + public string Status => "notifiche non configurate"; + + public void Notify(NotificationKind kind, string title, string text) + { + // Nothing to deliver to. + } +} diff --git a/Encelado/tests/Encelado.Tests/KillSwitchTests.cs b/Encelado/tests/Encelado.Tests/KillSwitchTests.cs new file mode 100644 index 0000000..f3506cb --- /dev/null +++ b/Encelado/tests/Encelado.Tests/KillSwitchTests.cs @@ -0,0 +1,100 @@ +using Encelado.Core.Baskets; +using Encelado.Core.Broker; + +namespace Encelado.Tests; + +/// (v) Two baskets, one orphan, one stranger: five legs closed, the stranger left, flatness verified. (w), (x): the reset's gates. +public class KillSwitchTests +{ + private static BrokerPosition Position(long id, long instrument, bool buy, double units) => + new(id, instrument, buy, units, 1.1, DateTime.UtcNow.AddHours(-1), 0, 0, 10, units * 0.11, 0, 0, 1.1); + + private static SlowVenue VenueWithFiveOfOursAndAStranger(out List ours) + { + SlowVenue venue = new(); + venue.Positions[1] = Position(1, 1, true, 10_000); // basket 1, leg A + venue.Positions[2] = Position(2, 6, true, 9_000); // basket 1, leg B + venue.Positions[3] = Position(3, 7, false, 20_000); // basket 2, leg A + venue.Positions[4] = Position(4, 4, false, 15_000); // basket 2, leg B + venue.Positions[5] = Position(5, 12, false, 17_420); // an orphan of ours + venue.Positions[9] = Position(9, 1531, true, 12); // a stranger (HAL) + ours = + [ + new(1, 1, "EURUSD", true, 10_000, "EURUSD/USDCHF", "basket"), + new(2, 6, "USDCHF", true, 9_000, "EURUSD/USDCHF", "basket"), + new(3, 7, "AUDUSD", false, 20_000, "AUDUSD/USDCAD", "basket"), + new(4, 4, "USDCAD", false, 15_000, "AUDUSD/USDCAD", "basket"), + new(5, 12, "EURAUD", false, 17_420, "EURAUD/AUDCAD", "orfana-bot"), + ]; + return venue; + } + + [Fact] + public async Task TheKillSwitchClosesEveryLegOfOursLeavesTheStrangerAndVerifiesFlatness() + { + SlowVenue venue = VenueWithFiveOfOursAndAStranger(out List ours); + OrderTracker tracker = new(string.Empty); + FlattenProcedure procedure = new(venue, tracker, static _ => { }) { PollInterval = TimeSpan.FromMilliseconds(50), FlatnessTimeout = TimeSpan.FromSeconds(2) }; + + FlattenReport report = await procedure.RunAsync(ours, (t, ct) => venue.CloseAsync(t.PositionId, t.InstrumentId, ct), null, CancellationToken.None); + + Assert.True(report.Flat, report.Summary); + Assert.Equal("Halted", report.State); + Assert.Equal(5, report.Closed.Count); + Assert.Empty(report.Residue); + Assert.Single(venue.Positions); + Assert.True(venue.Positions.ContainsKey(9), "la posizione esterna resta sul conto"); + Assert.Equal(5 * 1.5, report.RealizedPnlUsd, 6); + } + + [Fact] + public async Task APendingOrderIsCancelledOrFollowedAndItsFillIsClosedToo() + { + SlowVenue venue = new() { FillsAppearOnAccount = true }; + OrderTracker tracker = new(string.Empty); + // An order sent earlier, still without an outcome in the register; the venue filled it. + TrackedOrder pending = new() { ClientRef = "p1", OrderId = 0, Symbol = "EURUSD", InstrumentId = 1, IsBuy = true, RequestedUnits = 10_000, Basket = "EURUSD/USDCHF", Leg = OrderLeg.A, SentUtc = DateTime.UtcNow.AddSeconds(-30) }; + tracker.Register(pending); + venue.Positions[500] = Position(500, 1, true, 10_000) with { OpenedUtc = DateTime.UtcNow.AddSeconds(-29) }; + FlattenProcedure procedure = new(venue, tracker, static _ => { }) { PollInterval = TimeSpan.FromMilliseconds(50), CancelWait = TimeSpan.FromSeconds(2), FlatnessTimeout = TimeSpan.FromSeconds(2) }; + + FlattenReport report = await procedure.RunAsync([], (t, ct) => venue.CloseAsync(t.PositionId, t.InstrumentId, ct), null, CancellationToken.None); + + Assert.True(report.Flat, report.Summary); + Assert.Contains(500, report.OrdersFilledMeanwhile); + Assert.Contains(500, report.Closed); + Assert.Empty(venue.Positions); + Assert.Equal(0, tracker.PendingCount); + } + + /// (x) A leg that refuses to close: the state is Halted-Residuo and the residue names it. + [Fact] + public async Task ALegThatWillNotCloseLeavesAResidueAndTheHaltedResidueState() + { + SlowVenue venue = VenueWithFiveOfOursAndAStranger(out List ours); + OrderTracker tracker = new(string.Empty); + FlattenProcedure procedure = new(venue, tracker, static _ => { }) { PollInterval = TimeSpan.FromMilliseconds(50), FlatnessTimeout = TimeSpan.FromMilliseconds(300) }; + + FlattenReport report = await procedure.RunAsync(ours, + (t, ct) => t.PositionId == 3 ? Task.FromResult(new CloseOutcome(false, false, 0, 0, 0, DateTime.UtcNow, 0, "il server non risponde")) : venue.CloseAsync(t.PositionId, t.InstrumentId, ct), + null, CancellationToken.None); + + Assert.False(report.Flat); + Assert.Equal("Halted-Residuo", report.State); + Assert.Equal([3L], report.Residue); + Assert.Equal(4, report.Closed.Count); + Assert.Contains("RESIDUO", report.Summary, StringComparison.Ordinal); + Assert.True(venue.Positions.ContainsKey(3)); + Assert.True(venue.Positions.ContainsKey(9)); + } + + /// (w) A reset without a written reason is refused. + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("ok", false)] + [InlineData(" nove ch ", false)] + [InlineData("verificato il conto su eToro, tutto piatto", true)] + public void TheResetNeedsAReasonOfAtLeastTenCharacters(string? reason, bool valid) => + Assert.Equal(valid, FlattenProcedure.IsValidResetReason(reason)); +}