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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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_<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; }
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user