5.0 Fase 3: kill-switch che chiude davvero e ripristino in cinque passi

Il kill-switch della 4.0.0 chiudeva gli slot, non le posizioni, e «riusciva»
con venti gambe ancora sul conto. Ora annulla gli ordini senza esito e ne
attende la risoluzione, chiude basket, gambe in attesa e orfane (le esterne
solo su richiesta o con risk.closeForeignOnKill), rilegge il conto finché le
posizioni del bot non sono sparite e, se qualcosa resta, dichiara
Halted-Residuo con l'elenco invece di «tutto chiuso». Il reset è una procedura
in cinque passi (stato, file STOP, motivazione, riconciliazione con
riscaldamento e picco, ripartenza con entrate bloccate) rifiutata finché il
conto non è piatto. INotifier per le notifiche della Fase 5. Test (v)-(x).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:08:47 +02:00
co-authored by Claude Fable 5.1
parent 327d3c6981
commit aaec965241
20 changed files with 883 additions and 102 deletions
+9
View File
@@ -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.
+5 -4
View File
@@ -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
+2 -2
View File
@@ -16,7 +16,7 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15
| `cross` | testo | cross sintetico (`EURCHF`) |
| `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)
+2 -1
View File
@@ -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 |
+33 -5
View File
@@ -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 <motivazione>` 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 <id>` 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 <motivazione>` 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
+8 -6
View File
@@ -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 3kill-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 4recupero 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`.
## Problemi aperti
@@ -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);
@@ -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;
/// <summary>
/// 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.
/// </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);
private List<PositionInfo> _haltResidue = [];
private DateTime? _entriesBlockedUntilUtc;
/// <summary>True after a kill-switch that could not flatten the bot's positions.</summary>
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);
}
}
/// <summary>
/// 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
/// <c>Halted-Residuo</c>.
/// </summary>
private async Task<FlattenReport> 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<long> cancelled, List<BrokerPosition> 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<FlattenTarget> 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<long> closed, List<long> 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<long> mustBeGone = [.. targets.Select(static t => t.PositionId)];
foreach (BasketSlot slot in _slots)
{
mustBeGone.UnionWith(slot.LegPositionIds());
}
List<long> 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;
}
/// <summary>Closes every basket, every pending leg and every orphan of ours; foreign positions are left alone. No flatness check: the kill-switch adds it.</summary>
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);
}
}
/// <summary>Closes the residue of a kill-switch (one position, or all) and re-verifies.</summary>
private async Task<CommandResult> CloseResidueAsync(string argument, CancellationToken ct)
{
if (_haltResidue.Count == 0)
{
return new CommandResult(false, "nessun residuo da chiudere");
}
List<PositionInfo> 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<FlattenTarget> 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<long> closed, List<long> 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<long> 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"));
}
/// <summary>
/// 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.
/// </summary>
private async Task<CommandResult> ResetAsync(string reason, CancellationToken ct)
{
List<string> 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<long> 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 };
}
/// <summary>Lifts the warm-up block once its time has passed.</summary>
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");
}
}
}
}
@@ -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();
}
/// <summary>Closes every basket and every orphan of ours; foreign positions are left alone.</summary>
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);
}
}
}
@@ -81,6 +81,7 @@ public sealed partial class BasketEngine
UnreconciledReason = _unreconciledReason,
Halted = _killSwitched || _equityStopped,
HaltReason = _haltReason,
HaltResidue = _haltResidue,
EquityStopped = _equityStopped,
KillSwitched = _killSwitched,
EntriesBlockedReason = _entriesBlocked,
@@ -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())
@@ -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.
@@ -99,7 +99,7 @@ public static class HeadlessRunner
return 5;
}
Log.Info($"bot avviato in {mode}. Comandi: status, close <basket>, kill, preset <nome>, reset <motivazione>, bonifica, stop");
Log.Info($"bot avviato in {mode}. Comandi: status, close <basket>, kill, residuo [id], preset <nome>, reset <motivazione>, 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<PositionInfo> { 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 <basket>, kill, preset <nome>, reset <motivazione>, bonifica, stop");
Console.WriteLine("comandi: status, close <basket>, kill, residuo [id], preset <nome>, reset <motivazione>, bonifica, stop");
continue;
}
@@ -214,6 +214,11 @@ public sealed record BotSnapshot
public string? HaltReason { get; init; }
/// <summary>After a kill-switch that could not flatten: the bot's positions still on the account.</summary>
public IReadOnlyList<PositionInfo> HaltResidue { get; init; } = [];
public bool HaltedWithResidue => Halted && HaltResidue.Count > 0;
public bool EquityStopped { get; init; }
public bool KillSwitched { get; init; }
+10 -2
View File
@@ -6,15 +6,23 @@ public enum EngineCommandKind
/// <summary>Close one basket at market. <c>Argument</c> = basket name.</summary>
Close = 0,
/// <summary>Close everything now, block new entries until reset.</summary>
/// <summary>Close everything now, block new entries until reset. <c>Argument</c> = <c>esterne</c> to close the foreign positions too.</summary>
KillSwitch,
/// <summary>Change the style preset at runtime. <c>Argument</c> = Conservative | Moderate | Aggressive.</summary>
SetPreset,
/// <summary>Lift the equity stop or the kill-switch. <c>Reason</c> is written to the ledger and must not be empty.</summary>
/// <summary>
/// The guided reset (§9 of the 5.0 plan): state, STOP file, written reason (at least
/// ten characters, in <c>Reason</c>), 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.
/// </summary>
ResetEquityStop,
/// <summary>Closes what a kill-switch left on the account: <c>Argument</c> = a position id, or <c>all</c>.</summary>
CloseResidue,
/// <summary>
/// The one-off clean-up of orphan legs (§5.5 of the 5.0 plan). <c>Argument</c>:
/// <c>list</c> returns the orphans and the foreign positions as <see cref="PositionInfo"/>
+28 -2
View File
@@ -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.",
@@ -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}";
@@ -0,0 +1,210 @@
using System.Globalization;
using Encelado.Core.Broker;
namespace Encelado.Core.Baskets;
/// <summary>A position the kill-switch must close, with what it is for the report.</summary>
public sealed record FlattenTarget(long PositionId, long InstrumentId, string Symbol, bool IsBuy, double Units, string Basket, string Kind);
/// <summary>What the kill-switch did and what is left.</summary>
public sealed record FlattenReport(
IReadOnlyList<long> CancelledOrders,
IReadOnlyList<long> OrdersFilledMeanwhile,
IReadOnlyList<long> Closed,
IReadOnlyList<long> Residue,
double RealizedPnlUsd,
bool Flat,
string Summary)
{
/// <summary>The engine's state after the procedure: <c>Halted</c> when flat, <c>Halted-Residuo</c> otherwise.</summary>
public string State => Flat ? "Halted" : "Halted-Residuo";
}
/// <summary>
/// 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:
/// <list type="number">
/// <item><see cref="CancelPendingAsync"/>: 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.</item>
/// <item><see cref="CloseTargetsAsync"/>: every target is closed through the caller's closer
/// (three attempts and a check on the position list live in the executor).</item>
/// <item><see cref="VerifyFlatAsync"/>: 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 <c>Halted-Residuo</c>.</item>
/// </list>
/// </summary>
public sealed class FlattenProcedure(IBroker broker, OrderTracker tracker, Action<string> log)
{
private readonly IBroker _broker = broker ?? throw new ArgumentNullException(nameof(broker));
private readonly OrderTracker _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker));
private readonly Action<string> _log = log ?? (static _ => { });
/// <summary>How long to wait for a cancelled order to resolve.</summary>
public TimeSpan CancelWait { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>How long the position list may still show a target before it is a residue.</summary>
public TimeSpan FlatnessTimeout { get; init; } = TimeSpan.FromSeconds(120);
/// <summary>How often the position list is re-read while waiting.</summary>
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Step 1. Returns the orders cancelled and the positions of the orders that filled anyway.</summary>
public async Task<(List<long> Cancelled, List<BrokerPosition> FilledMeanwhile)> CancelPendingAsync(CancellationToken ct)
{
List<long> cancelled = [];
List<BrokerPosition> filled = [];
IReadOnlyList<TrackedOrder> 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);
}
/// <summary>Step 2. Closes every target through <paramref name="closer"/>; returns the ids closed, the ids that refused, and the realised result.</summary>
public static async Task<(List<long> Closed, List<long> Failed, double Realized)> CloseTargetsAsync(
IReadOnlyList<FlattenTarget> targets, Func<FlattenTarget, CancellationToken, Task<CloseOutcome>> closer, Action<string> log, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(targets);
ArgumentNullException.ThrowIfNull(closer);
ArgumentNullException.ThrowIfNull(log);
List<long> closed = [];
List<long> 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);
}
/// <summary>Step 3. Re-reads the positions until none of <paramref name="mustBeGone"/> is there, or the timeout passes. Returns what is left.</summary>
public async Task<List<long>> VerifyFlatAsync(IReadOnlyCollection<long> mustBeGone, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(mustBeGone);
DateTime deadline = DateTime.UtcNow + FlatnessTimeout;
List<long> residue = [.. mustBeGone];
while (true)
{
try
{
IReadOnlyList<BrokerPosition> positions = await _broker.GetPositionsAsync(ct).ConfigureAwait(false);
HashSet<long> 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);
}
}
/// <summary>The whole procedure over a set of targets: cancel, close, verify.</summary>
public async Task<FlattenReport> RunAsync(IReadOnlyList<FlattenTarget> targets, Func<FlattenTarget, CancellationToken, Task<CloseOutcome>> closer, Func<long, string>? symbolOf, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(targets);
ArgumentNullException.ThrowIfNull(closer);
(List<long> cancelled, List<BrokerPosition> filledMeanwhile) = await CancelPendingAsync(ct).ConfigureAwait(false);
List<FlattenTarget> 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<long> closed, List<long> failed, double realized) = await CloseTargetsAsync(all, closer, _log, ct).ConfigureAwait(false);
List<long> 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);
}
/// <summary>The reset's first gate: a written reason of at least ten characters.</summary>
public static bool IsValidResetReason(string? reason) => (reason ?? string.Empty).Trim().Length >= 10;
}
@@ -0,0 +1,44 @@
namespace Encelado.Core.Notifications;
/// <summary>What kind of message a notification is, so a channel can route or throttle it.</summary>
public enum NotificationKind
{
/// <summary>Something happened: a basket opened or closed, an order resolved, the bot started.</summary>
Event = 0,
/// <summary>Something needs a person: kill-switch, equity stop, residues, persistent errors.</summary>
Alert,
/// <summary>The hourly status.</summary>
Hourly,
/// <summary>The daily summary.</summary>
Daily,
}
/// <summary>
/// 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 <see cref="Status"/>.
/// </summary>
public interface INotifier
{
/// <summary>One line for the dashboard: last delivery, queue length, last error.</summary>
string Status { get; }
/// <summary>Queues a message. Returns at once; delivery is the channel's business.</summary>
void Notify(NotificationKind kind, string title, string text);
}
/// <summary>No channel configured: everything is dropped, the status says so.</summary>
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.
}
}
@@ -0,0 +1,100 @@
using Encelado.Core.Baskets;
using Encelado.Core.Broker;
namespace Encelado.Tests;
/// <summary>(v) Two baskets, one orphan, one stranger: five legs closed, the stranger left, flatness verified. (w), (x): the reset's gates.</summary>
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<FlattenTarget> 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<FlattenTarget> 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);
}
/// <summary>(x) A leg that refuses to close: the state is Halted-Residuo and the residue names it.</summary>
[Fact]
public async Task ALegThatWillNotCloseLeavesAResidueAndTheHaltedResidueState()
{
SlowVenue venue = VenueWithFiveOfOursAndAStranger(out List<FlattenTarget> 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));
}
/// <summary>(w) A reset without a written reason is refused.</summary>
[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));
}