5.0 Fase 5: notifiche e comandi Telegram

Il bot riferisce al telefono: stato ogni ora, riepilogo giornaliero ed eventi
(avvio e arresto, basket aperti e chiusi, gambe in attesa, ordini risolti,
orfane, kill-switch, equity stop, perdita giornaliera, margin guard, recupero,
reset, preset, API in errore, scarto orologio) e accetta comandi dalla sola
chat autorizzata (/stato, /posizioni, /storico, /pausa, /riprendi, /chiudi,
/kill CONFERMO, /reset). Il canale è solo HttpClient: coda non bloccante, un
messaggio al secondo, retry con backoff e retry_after, long polling da un solo
task; il token vive nell'ambiente. Ogni comando eseguito, da qualunque origine,
è una riga «comando» nel ledger. Test (s)-(u).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:23:18 +02:00
co-authored by Claude Fable 5.1
parent c5f2b7d852
commit 9f040ade76
24 changed files with 1205 additions and 16 deletions
+8
View File
@@ -2,6 +2,14 @@
Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto. Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto.
## 2026-09-23 — 5.0, Fase 5: notifiche e comandi Telegram
- `TelegramNotifier` nel Core (solo `HttpClient`): coda non bloccante, un messaggio al secondo, tre tentativi con backoff e `retry_after`, spezzatura a 4096 caratteri, long polling `getUpdates` da un solo task, comandi accettati solo dalla chat autorizzata.
- Sezione `notifications.telegram` in `encelado.json` e in Impostazioni → Notifiche; token da `TELEGRAM_BOT_TOKEN`, chat da `TELEGRAM_CHAT_ID`.
- Stato ogni ora, riepilogo giornaliero, eventi (avvio/arresto, basket aperto/chiuso, gamba in attesa, ordine risolto, unwind, orfana, kill-switch, equity stop, perdita giornaliera, margin guard, recupero, reset, preset, API in errore, scarto orologio).
- Comandi `/stato`, `/posizioni`, `/storico Nd`, `/pausa`, `/riprendi`, `/chiudi`, `/kill CONFERMO`, `/reset`; comandi `Pause`, `Resume`, `History` nel motore; ogni comando eseguito (finestra, console, Telegram) scrive una riga `comando` nel ledger.
- Test (s), (t), (u) e altri: 219 verdi.
## 2026-09-23 — 5.0, Fase 4: recupero dopo inattività, heartbeat, lock di istanza ## 2026-09-23 — 5.0, Fase 4: recupero dopo inattività, heartbeat, lock di istanza
- `data/state/heartbeat.json` ogni 30 s (all'avvio e all'arresto con nota); all'avvio e a ogni ciclo l'inattività oltre `recovery.thresholdMinutes` (10) fa partire il recupero. - `data/state/heartbeat.json` ogni 30 s (all'avvio e all'arresto con nota); all'avvio e a ogni ciclo l'inattività oltre `recovery.thresholdMinutes` (10) fa partire il recupero.
+12
View File
@@ -48,5 +48,17 @@
"_righe": "statusLines = righe della striscia di attività nella dashboard; bufferedLines = righe tenute in memoria dalla pagina Log (il file su disco resta completo).", "_righe": "statusLines = righe della striscia di attività nella dashboard; bufferedLines = righe tenute in memoria dalla pagina Log (il file su disco resta completo).",
"statusLines": 200, "statusLines": 200,
"bufferedLines": 5000 "bufferedLines": 5000
},
"notifications": {
"_telegram": "Notifiche e comandi su Telegram (5.0). Il token del bot NON si scrive qui: variabile d'ambiente TELEGRAM_BOT_TOKEN (e TELEGRAM_CHAT_ID, che ha la precedenza su chatId). hourlyStatus = stato allo scoccare di ogni ora UTC; eventAlerts = aperture, chiusure, ordini risolti, kill-switch, recupero, errori; dailySummaryUtcHour = ora UTC del riepilogo giornaliero (negativo = spento); commands = risposte ai comandi /stato, /posizioni, /storico, /pausa, /riprendi, /chiudi, /kill CONFERMO, /reset dalla sola chat autorizzata.",
"telegram": {
"enabled": false,
"chatId": "",
"hourlyStatus": true,
"eventAlerts": true,
"dailySummaryUtcHour": 21,
"commands": true
}
} }
} }
+4 -2
View File
@@ -79,7 +79,7 @@ src/Encelado.Core/Baskets/ matematica e logica pura, senza I/O:
BasketPosition (macchina a stati, con PendingA/PendingB), PendingEntry (ingresso in sospeso), BasketStrategyConfig (strategy.json, preset), ExecutionMode (Paper | Demo | Live), 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), OrderTracker (registro persistente degli ordini, risoluzione per orderId / riferimento / posizioni — ADR-0009), PositionClassifier (basket | orfana-bot | esterna),
EquityTracker (picco al netto dei movimenti di cassa), FlattenProcedure (kill-switch in tre passi: annulla, chiudi, verifica la piattezza), Heartbeat/HeartbeatFile, InstanceLock, RecoveryPlanner (barre trascorse, chiudi/tieni, rapporto) EquityTracker (picco al netto dei movimenti di cassa), FlattenProcedure (kill-switch in tre passi: annulla, chiudi, verifica la piattezza), Heartbeat/HeartbeatFile, InstanceLock, RecoveryPlanner (barre trascorse, chiudi/tieni, rapporto)
src/Encelado.Core/Notifications/ INotifier, NullNotifier (dalla Fase 5 TelegramNotifier) src/Encelado.Core/Notifications/ INotifier, NullNotifier, TelegramNotifier (coda Channel<T>, un messaggio al secondo, retry con backoff, long polling dei comandi dalla sola chat autorizzata; solo HttpClient)
src/Encelado.Core/Baskets/History/ OrderRecord (riga di orders.jsonl); dalla Fase 7 PositionRecord, PeriodStats, HistoryBuilder 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/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), src/Encelado.Core/Baskets/Learning/ livelli 0-3: CalibrationTables, OnlineLogistic (SGD+L2, standardizzazione rolling), SmallMlp (16 ReLU, Adam, early stopping, gradient check),
@@ -92,6 +92,7 @@ src/Encelado.Bot/Baskets/ BasketEngine in file parziali: BasketEngine.cs (
ripresa degli ingressi in sospeso), .Reconcile.cs (conto, classificazione delle posizioni, adozione delle orfane, movimenti di cassa, margin guard, equity stop), .Kill.cs (file STOP, kill-switch con verifica di piattezza e stato Halted-Residuo, chiusura dei residui, reset in cinque passi), .Recovery.cs (lock di istanza, heartbeat, inattività, procedura di recupero), ripresa degli ingressi in sospeso), .Reconcile.cs (conto, classificazione delle posizioni, adozione delle orfane, movimenti di cassa, margin guard, equity stop), .Kill.cs (file STOP, kill-switch con verifica di piattezza e stato Halted-Residuo, chiusura dei residui, reset in cinque passi), .Recovery.cs (lock di istanza, heartbeat, inattività, procedura di recupero),
.State.cs (baskets_state.json), .Commands.cs (comandi, bonifica), .Snapshot.cs (snapshot per finestra e console), .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), 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),
TelegramReports (testi dallo snapshot: stato orario, posizioni, periodo), TelegramCommands (i comandi della chat passano dal supervisore), Notifications.Create (Telegram o niente),
LearningState (modello in ombra, bandit, ciclo settimanale, knowledge/), HeadlessRunner (--headless) LearningState (modello in ombra, bandit, ciclo settimanale, knowledge/), HeadlessRunner (--headless)
src/Encelado.Bot/Configuration/ BotConfig (etoro, run, ui, logging), ConfigLoader (JsonDocument, avvisi sulle sezioni di versioni precedenti), ConfigDefaults, ConfigWriter, EtoroKeyStore (DPAPI) src/Encelado.Bot/Configuration/ BotConfig (etoro, run, ui, logging), ConfigLoader (JsonDocument, avvisi sulle sezioni di versioni precedenti), ConfigDefaults, ConfigWriter, EtoroKeyStore (DPAPI)
src/Encelado.Bot/Engine/ IEngine, BotSupervisor (ciclo di vita, snapshot, feed di attività), BotSnapshot src/Encelado.Bot/Engine/ IEngine, BotSupervisor (ciclo di vita, snapshot, feed di attività), BotSnapshot
@@ -147,7 +148,8 @@ Stati del motore (non del basket): `Halted` dopo un kill-switch o un equity stop
- `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`. - `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). - `IContextProvider` (Bot): calendario, notizie e sentiment per basket (`FeedContextProvider`; `EmptyContextProvider` nei test).
- `IModel`: `Predict(features)`, `Update(features, label)`, JSON, implementato da `OnlineLogistic` e `SmallMlp`. - `IModel`: `Predict(features)`, `Update(features, label)`, JSON, implementato da `OnlineLogistic` e `SmallMlp`.
- `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()`. - `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`, `History`, `Pause`, `Resume`, `Snapshot()`. Ogni comando eseguito è una riga `comando` nel ledger.
- `INotifier` (Core): `Notify(kind, title, text)` non bloccante e `Status`; il motore lo riceve dal supervisore (`BotSupervisor(config, factory, notifier)`), che a sua volta lo riceve dalla finestra o dall'headless (`Notifications.Create`).
- `IUiActions` (Bot): ciò che le pagine possono chiedere alla finestra (chiudi basket, kill-switch, preset, reset, chiavi, file). - `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.5 Vincoli e limiti scoperti in Fase 0
+9
View File
@@ -63,6 +63,15 @@ Sentiment senza librerie (`Encelado.Core/News/SentimentLexicon.cs`, `SentimentEn
Copie dei feed usate dai test: `tests/fixtures/` (scaricate il 2026-09-16). Copie dei feed usate dai test: `tests/fixtures/` (scaricate il 2026-09-16).
## 3.1 Telegram Bot API (dalla 5.0)
| Rotta | Uso | Note |
|---|---|---|
| `POST https://api.telegram.org/bot<token>/sendMessage` | notifiche e risposte, corpo `{chat_id, text, parse_mode: "HTML", disable_web_page_preview: true}` | limite 4096 caratteri per messaggio (il bot spezza sulle righe); un invio al secondo; su 429 il corpo porta `parameters.retry_after`; un 4xx (token, chat o markup sbagliati) non viene ritentato |
| `GET https://api.telegram.org/bot<token>/getUpdates?offset=<n>&timeout=25&allowed_updates=["message"]` | comandi in ingresso, long polling da un solo task | solo i messaggi con `chat.id` uguale a `TELEGRAM_CHAT_ID` vengono eseguiti; gli altri sono ignorati e annotati nel log; `offset` avanza all'ultimo `update_id` + 1 |
Il token non è mai in un file del repository: `TELEGRAM_BOT_TOKEN` nell'ambiente (o, a scelta dell'operatore, `botToken` in `encelado.local.json`).
## 4. Schema dei file in `Documenti\Encelado` ## 4. Schema dei file in `Documenti\Encelado`
``` ```
+1 -1
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`) | | `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` | | `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` | | `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` |
| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; dalla 5.0 anche le righe di evento (senza feature, solo `ts`, `run_id`, `evento`, `basket_id`, campi propri e `motivazione`): `pending` (gamba senza esito, con `leg`, `client_ref`, `order_id`), `pending_risolto` (con `esito`, `fonte`, `position_id`), `orfana_adottata` e `orfana_chiusa` (con `position_id`, `strumento`, `pnl`, `exit_reason`), `movimento_di_cassa` (con `importo`, `saldo_prima`, `saldo_dopo`, `chiusure_nel_frattempo`, `cassa_cumulata`), `margin_guard` (con `equity`, `margine_usato`), `kill_switch_avviato`, `kill_switch_concluso` (con `stato` = `Halted` o `Halted-Residuo`, `chiuse`, `residuo`, `annullati`, `pnl`), `residuo_chiuso`, `reset_rifiutato`, `reset_concluso`, `recupero_avviato` (con `inattivita_min`, `barre`), `recupero_concluso` (con `inattivita_min`, `barre`, `chiusi`, `tenuti`) | | `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; dalla 5.0 anche le righe di evento (senza feature, solo `ts`, `run_id`, `evento`, `basket_id`, campi propri e `motivazione`): `pending` (gamba senza esito, con `leg`, `client_ref`, `order_id`), `pending_risolto` (con `esito`, `fonte`, `position_id`), `orfana_adottata` e `orfana_chiusa` (con `position_id`, `strumento`, `pnl`, `exit_reason`), `movimento_di_cassa` (con `importo`, `saldo_prima`, `saldo_dopo`, `chiusure_nel_frattempo`, `cassa_cumulata`), `margin_guard` (con `equity`, `margine_usato`), `kill_switch_avviato`, `kill_switch_concluso` (con `stato` = `Halted` o `Halted-Residuo`, `chiuse`, `residuo`, `annullati`, `pnl`), `residuo_chiuso`, `reset_rifiutato`, `reset_concluso`, `recupero_avviato` (con `inattivita_min`, `barre`), `recupero_concluso` (con `inattivita_min`, `barre`, `chiusi`, `tenuti`), `comando` (ogni comando dalla finestra, dalla console o da Telegram: `comando`, `argomento`, `ok`; la motivazione porta l'origine e la prima riga dell'esito) |
| `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` | | `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` |
| `buy_cross` | bool | verso deciso (compra il cross = compra entrambe le gambe nei cinque basket) | | `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) | | `z`, `z_in_eff` | numero | z-score del cross e soglia effettiva (scalata dalla vol prevista) |
+12 -2
View File
@@ -1,6 +1,6 @@
# Runbook # Runbook
Aggiornato: 2026-09-23 (5.0, Fase 4). Come si avvia, si ferma, si sblocca e si ripara il bot. I file dell'operatore stanno in `Documenti\Encelado\`; le chiavi in `%LOCALAPPDATA%\Encelado\etoro.dat`. Aggiornato: 2026-09-23 (5.0, Fase 5). 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 ## Prima volta
@@ -9,7 +9,7 @@ Aggiornato: 2026-09-23 (5.0, Fase 4). Come si avvia, si ferma, si sblocca e si r
3. Controlla in **Impostazioni**: ambiente `demo`, modalità `Demo`, fuso orario. 3. Controlla in **Impostazioni**: ambiente `demo`, modalità `Demo`, fuso orario.
4. Premi **AVVIA**. 4. Premi **AVVIA**.
In alternativa alle chiavi salvate: variabili d'ambiente `ETORO_API_KEY` e `ETORO_USER_KEY` (hanno la precedenza), utili su un VPS. In alternativa alle chiavi salvate: variabili d'ambiente `ETORO_API_KEY` e `ETORO_USER_KEY` (hanno la precedenza), utili su un VPS. Per Telegram: `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` (vedi la sezione Telegram).
## Modalità ## Modalità
@@ -90,6 +90,16 @@ Il bot scrive `data/state/heartbeat.json` ogni 30 s (`recovery.heartbeatSeconds`
**Una sola istanza** per cartella dati è imposta dal file `data/state/instance.lock`, tenuto aperto in esclusiva dal motore: un secondo avvio sulla stessa cartella si ferma con «un'altra istanza di Encelado sta usando questa cartella dati» e il pid della prima. Un crash rilascia il lock con il processo: non c'è mai un lock stantio da cancellare a mano. **Una sola istanza** per cartella dati è imposta dal file `data/state/instance.lock`, tenuto aperto in esclusiva dal motore: un secondo avvio sulla stessa cartella si ferma con «un'altra istanza di Encelado sta usando questa cartella dati» e il pid della prima. Un crash rilascia il lock con il processo: non c'è mai un lock stantio da cancellare a mano.
## Telegram
Configurazione in `encelado.json``notifications.telegram` (`enabled`, `chatId`, `hourlyStatus`, `eventAlerts`, `dailySummaryUtcHour`, `commands`) e in Impostazioni → Notifiche. Il token del bot va **solo** nella variabile d'ambiente `TELEGRAM_BOT_TOKEN` (la chat in `TELEGRAM_CHAT_ID`, che ha la precedenza sul file). Per creare il bot: `@BotFather``/newbot` → token; per il chat id: scrivi al bot e leggi `chat.id` da `https://api.telegram.org/bot<token>/getUpdates`, oppure `@userinfobot`. Cosa arriva:
- **stato ogni ora** (allo scoccare dell'ora UTC): attivo/fermo/bloccato, modalità, preset, uptime, equity/saldo/disponibile/margine, P&L di oggi e aperto (conto e basket), drawdown dal picco, basket aperti con z/pip/P&L/durata, in attesa, ordini pendenti, orfane, esterne, blocchi, prossimo evento, stato API e quote, stato del canale;
- **eventi**: avvio e arresto del bot, basket aperto e chiuso (con motivo e P&L), gamba in attesa, ordine pendente risolto, unwind, gamba orfana, kill-switch (con esito e residui), equity stop, perdita giornaliera, margin guard, recupero dopo inattività, reset, cambio preset, API in errore persistente, scarto orologio;
- **riepilogo giornaliero** all'ora impostata (21 UTC): basket chiusi, vinti, lordo, costi, netto, drawdown, motivi di uscita.
Comandi accettati **solo dalla chat autorizzata** (ogni altro mittente viene ignorato e annotato nel log; ogni comando eseguito finisce nel ledger come `comando`): `/stato`, `/posizioni`, `/storico 7d` (anche `1d`, `30d`), `/pausa` (blocca le entrate, le uscite restano attive), `/riprendi`, `/chiudi <basket>`, `/kill CONFERMO` (senza la parola non fa niente), `/reset <motivazione>` (almeno dieci caratteri); tutto il resto risponde «comando non riconosciuto». Il polling usa un solo task con `getUpdates` e non tocca le quote di eToro; gli invii sono al massimo uno al secondo con tre tentativi (2, 5, 15 s; su 429 rispetta `retry_after`). La riga «quote» della dashboard e la riga di stato del log riportano lo stato del canale (ultimo invio, coda, falliti, ultimo errore).
## Bonifica delle gambe orfane ## Bonifica delle gambe orfane
Una tantum, dopo un'anomalia: avvia il bot con `--headless --bonifica`. Il motore parte **senza** chiudere le orfane da solo, le elenca con P&L e motivo della classificazione insieme alle posizioni esterne, e per ogni orfana chiede `chiudere? [s/N]`. Ogni chiusura confermata scrive una riga in `baskets.csv` (`exit_reason = bonifica_orfana`, P&L dallo storico) e in `reports/bonifica_YYYYMMDD.csv`. Alla fine il bot torna a chiudere le orfane da solo. Lo stesso comando si lancia dalla console con `bonifica`. Al 2026-09-23 il conto demo è piatto: non c'è niente da bonificare. Una tantum, dopo un'anomalia: avvia il bot con `--headless --bonifica`. Il motore parte **senza** chiudere le orfane da solo, le elenca con P&L e motivo della classificazione insieme alle posizioni esterne, e per ogni orfana chiede `chiudere? [s/N]`. Ogni chiusura confermata scrive una riga in `baskets.csv` (`exit_reason = bonifica_orfana`, P&L dallo storico) e in `reports/bonifica_YYYYMMDD.csv`. Alla fine il bot torna a chiudere le orfane da solo. Lo stesso comando si lancia dalla console con `bonifica`. Al 2026-09-23 il conto demo è piatto: non c'è niente da bonificare.
+6 -5
View File
@@ -1,14 +1,15 @@
# Stato del lavoro # Stato del lavoro
Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-4 concluse). Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-5 concluse).
## Fase in corso ## Fase in corso
**Piano 5.0, Fase 5** (`docs/PIANO_5.0.md`). Le Fasi 0-4 sono committate: post-mortem, registro degli ordini, stati `PendingA`/`PendingB`, classificazione e chiusura delle orfane, picco al netto dei movimenti di cassa, bonifica, limiti di margine, kill-switch con verifica di piattezza e reset in cinque passi. La «correttezza dell'esecuzione» richiesta dalla regola finale del piano è coperta: il Demo **può ripartire** per le 24 ore di verifica (contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti, `sizing_bound = margin` sui primi ingressi), e la prova manuale del kill-switch con verifica di piattezza va fatta in quella sessione. **Piano 5.0, in attesa delle risposte D-28 e D-29 per le Fasi 6-9** (`docs/PIANO_5.0.md`). Le Fasi 0-5 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) ## 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 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 5**: `TelegramNotifier`, stato orario, eventi, riepilogo giornaliero, comandi dalla chat autorizzata, riga `comando` nel ledger; test (s)-(u); 219 verdi.
- **Fase 4**: heartbeat, lock di istanza, sezione `recovery`, procedura di recupero dopo inattività con rapporto `recupero_<run_id>.csv`; test (r); 211 verdi. - **Fase 4**: heartbeat, lock di istanza, sezione `recovery`, procedura di recupero dopo inattività con rapporto `recupero_<run_id>.csv`; test (r); 211 verdi.
- **Fase 3**: `FlattenProcedure` (annulla, chiudi, verifica), kill-switch che chiude basket + gambe in attesa + orfane (esterne opzionali) e dichiara `Halted-Residuo` quando il conto non è piatto, comando `residuo`, reset in cinque passi rifiutato con residui, `INotifier`; test (v)-(x); 204 verdi. - **Fase 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 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.
@@ -16,9 +17,9 @@ Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-4 concluse).
## Prossimi passi ## Prossimi passi
1. **Fase 5 — Telegram** (§7): `TelegramNotifier`, stato orario, eventi, riepilogo giornaliero, comandi in ingresso. Test (s)-(u). 1. **Rispondere a D-28 (ritiro WPF → web UI) e D-29 (registry, icona)**: senza risposta le Fasi 6-9 non partono (`docs/QUESTIONS.md`).
2. **Dopo la risposta a D-28/D-29**: Fasi 6-9 (Engine/Server, web UI M3, Docker, Unraid, skill, 5.0.0). 2. Riaccendere il Demo per 24 ore di verifica: contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti, `sizing_bound = margin` sui primi ingressi; provare il kill-switch a mano con la verifica di piattezza; fermare il bot un'ora con un basket aperto per vedere il recupero; ricevere i messaggi Telegram reali (serve `TELEGRAM_BOT_TOKEN`).
3. Riaccendere il Demo per 24 ore di verifica (vedi sopra), provare il kill-switch a mano con la verifica di piattezza e fermare il bot un'ora con un basket aperto per vedere il recupero. 3. Fasi 6-9 (Engine/Server, web UI M3 con Storico/valuta/navigazione/Ricerca, Docker, Unraid, skill, ADR-0006/0007/0008, 5.0.0).
4. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`. 4. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`.
## Problemi aperti ## Problemi aperti
@@ -4,10 +4,11 @@ using Encelado.Bot.Engine;
using Encelado.Bot.Logging; using Encelado.Bot.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Broker; using Encelado.Core.Broker;
using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Bot.Baskets;
/// <summary>Commands from the window or the console, executed on the engine's own thread.</summary> /// <summary>Commands from the window, the console or Telegram, executed on the engine's own thread and written to the ledger.</summary>
public sealed partial class BasketEngine public sealed partial class BasketEngine
{ {
public Task<CommandResult> ExecuteAsync(EngineCommand command, CancellationToken ct) public Task<CommandResult> ExecuteAsync(EngineCommand command, CancellationToken ct)
@@ -33,6 +34,13 @@ public sealed partial class BasketEngine
result = new CommandResult(false, ex.Message); result = new CommandResult(false, ex.Message);
} }
// Every command from the window, the console or Telegram is in the ledger.
_ledger.Note(_runId, "comando", string.Empty, $"{item.Command.Kind} {item.Command.Argument} ({item.Command.Reason}) → {(result.Ok ? "ok" : "NO")}: {FirstLine(result.Message)}", w =>
{
w.WriteString("comando", item.Command.Kind.ToString());
w.WriteString("argomento", item.Command.Argument);
w.WriteBoolean("ok", result.Ok);
});
item.Done.TrySetResult(result); item.Done.TrySetResult(result);
} }
} }
@@ -75,8 +83,38 @@ public sealed partial class BasketEngine
_decider.SetPreset(preset); _decider.SetPreset(preset);
Log.Info($"preset cambiato in {preset.ToString().ToUpperInvariant()} ({c.Reason}); i basket aperti non vengono toccati"); Log.Info($"preset cambiato in {preset.ToString().ToUpperInvariant()} ({c.Reason}); i basket aperti non vengono toccati");
_ledger.Correction(_runId, string.Empty, $"preset → {preset} ({c.Reason})"); _ledger.Correction(_runId, string.Empty, $"preset → {preset} ({c.Reason})");
_notifier.Notify(NotificationKind.Event, "Preset cambiato", $"{preset.ToString().ToUpperInvariant()} ({TelegramEscape(c.Reason)}); i basket aperti non vengono toccati");
return new CommandResult(true, $"preset {preset.ToString().ToUpperInvariant()} attivo"); return new CommandResult(true, $"preset {preset.ToString().ToUpperInvariant()} attivo");
case EngineCommandKind.Pause:
_pausedByOperator = true;
_entriesBlocked ??= "pausa dell'operatore";
Log.Warn($"PAUSA ({c.Reason}): nessuna nuova entrata fino a /riprendi o al riavvio; le uscite restano attive");
return new CommandResult(true, "in pausa: nessuna nuova entrata, uscite attive");
case EngineCommandKind.Resume:
if (!_pausedByOperator)
{
return new CommandResult(false, "non era in pausa");
}
_pausedByOperator = false;
if (_entriesBlocked == "pausa dell'operatore")
{
_entriesBlocked = null;
}
Log.Info($"ripresa ({c.Reason}): entrate riabilitate{(_entriesBlocked is not null ? " salvo " + _entriesBlocked : string.Empty)}");
return new CommandResult(true, _entriesBlocked is null ? "entrate riabilitate" : $"pausa tolta, ma le entrate restano bloccate: {_entriesBlocked}");
case EngineCommandKind.History:
{
int days = c.Argument.Trim().TrimEnd('d', 'D', 'g', 'G') is { Length: > 0 } n && int.TryParse(n, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) ? Math.Clamp(parsed, 1, 365) : 7;
DateTime since = DateTime.UtcNow.AddDays(-days);
List<BasketOutcomeRow> rows = _ledger.ReadBaskets().Where(r => r.ClosedUtc >= since).ToList();
return new CommandResult(true, TelegramReports.Period($"ultimi {days} giorni", rows, _equity.Drawdown(_account.Equity))) { Payload = rows };
}
case EngineCommandKind.ResetEquityStop: case EngineCommandKind.ResetEquityStop:
return await ResetAsync(c.Reason, ct).ConfigureAwait(false); return await ResetAsync(c.Reason, ct).ConfigureAwait(false);
@@ -91,6 +129,12 @@ public sealed partial class BasketEngine
} }
} }
private static string FirstLine(string s)
{
int i = s.IndexOf('\n', StringComparison.Ordinal);
return i < 0 ? s : s[..i];
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Bonifica (§5.5 of the 5.0 plan) // Bonifica (§5.5 of the 5.0 plan)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -2,6 +2,7 @@ using System.Globalization;
using Encelado.Bot.Logging; using Encelado.Bot.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Broker; using Encelado.Core.Broker;
using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Bot.Baskets;
@@ -79,6 +80,7 @@ public sealed partial class BasketEngine
private async Task OnOrderResolvedAsync(TrackedOrder order, OrderOutcome outcome, CancellationToken ct) private async Task OnOrderResolvedAsync(TrackedOrder order, OrderOutcome outcome, CancellationToken ct)
{ {
Log.Info($"registro ordini: {order.Describe()} — risolto ({outcome.Source})"); Log.Info($"registro ordini: {order.Describe()} — risolto ({outcome.Source})");
_notifier.Notify(NotificationKind.Event, "Ordine pendente risolto", TelegramEscape($"{order.Describe()} ({outcome.Source})"));
_ledger.Note(_runId, "pending_risolto", order.BasketId, order.Describe(), w => _ledger.Note(_runId, "pending_risolto", order.BasketId, order.Describe(), w =>
{ {
w.WriteString("basket", order.Basket); w.WriteString("basket", order.Basket);
@@ -3,6 +3,7 @@ using System.Text;
using Encelado.Bot.Logging; using Encelado.Bot.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Broker; using Encelado.Core.Broker;
using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Bot.Baskets;
@@ -229,6 +230,7 @@ public sealed partial class BasketEngine
{ {
Log.Warn(string.Create(CultureInfo.InvariantCulture, Log.Warn(string.Create(CultureInfo.InvariantCulture,
$"GAMBA ORFANA del bot: posizione {bp.PositionId} su {SymbolOf(bp.InstrumentId)} ({(bp.IsBuy ? "long" : "short")} {bp.Units:0.##} @ {bp.OpenRate}, P&L {bp.UnrealizedPnl:+0.00;-0.00}) — {c.Reason}; {(OrphanPolicy == OrphanPolicy.Close ? "la adotto e la chiudo" : "in attesa della bonifica")}")); $"GAMBA ORFANA del bot: posizione {bp.PositionId} su {SymbolOf(bp.InstrumentId)} ({(bp.IsBuy ? "long" : "short")} {bp.Units:0.##} @ {bp.OpenRate}, P&L {bp.UnrealizedPnl:+0.00;-0.00}) — {c.Reason}; {(OrphanPolicy == OrphanPolicy.Close ? "la adotto e la chiudo" : "in attesa della bonifica")}"));
_notifier.Notify(NotificationKind.Alert, "Gamba orfana del bot", TelegramEscape(string.Create(CultureInfo.InvariantCulture, $"posizione {bp.PositionId} {SymbolOf(bp.InstrumentId)} {(bp.IsBuy ? "long" : "short")} {bp.Units:0.##}, P&L {bp.UnrealizedPnl:+0.00;-0.00}: {c.Reason}; {(OrphanPolicy == OrphanPolicy.Close ? "la chiudo" : "in attesa della bonifica")}")));
_ledger.Note(_runId, "orfana_adottata", string.Empty, c.Reason, w => _ledger.Note(_runId, "orfana_adottata", string.Empty, c.Reason, w =>
{ {
w.WriteString("basket", c.Basket); w.WriteString("basket", c.Basket);
@@ -308,6 +310,7 @@ public sealed partial class BasketEngine
{ {
string why = string.Create(CultureInfo.InvariantCulture, $"equity / margine usato {ratio:0.00} sotto {_strategy.Risk.MarginCallCloseRatio:0.00}: chiudo {worst.Name} (P&L {worst.LastEvaluation.PnlOpenUsd:+0.00;-0.00})"); string why = string.Create(CultureInfo.InvariantCulture, $"equity / margine usato {ratio:0.00} sotto {_strategy.Risk.MarginCallCloseRatio:0.00}: chiudo {worst.Name} (P&L {worst.LastEvaluation.PnlOpenUsd:+0.00;-0.00})");
Log.Error($"MARGIN GUARD: {why}", null); Log.Error($"MARGIN GUARD: {why}", null);
_notifier.Notify(NotificationKind.Alert, "Margin guard", TelegramEscape(why));
_ledger.Note(_runId, "margin_guard", worst.PositionBasketId, why, w => _ledger.Note(_runId, "margin_guard", worst.PositionBasketId, why, w =>
{ {
w.WriteString("basket", worst.Name); w.WriteString("basket", worst.Name);
@@ -378,6 +381,7 @@ public sealed partial class BasketEngine
{ {
_entriesBlocked = $"gamba orfana {bp.PositionId} non chiudibile dopo tre tentativi: chiudila a mano su eToro"; _entriesBlocked = $"gamba orfana {bp.PositionId} non chiudibile dopo tre tentativi: chiudila a mano su eToro";
Log.Error(_entriesBlocked, null); Log.Error(_entriesBlocked, null);
_notifier.Notify(NotificationKind.Alert, "Orfana non chiudibile", TelegramEscape(_entriesBlocked));
} }
return false; return false;
@@ -462,6 +466,7 @@ public sealed partial class BasketEngine
_equityStopped = true; _equityStopped = true;
_haltReason = string.Create(CultureInfo.InvariantCulture, $"equity {_account.Equity:F2} a {dd:P2} dal picco {_equity.PeakEquity:F2} (soglia {_strategy.EquityStopPct:0.#} %)"); _haltReason = string.Create(CultureInfo.InvariantCulture, $"equity {_account.Equity:F2} a {dd:P2} dal picco {_equity.PeakEquity:F2} (soglia {_strategy.EquityStopPct:0.#} %)");
Log.Error($"EQUITY STOP: {_haltReason}. Chiudo tutto e mi blocco: serve un reset manuale con motivazione.", null); Log.Error($"EQUITY STOP: {_haltReason}. Chiudo tutto e mi blocco: serve un reset manuale con motivazione.", null);
_notifier.Notify(NotificationKind.Alert, "EQUITY STOP", TelegramEscape($"{_haltReason}. Chiudo tutto e mi blocco: serve un reset con motivazione."));
_ledger.Correction(_runId, string.Empty, $"equity stop: {_haltReason}"); _ledger.Correction(_runId, string.Empty, $"equity stop: {_haltReason}");
await CloseAllAsync("equity stop", ct).ConfigureAwait(false); await CloseAllAsync("equity stop", ct).ConfigureAwait(false);
SaveState(); SaveState();
@@ -85,7 +85,7 @@ public sealed partial class BasketEngine
EquityStopped = _equityStopped, EquityStopped = _equityStopped,
KillSwitched = _killSwitched, KillSwitched = _killSwitched,
EntriesBlockedReason = _entriesBlocked, EntriesBlockedReason = _entriesBlocked,
Counters = string.Create(CultureInfo.InvariantCulture, $"quote/min {_feed.QuotaUsed(EtoroQuota.MarketData)}/110 · ordini/min {_feed.QuotaUsed(EtoroQuota.Trading)}/18 · esiti/min {_feed.QuotaUsed(EtoroQuota.Lookup)}/55 · ultima quotazione {(_lastQuoteUtc == default ? "" : (now - _lastQuoteUtc).TotalSeconds.ToString("0") + " s fa")}"), Counters = string.Create(CultureInfo.InvariantCulture, $"quote/min {_feed.QuotaUsed(EtoroQuota.MarketData)}/110 · ordini/min {_feed.QuotaUsed(EtoroQuota.Trading)}/18 · esiti/min {_feed.QuotaUsed(EtoroQuota.Lookup)}/55 · ultima quotazione {(_lastQuoteUtc == default ? "" : (now - _lastQuoteUtc).TotalSeconds.ToString("0") + " s fa")} · {_notifier.Status}"),
Events = events, Events = events,
Baskets = rows, Baskets = rows,
Quotes = quotes, Quotes = quotes,
@@ -106,6 +106,11 @@ public sealed partial class BasketEngine : IEngine
private DateTime _lastQuoteUtc; private DateTime _lastQuoteUtc;
private int _consecutiveApiErrors; private int _consecutiveApiErrors;
private string _username = string.Empty; private string _username = string.Empty;
private DateTime _startedUtc;
private long _lastHourlyBucket = -1;
private DateOnly _lastDailySummary;
private DateOnly _dailyLossNotified;
private bool _pausedByOperator;
/// <summary>Everything the engine knows about one basket.</summary> /// <summary>Everything the engine knows about one basket.</summary>
private sealed class BasketSlot private sealed class BasketSlot
@@ -277,6 +282,7 @@ public sealed partial class BasketEngine : IEngine
public async Task RunAsync(CancellationToken ct) public async Task RunAsync(CancellationToken ct)
{ {
Log.Info($"motore basket: {_mode} su {_broker.Name}, preset {PresetLabel}, strategia {_configHash}, run {_runId}"); Log.Info($"motore basket: {_mode} su {_broker.Name}, preset {PresetLabel}, strategia {_configHash}, run {_runId}");
_startedUtc = DateTime.UtcNow;
AcquireInstanceLock(); AcquireInstanceLock();
CheckInactivityAtStartup(); CheckInactivityAtStartup();
@@ -317,6 +323,8 @@ public sealed partial class BasketEngine : IEngine
} }
Log.Info($"in ascolto: {_slots.Count(static s => s.Enabled)} basket attivi su {_slots.Count}, {_series.Count} strumenti, polling ogni {_config.Run.PollSeconds} s"); Log.Info($"in ascolto: {_slots.Count(static s => s.Enabled)} basket attivi su {_slots.Count}, {_series.Count} strumenti, polling ogni {_config.Run.PollSeconds} s");
_notifier.Notify(NotificationKind.Event, "Bot avviato", string.Create(CultureInfo.InvariantCulture,
$"{_mode} su {TelegramEscape(_broker.Name)}, preset {PresetLabel}, equity {_account.Equity:N2}, {_slots.Count(static s => s.Position is not null)} basket aperti, {_orphanCount} orfane, {_foreignCount} esterne{(_haltReason is not null ? ", BLOCCO: " + TelegramEscape(_haltReason) : string.Empty)}"));
await LoopAsync(ct).ConfigureAwait(false); await LoopAsync(ct).ConfigureAwait(false);
} }
@@ -339,6 +347,7 @@ public sealed partial class BasketEngine : IEngine
{ {
_entriesBlocked = string.Create(CultureInfo.InvariantCulture, $"orologio locale sfasato di {skew:+0.0;-0.0} s rispetto al server (limite {_strategy.ClockSkewMaxSeconds} s)"); _entriesBlocked = string.Create(CultureInfo.InvariantCulture, $"orologio locale sfasato di {skew:+0.0;-0.0} s rispetto al server (limite {_strategy.ClockSkewMaxSeconds} s)");
Log.Warn(_entriesBlocked); Log.Warn(_entriesBlocked);
_notifier.Notify(NotificationKind.Alert, "Scarto orologio", TelegramEscape(_entriesBlocked));
} }
if (File.Exists(_stopFile)) if (File.Exists(_stopFile))
@@ -514,6 +523,7 @@ public sealed partial class BasketEngine : IEngine
await DrainCommandsAsync(ct).ConfigureAwait(false); await DrainCommandsAsync(ct).ConfigureAwait(false);
RollSessionIfNeeded(now); RollSessionIfNeeded(now);
ExpireWarmupBlock(now); ExpireWarmupBlock(now);
NotifyScheduled(now);
// Orders without an outcome come first: their resolution changes what the // Orders without an outcome come first: their resolution changes what the
// reconciliation and the decisions below see. // reconciliation and the decisions below see.
@@ -581,6 +591,41 @@ public sealed partial class BasketEngine : IEngine
{ {
SaveState(); SaveState();
WriteHeartbeat(DateTime.UtcNow, "arresto", force: true); WriteHeartbeat(DateTime.UtcNow, "arresto", force: true);
_notifier.Notify(NotificationKind.Event, "Bot fermato", string.Create(CultureInfo.InvariantCulture, $"{_slots.Count(static s => s.Position is not null)} basket restano aperti sul conto (closeOnShutdown = {(_config.Run.CloseOnShutdown ? "" : "no")})"));
}
}
private static string TelegramEscape(string? s) => Core.Notifications.TelegramNotifier.Escape(s);
/// <summary>The hourly status at the top of every UTC hour and the daily summary at the configured hour.</summary>
private void NotifyScheduled(DateTime now)
{
if (_notifier is NullNotifier)
{
return;
}
long bucket = now.Ticks / TimeSpan.TicksPerHour;
if (_lastHourlyBucket < 0)
{
_lastHourlyBucket = bucket;
}
else if (bucket != _lastHourlyBucket)
{
_lastHourlyBucket = bucket;
_notifier.Notify(NotificationKind.Hourly, string.Create(CultureInfo.InvariantCulture, $"Stato delle {now:HH:mm} UTC"), TelegramReports.Hourly(Snapshot(BotState.Running, null, _startedUtc, []), _notifier.Status));
}
int hour = _config.Notifications.Telegram.DailySummaryUtcHour;
DateOnly today = DateOnly.FromDateTime(now);
if (hour >= 0 && now.Hour >= hour && _lastDailySummary != today)
{
_lastDailySummary = today;
if (now.Hour == hour || _lastDailySummary == default)
{
List<BasketOutcomeRow> rows = _ledger.ReadBaskets().Where(r => DateOnly.FromDateTime(r.ClosedUtc) == today).ToList();
_notifier.Notify(NotificationKind.Daily, string.Create(CultureInfo.InvariantCulture, $"Riepilogo del {today:dd/MM}"), TelegramReports.Period("oggi", rows, _equity.Drawdown(_account.Equity)));
}
} }
} }
@@ -610,6 +655,7 @@ public sealed partial class BasketEngine : IEngine
{ {
_entriesBlocked = "API in errore persistente"; _entriesBlocked = "API in errore persistente";
Log.Warn("cinque letture consecutive fallite: nuove entrate bloccate finché l'API non risponde"); Log.Warn("cinque letture consecutive fallite: nuove entrate bloccate finché l'API non risponde");
_notifier.Notify(NotificationKind.Alert, "API eToro in errore", TelegramEscape($"cinque letture consecutive fallite: {ex.Message}; entrate bloccate, uscite attive"));
} }
return; return;
@@ -915,6 +961,12 @@ public sealed partial class BasketEngine : IEngine
bool sameCrossOpen = _slots.Any(o => o != slot && o.Cross.Symbol == slot.Cross.Symbol && (o.Position is not null || o.State == BasketState.Entering || o.State.IsPending())); bool sameCrossOpen = _slots.Any(o => o != slot && o.Cross.Symbol == slot.Cross.Symbol && (o.Position is not null || o.State == BasketState.Entering || o.State.IsPending()));
double dailyPnl = _todayRealized + _account.UnrealizedPnl; double dailyPnl = _todayRealized + _account.UnrealizedPnl;
bool dailyLossHit = _dayStartEquity > 0 && dailyPnl <= -_dayStartEquity * _strategy.DailyLossPct / 100.0; bool dailyLossHit = _dayStartEquity > 0 && dailyPnl <= -_dayStartEquity * _strategy.DailyLossPct / 100.0;
if (dailyLossHit && _dailyLossNotified != _sessionDate)
{
_dailyLossNotified = _sessionDate;
Log.Warn(string.Create(CultureInfo.InvariantCulture, $"perdita giornaliera raggiunta: {dailyPnl:+0.00;-0.00} USD su {_dayStartEquity:F2} di equity di partenza ({_strategy.DailyLossPct:0.#} %): niente nuove entrate fino a domani"));
_notifier.Notify(NotificationKind.Alert, "Perdita giornaliera raggiunta", string.Create(CultureInfo.InvariantCulture, $"{dailyPnl:+0.00;-0.00} USD oggi su {_dayStartEquity:N2} di equity ({_strategy.DailyLossPct:0.#} %): niente nuove entrate fino alla mezzanotte UTC"));
}
return new BasketContext return new BasketContext
{ {
@@ -1066,6 +1118,7 @@ public sealed partial class BasketEngine : IEngine
slot.Intent = "IN POSIZIONE — " + position.Describe(); slot.Intent = "IN POSIZIONE — " + position.Describe();
string fill = string.Create(CultureInfo.InvariantCulture, $"eseguito: A @ {position.A.EntryPrice} (slippage {outcome.SlippagePipsA:+0.0;-0.0} pip), B @ {position.B.EntryPrice} (slippage {outcome.SlippagePipsB:+0.0;-0.0} pip), latenza {outcome.LatencyMs:F0} ms"); string fill = string.Create(CultureInfo.InvariantCulture, $"eseguito: A @ {position.A.EntryPrice} (slippage {outcome.SlippagePipsA:+0.0;-0.0} pip), B @ {position.B.EntryPrice} (slippage {outcome.SlippagePipsB:+0.0;-0.0} pip), latenza {outcome.LatencyMs:F0} ms");
Log.Info($"[{slot.Name}] APERTO {basketId}: {position.Describe()}; {fill}"); Log.Info($"[{slot.Name}] APERTO {basketId}: {position.Describe()}; {fill}");
_notifier.Notify(NotificationKind.Event, $"Basket aperto: {slot.Name}", TelegramEscape($"{position.Describe()}\n{fill}"));
if (d is not null) if (d is not null)
{ {
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx with { Position = position }, d, "ingresso", basketId, fill); _ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx with { Position = position }, d, "ingresso", basketId, fill);
@@ -1091,6 +1144,7 @@ public sealed partial class BasketEngine : IEngine
slot.Intent = $"IN ATTESA — {outcome.Error}"; slot.Intent = $"IN ATTESA — {outcome.Error}";
Log.Warn($"[{slot.Name}] {slot.Intent}"); Log.Warn($"[{slot.Name}] {slot.Intent}");
_notifier.Notify(NotificationKind.Event, $"Gamba in attesa: {slot.Name}", TelegramEscape(outcome.Error));
_ledger.Note(_runId, "pending", basketId, outcome.Error, w => _ledger.Note(_runId, "pending", basketId, outcome.Error, w =>
{ {
w.WriteString("basket", slot.Name); w.WriteString("basket", slot.Name);
@@ -1107,6 +1161,11 @@ public sealed partial class BasketEngine : IEngine
string evento = outcome.Unwound ? "leg_risk_unwind" : "rifiuto"; string evento = outcome.Unwound ? "leg_risk_unwind" : "rifiuto";
slot.Intent = $"NON APERTO — {outcome.Error}"; slot.Intent = $"NON APERTO — {outcome.Error}";
Log.Warn($"[{slot.Name}] {slot.Intent}"); Log.Warn($"[{slot.Name}] {slot.Intent}");
if (outcome.Unwound || outcome.Error.Contains("NON richiusa", StringComparison.Ordinal))
{
_notifier.Notify(NotificationKind.Alert, $"Unwind: {slot.Name}", TelegramEscape(outcome.Error));
}
if (d is not null) if (d is not null)
{ {
_ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, evento, basketId, outcome.Error); _ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, evento, basketId, outcome.Error);
@@ -1223,6 +1282,8 @@ public sealed partial class BasketEngine : IEngine
Log.Info(string.Create(CultureInfo.InvariantCulture, Log.Info(string.Create(CultureInfo.InvariantCulture,
$"[{slot.Name}] CHIUSO {slot.PositionBasketId}: netto {x.RealizedPnlUsd:+0.00;-0.00} USD, {x.PipsTotal:+0.0;-0.0} pip, {p.BarsHeld} barre, motivo {reasonCode}")); $"[{slot.Name}] CHIUSO {slot.PositionBasketId}: netto {x.RealizedPnlUsd:+0.00;-0.00} USD, {x.PipsTotal:+0.0;-0.0} pip, {p.BarsHeld} barre, motivo {reasonCode}"));
_notifier.Notify(NotificationKind.Event, $"Basket chiuso: {slot.Name}", string.Create(CultureInfo.InvariantCulture,
$"netto <b>{x.RealizedPnlUsd:+0.00;-0.00}</b> USD, {x.PipsTotal:+0.0;-0.0} pip, {p.BarsHeld} barre, motivo {reasonCode}\n{TelegramEscape(reason)}"));
if (d is not null) if (d is not null)
{ {
@@ -4,6 +4,7 @@ using Encelado.Bot.Configuration;
using Encelado.Bot.Engine; using Encelado.Bot.Engine;
using Encelado.Bot.Logging; using Encelado.Bot.Logging;
using Encelado.Core.Baskets; using Encelado.Core.Baskets;
using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets; namespace Encelado.Bot.Baskets;
@@ -83,7 +84,15 @@ public static class HeadlessRunner
} }
bool bonifica = args.Any(static a => a.Equals("--bonifica", StringComparison.OrdinalIgnoreCase)); bool bonifica = args.Any(static a => a.Equals("--bonifica", StringComparison.OrdinalIgnoreCase));
await using BotSupervisor supervisor = new(config, (c, confirmed) => new BasketEngine(c, confirmed) { OrphanPolicy = bonifica ? OrphanPolicy.Report : OrphanPolicy.Close }) { StartConfirmed = true }; INotifier notifier = Notifications.Create(config);
await using BotSupervisor supervisor = new(config, (c, confirmed) => new BasketEngine(c, confirmed, null, notifier) { OrphanPolicy = bonifica ? OrphanPolicy.Report : OrphanPolicy.Close }, notifier) { StartConfirmed = true };
if (notifier is TelegramNotifier telegram)
{
TelegramCommands commands = new(supervisor, () => notifier.Status);
telegram.CommandHandler = commands.HandleAsync;
telegram.StartCommands();
}
using CancellationTokenSource stopping = new(); using CancellationTokenSource stopping = new();
Console.CancelKeyPress += (_, e) => Console.CancelKeyPress += (_, e) =>
{ {
@@ -138,6 +147,11 @@ public static class HeadlessRunner
} }
await supervisor.StopAsync().ConfigureAwait(false); await supervisor.StopAsync().ConfigureAwait(false);
if (notifier is IAsyncDisposable disposable)
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
await Log.FlushAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); await Log.FlushAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
return 0; return 0;
} }
@@ -0,0 +1,102 @@
using System.Globalization;
using Encelado.Bot.Engine;
using Encelado.Bot.Logging;
using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets;
/// <summary>
/// The commands accepted from the authorised Telegram chat (§7 of the 5.0 plan). Every
/// one goes through the supervisor like a command from the window or the console, so
/// the engine logs it in the ledger like any other; the phone never talks to the venue.
/// </summary>
public sealed class TelegramCommands(BotSupervisor supervisor, Func<string> notifierStatus)
{
/// <summary>The phrase that arms <c>/kill</c>, the same as the console's confirmation habit.</summary>
public const string KillPhrase = "CONFERMO";
public async Task<string> HandleAsync(string text, CancellationToken ct)
{
string[] parts = (text ?? string.Empty).Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
return TelegramReports.Help;
}
string command = parts[0].ToLowerInvariant();
int at = command.IndexOf('@', StringComparison.Ordinal);
if (at > 0)
{
command = command[..at]; // "/stato@NomeBot" in a group
}
string arg = parts.Length > 1 ? parts[1] : string.Empty;
Log.Info($"Telegram: comando {command}{(arg.Length > 0 ? " " + arg : string.Empty)}");
switch (command)
{
case "/stato":
case "/status":
return TelegramReports.Hourly(supervisor.Snapshot(), notifierStatus());
case "/posizioni":
return TelegramReports.Positions(supervisor.Snapshot());
case "/storico":
return Text(await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.History, arg.Length > 0 ? arg : "7d", "telegram"), ct).ConfigureAwait(false));
case "/pausa":
return Text(await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Pause, string.Empty, "pausa da Telegram"), ct).ConfigureAwait(false));
case "/riprendi":
return Text(await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Resume, string.Empty, "ripresa da Telegram"), ct).ConfigureAwait(false));
case "/chiudi":
if (arg.Length == 0)
{
return "indica il basket: /chiudi EURUSD/USDCHF";
}
return Text(await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.Close, arg, "chiusura da Telegram"), ct).ConfigureAwait(false));
case "/kill":
if (!arg.Equals(KillPhrase, StringComparison.Ordinal))
{
return $"il kill-switch chiude tutte le posizioni del bot e blocca le entrate: scrivi <b>/kill {KillPhrase}</b> per confermare";
}
return Text(await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.KillSwitch, string.Empty, "kill-switch da Telegram"), ct).ConfigureAwait(false));
case "/reset":
if (arg.Trim().Length < 10)
{
return "serve una motivazione di almeno dieci caratteri: /reset verificato il conto, riparto";
}
return Text(await supervisor.ExecuteAsync(new EngineCommand(EngineCommandKind.ResetEquityStop, string.Empty, arg), ct).ConfigureAwait(false));
case "/aiuto":
case "/help":
case "/start":
return TelegramReports.Help;
default:
return "comando non riconosciuto. " + TelegramReports.Help;
}
}
private static string Text(CommandResult r) => (r.Ok ? "✅ " : "❌ ") + TelegramNotifier.Escape(r.Message);
}
/// <summary>Builds the notifier the configuration asks for: Telegram when usable, nothing otherwise.</summary>
public static class Notifications
{
public static INotifier Create(Configuration.BotConfig config)
{
ArgumentNullException.ThrowIfNull(config);
TelegramOptions t = config.Notifications.Telegram;
if (!t.IsUsable)
{
if (t.Enabled)
{
Log.Warn("Telegram abilitato ma senza token o chat id (TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID): notifiche disattivate");
}
return NullNotifier.Instance;
}
Log.Info(string.Create(CultureInfo.InvariantCulture, $"Telegram attivo: chat {t.ChatId}, stato orario {(t.HourlyStatus ? "" : "no")}, eventi {(t.EventAlerts ? "" : "no")}, riepilogo alle {t.DailySummaryUtcHour:00}:00 UTC, comandi {(t.Commands ? "" : "no")}"));
return new TelegramNotifier(t, null, static m => Log.Warn(m));
}
}
@@ -0,0 +1,130 @@
using System.Globalization;
using System.Text;
using Encelado.Bot.Engine;
using Encelado.Core.Notifications;
namespace Encelado.Bot.Baskets;
/// <summary>
/// The texts Telegram receives (§7 of the 5.0 plan), built from a snapshot so the same
/// numbers the window shows go to the phone. Pure functions: a test feeds a fixed
/// snapshot and reads the string. HTML for Telegram, every value escaped.
/// </summary>
public static class TelegramReports
{
private static readonly CultureInfo It = CultureInfo.InvariantCulture;
/// <summary>The hourly status (also the answer to <c>/stato</c>).</summary>
public static string Hourly(BotSnapshot s, string notifierStatus)
{
ArgumentNullException.ThrowIfNull(s);
StringBuilder sb = new();
string state = s.HaltedWithResidue ? "BLOCCATO CON RESIDUO" : s.Halted ? "BLOCCATO" : s.State == BotState.Running ? "attivo" : "fermo";
sb.Append(CultureInfo.InvariantCulture, $"<b>{E(state)}</b> · {E(s.Mode)} · {E(s.ExecutionMode)} · preset {E(s.Preset)} · attivo da {E(Uptime(s.Uptime))}\n");
sb.Append(CultureInfo.InvariantCulture, $"Equity <b>{s.Equity:N2}</b> · saldo {s.Balance:N2} · disponibile {s.AvailableBalance:N2} · margine usato {s.UsedMargin:N2}\n");
sb.Append(CultureInfo.InvariantCulture, $"P&amp;L oggi <b>{s.TodayPnl:+0.00;-0.00;0.00}</b> ({s.TodayPnlPct:P2}) · aperto (conto) {s.AccountOpenPnl:+0.00;-0.00;0.00} · di cui basket {s.OpenPnl:+0.00;-0.00;0.00}\n");
sb.Append(CultureInfo.InvariantCulture, $"Drawdown {s.DrawdownPct:P2} dal picco {s.PeakEquity:N2} (stop a {s.EquityStopPct:P0})\n");
sb.Append(CultureInfo.InvariantCulture, $"Basket aperti {s.OpenBaskets}/{s.MaxBaskets} · in attesa {s.PendingBaskets} · ordini pendenti {s.PendingOrders} · orfane <b>{s.OrphanLegs}</b> · esterne {s.ForeignPositions}\n");
foreach (BasketRow b in s.Baskets.Where(static b => b.IsOpen))
{
sb.Append(CultureInfo.InvariantCulture, $" • {E(b.Name)} ({E(b.Cross)}) z {b.Z:+0.00;-0.00} · {b.Pips:+0.0;-0.0} pip · {b.PnlUsd:+0.00;-0.00} USD · {b.BarsHeld} barre · TP {b.TpPips:0}\n");
}
if (s.HaltReason is { Length: > 0 } halt)
{
sb.Append(CultureInfo.InvariantCulture, $"Blocco: {E(halt)}\n");
}
if (s.EntriesBlockedReason is { Length: > 0 } blocked)
{
sb.Append(CultureInfo.InvariantCulture, $"Entrate bloccate: {E(blocked)}\n");
}
if (s.Unreconciled)
{
sb.Append(CultureInfo.InvariantCulture, $"⚠ Posizioni non riconciliate: {E(s.UnreconciledReason)}\n");
}
CalendarRow? next = s.Context?.NextEvents.FirstOrDefault(static e => e.TimeUtc >= DateTime.UtcNow);
sb.Append(CultureInfo.InvariantCulture, $"Prossimo evento: {(next is null ? "nessuno in vista" : E($"{next.Currency} {next.Title} {next.TimeUtc:dd/MM HH:mm} UTC"))}\n");
sb.Append(CultureInfo.InvariantCulture, $"API {E(s.ApiState)} {(double.IsFinite(s.ApiLatencyMs) ? s.ApiLatencyMs.ToString("0", It) + " ms" : "")} · {E(s.Counters)}\n");
if (!string.IsNullOrEmpty(s.Error))
{
sb.Append(CultureInfo.InvariantCulture, $"Ultimo errore: {E(s.Error)}\n");
}
sb.Append(E(notifierStatus));
return sb.ToString();
}
/// <summary>The answer to <c>/posizioni</c>.</summary>
public static string Positions(BotSnapshot s)
{
ArgumentNullException.ThrowIfNull(s);
StringBuilder sb = new();
int open = 0;
foreach (BasketRow b in s.Baskets)
{
if (b.IsOpen)
{
open++;
sb.Append(CultureInfo.InvariantCulture, $"• <b>{E(b.Name)}</b> {E(b.StateLabel)} · {b.OpenLegs} gambe · z {b.Z:+0.00;-0.00} (entrata {b.EntryZ:+0.00;-0.00}) · {b.Pips:+0.0;-0.0} pip · <b>{b.PnlUsd:+0.00;-0.00}</b> USD · {b.BarsHeld} barre\n");
}
else if (b.State is "PendingA" or "PendingB")
{
sb.Append(CultureInfo.InvariantCulture, $"• {E(b.Name)} {E(b.StateLabel)}\n");
}
}
if (open == 0)
{
sb.Append("Nessun basket aperto.\n");
}
sb.Append(CultureInfo.InvariantCulture, $"Orfane {s.OrphanLegs} · esterne {s.ForeignPositions} · ordini pendenti {s.PendingOrders}\n");
if (s.HaltResidue.Count > 0)
{
sb.Append("Residui del kill-switch:\n");
foreach (PositionInfo r in s.HaltResidue)
{
sb.Append(CultureInfo.InvariantCulture, $" {r.PositionId} {E(r.Symbol)} {(r.IsBuy ? "long" : "short")} {r.Units:0.##} ({E(r.Origin)})\n");
}
}
return sb.ToString();
}
/// <summary>The daily summary and the answer to <c>/storico</c>: the closed baskets of a period.</summary>
public static string Period(string label, IReadOnlyList<BasketOutcomeRow> rows, double drawdownPct)
{
ArgumentNullException.ThrowIfNull(rows);
int n = rows.Count;
int won = rows.Count(static r => r.PnlNetUsd > 0);
double gross = rows.Sum(static r => double.IsFinite(r.PnlGrossUsd) ? r.PnlGrossUsd : r.PnlNetUsd);
double net = rows.Sum(static r => r.PnlNetUsd);
double cost = rows.Sum(static r => double.IsFinite(r.CostUsd) ? r.CostUsd : 0);
StringBuilder sb = new();
sb.Append(CultureInfo.InvariantCulture, $"<b>{E(label)}</b>: {n} basket chiusi");
if (n > 0)
{
sb.Append(CultureInfo.InvariantCulture, $", {won} vinti ({(double)won / n:P0}), lordo {gross:+0.00;-0.00} USD, costi {cost:0.00}, netto <b>{net:+0.00;-0.00}</b> USD, media {net / n:+0.00;-0.00} per basket");
}
sb.Append(CultureInfo.InvariantCulture, $"; drawdown attuale {drawdownPct:P2}");
Dictionary<string, int> reasons = rows.GroupBy(static r => r.ExitReason).ToDictionary(static g => g.Key, static g => g.Count());
if (reasons.Count > 0)
{
sb.Append("\nUscite: " + E(string.Join(", ", reasons.OrderByDescending(static k => k.Value).Select(static k => $"{k.Key} {k.Value}"))));
}
return sb.ToString();
}
/// <summary>The list of commands, for <c>/aiuto</c> and for anything unrecognised.</summary>
public const string Help = "Comandi: /stato, /posizioni, /storico 7d, /pausa, /riprendi, /chiudi &lt;basket&gt;, /kill CONFERMO, /reset &lt;motivazione&gt;";
private static string E(string? s) => TelegramNotifier.Escape(s);
private static string Uptime(TimeSpan t) =>
t.TotalHours >= 1 ? $"{(int)t.TotalHours}h {t.Minutes}m" : t.TotalMinutes >= 1 ? $"{t.Minutes}m" : "meno di un minuto";
}
@@ -22,16 +22,39 @@ public sealed class BotConfig
public LoggingOptions Logging { get; set; } = new(); public LoggingOptions Logging { get; set; } = new();
/// <summary>Where the bot tells the outside world what it did (Telegram).</summary>
public NotificationsOptions Notifications { get; set; } = new();
public BotConfig Validate() public BotConfig Validate()
{ {
Etoro.Validate(); Etoro.Validate();
Run.Validate(); Run.Validate();
Ui.Validate(); Ui.Validate();
Logging.Validate(); Logging.Validate();
Notifications.Validate();
return this; return this;
} }
} }
/// <summary>The notification channels (§7 of the 5.0 plan). Token and chat id come from the environment first.</summary>
public sealed class NotificationsOptions
{
public Core.Notifications.TelegramOptions Telegram { get; set; } = new();
public void Validate()
{
if (Telegram.DailySummaryUtcHour > 23)
{
throw new InvalidOperationException("notifications.telegram.dailySummaryUtcHour deve essere fra 0 e 23 (negativo = disattivato).");
}
if (!Uri.TryCreate(Telegram.BaseUrl, UriKind.Absolute, out Uri? uri) || uri.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException("notifications.telegram.baseUrl deve essere un URL https assoluto.");
}
}
}
/// <summary> /// <summary>
/// Execution mode and folders. Relative folders resolve against the configuration's own /// Execution mode and folders. Relative folders resolve against the configuration's own
/// directory (<c>Documenti\Encelado</c>), so data, knowledge and reports sit next to the /// directory (<c>Documenti\Encelado</c>), so data, knowledge and reports sit next to the
@@ -65,6 +65,7 @@ public static class ConfigDefaults
config.Run = factory.Run; config.Run = factory.Run;
config.Ui = factory.Ui; config.Ui = factory.Ui;
config.Logging = factory.Logging; config.Logging = factory.Logging;
config.Notifications = factory.Notifications;
// Keys are never part of a default: they belong to the operator, not to the // Keys are never part of a default: they belong to the operator, not to the
// shipped configuration, and clearing them here would log the user out every // shipped configuration, and clearing them here would log the user out every
@@ -149,6 +150,18 @@ public static class ConfigDefaults
"_righe": "statusLines = righe della striscia di attività nella dashboard; bufferedLines = righe tenute in memoria dalla pagina Log (il file su disco resta completo).", "_righe": "statusLines = righe della striscia di attività nella dashboard; bufferedLines = righe tenute in memoria dalla pagina Log (il file su disco resta completo).",
"statusLines": 200, "statusLines": 200,
"bufferedLines": 5000 "bufferedLines": 5000
},
"notifications": {
"_telegram": "Notifiche e comandi su Telegram (5.0). Il token del bot NON si scrive qui: variabile d'ambiente TELEGRAM_BOT_TOKEN (e TELEGRAM_CHAT_ID, che ha la precedenza su chatId). hourlyStatus = stato allo scoccare di ogni ora UTC; eventAlerts = aperture, chiusure, ordini risolti, kill-switch, recupero, errori; dailySummaryUtcHour = ora UTC del riepilogo giornaliero (negativo = spento); commands = risposte ai comandi /stato, /posizioni, /storico, /pausa, /riprendi, /chiudi, /kill CONFERMO, /reset dalla sola chat autorizzata.",
"telegram": {
"enabled": false,
"chatId": "",
"hourlyStatus": true,
"eventAlerts": true,
"dailySummaryUtcHour": 21,
"commands": true
}
} }
} }
"""; """;
@@ -101,6 +101,9 @@ public static class ConfigLoader
case "logging": case "logging":
ReadLogging(config, section.Value, warnings); ReadLogging(config, section.Value, warnings);
break; break;
case "notifications":
ReadNotifications(config, section.Value, warnings);
break;
case "$schema": case "$schema":
break; break;
default: default:
@@ -212,8 +215,52 @@ public static class ConfigLoader
} }
} }
private static void ReadNotifications(BotConfig config, JsonElement e, List<string> warnings)
{
foreach (JsonProperty section in Properties(e, "notifications", warnings))
{
if (!section.Name.Equals("telegram", StringComparison.OrdinalIgnoreCase))
{
warnings.Add($"chiave sconosciuta 'notifications.{section.Name}'");
continue;
}
Core.Notifications.TelegramOptions t = config.Notifications.Telegram;
foreach (JsonProperty p in Properties(section.Value, "notifications.telegram", warnings))
{
switch (p.Name.ToLowerInvariant())
{
case "enabled": t.Enabled = Bool(p); break;
case "chatid": t.ChatId = Str(p).Trim(); break;
case "hourlystatus": t.HourlyStatus = Bool(p); break;
case "eventalerts": t.EventAlerts = Bool(p); break;
case "dailysummaryutchour": t.DailySummaryUtcHour = Int(p); break;
case "commands": t.Commands = Bool(p); break;
case "baseurl": t.BaseUrl = Str(p); break;
// Accepted for a *.local.json overlay or the operator's own file, never written by the app.
case "bottoken": t.BotToken = Str(p).Trim(); break;
default: warnings.Add($"chiave sconosciuta 'notifications.telegram.{p.Name}'"); break;
}
}
}
}
private static void ApplyEnvironment(BotConfig config) private static void ApplyEnvironment(BotConfig config)
{ {
// Telegram: the token never lives in a committed file; the chat id may.
string? telegramToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN");
if (!string.IsNullOrWhiteSpace(telegramToken))
{
config.Notifications.Telegram.BotToken = telegramToken.Trim();
}
string? telegramChat = Environment.GetEnvironmentVariable("TELEGRAM_CHAT_ID");
if (!string.IsNullOrWhiteSpace(telegramChat))
{
config.Notifications.Telegram.ChatId = telegramChat.Trim();
}
// eToro keys for automation (a VPS service): never written anywhere by the app. // eToro keys for automation (a VPS service): never written anywhere by the app.
string? apiKey = Environment.GetEnvironmentVariable("ETORO_API_KEY"); string? apiKey = Environment.GetEnvironmentVariable("ETORO_API_KEY");
string? userKey = Environment.GetEnvironmentVariable("ETORO_USER_KEY"); string? userKey = Environment.GetEnvironmentVariable("ETORO_USER_KEY");
@@ -14,12 +14,12 @@ namespace Encelado.Bot.Engine;
/// session does; a new instance is simpler and cannot leak stale state. /// session does; a new instance is simpler and cannot leak stale state.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class BotSupervisor(BotConfig config, Func<BotConfig, bool, IEngine>? factory = null) : IAsyncDisposable public sealed class BotSupervisor(BotConfig config, Func<BotConfig, bool, IEngine>? factory = null, Core.Notifications.INotifier? notifier = null) : IAsyncDisposable
{ {
private readonly int _eventCapacity = Math.Max(20, config.Logging.StatusLines); private readonly int _eventCapacity = Math.Max(20, config.Logging.StatusLines);
private readonly Lock _gate = new(); private readonly Lock _gate = new();
private readonly Queue<EventRow> _events = new(); private readonly Queue<EventRow> _events = new();
private readonly Func<BotConfig, bool, IEngine> _factory = factory ?? (static (c, confirmed) => new BasketEngine(c, confirmed)); private readonly Func<BotConfig, bool, IEngine> _factory = factory ?? ((c, confirmed) => new BasketEngine(c, confirmed, null, notifier));
private IEngine? _engine; private IEngine? _engine;
private CancellationTokenSource? _engineCts; private CancellationTokenSource? _engineCts;
@@ -30,6 +30,9 @@ public sealed class BotSupervisor(BotConfig config, Func<BotConfig, bool, IEngin
public BotConfig Config => config; public BotConfig Config => config;
/// <summary>The channel the engines notify on; the host owns its lifetime.</summary>
public Core.Notifications.INotifier Notifier { get; } = notifier ?? Core.Notifications.NullNotifier.Instance;
/// <summary>Set by the shell once the operator has confirmed the live mode at start.</summary> /// <summary>Set by the shell once the operator has confirmed the live mode at start.</summary>
public bool StartConfirmed { get; set; } public bool StartConfirmed { get; set; }
@@ -23,6 +23,14 @@ public enum EngineCommandKind
/// <summary>Closes what a kill-switch left on the account: <c>Argument</c> = a position id, or <c>all</c>.</summary> /// <summary>Closes what a kill-switch left on the account: <c>Argument</c> = a position id, or <c>all</c>.</summary>
CloseResidue, CloseResidue,
/// <summary>The closed baskets of a period (<c>Argument</c> = <c>1d</c>, <c>7d</c>, <c>30d</c>): counts and P&amp;L in the message.</summary>
History,
/// <summary>Blocks new entries until <see cref="Resume"/>; exits keep running.</summary>
Pause,
Resume,
/// <summary> /// <summary>
/// The one-off clean-up of orphan legs (§5.5 of the 5.0 plan). <c>Argument</c>: /// 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"/> /// <c>list</c> returns the orphans and the foreign positions as <see cref="PositionInfo"/>
+14 -1
View File
@@ -28,6 +28,7 @@ public partial class MainWindow : Window, IUiActions
private readonly BotConfig _config = App.Config; private readonly BotConfig _config = App.Config;
private readonly MainViewModel _vm; private readonly MainViewModel _vm;
private readonly BotSupervisor _supervisor; private readonly BotSupervisor _supervisor;
private readonly Core.Notifications.INotifier _notifier;
private readonly DispatcherTimer _timer; private readonly DispatcherTimer _timer;
private readonly DashboardPage _dashboard = new(); private readonly DashboardPage _dashboard = new();
@@ -49,7 +50,15 @@ public partial class MainWindow : Window, IUiActions
Log = new LogViewModel(_config.Logging.BufferedLines), Log = new LogViewModel(_config.Logging.BufferedLines),
}; };
_supervisor = new BotSupervisor(_config); _notifier = Baskets.Notifications.Create(_config);
_supervisor = new BotSupervisor(_config, notifier: _notifier);
if (_notifier is Core.Notifications.TelegramNotifier telegram)
{
Baskets.TelegramCommands commands = new(_supervisor, () => _notifier.Status);
telegram.CommandHandler = commands.HandleAsync;
telegram.StartCommands();
}
_supervisor.AttachLogSink(); _supervisor.AttachLogSink();
_supervisor.EventLogged += _vm.Log.Enqueue; _supervisor.EventLogged += _vm.Log.Enqueue;
@@ -685,6 +694,10 @@ public partial class MainWindow : Window, IUiActions
try try
{ {
await _supervisor.DisposeAsync().ConfigureAwait(true); await _supervisor.DisposeAsync().ConfigureAwait(true);
if (_notifier is IAsyncDisposable disposable)
{
await disposable.DisposeAsync().ConfigureAwait(true);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -22,6 +22,7 @@ public static class SettingsCatalogue
Execution(config), Execution(config),
Etoro(config), Etoro(config),
Window(config), Window(config),
Notifications(config),
Logging(config), Logging(config),
]; ];
@@ -190,6 +191,75 @@ public static class SettingsCatalogue
return g; return g;
} }
private static SettingGroup Notifications(BotConfig config)
{
Core.Notifications.TelegramOptions t = config.Notifications.Telegram;
SettingGroup g = new(
"Notifiche",
"Telegram: stato ogni ora, eventi, riepilogo giornaliero e comandi dalla chat autorizzata. Il token del bot va nella variabile " +
"d'ambiente TELEGRAM_BOT_TOKEN (mai in un file); il chat id qui o in TELEGRAM_CHAT_ID.");
g.Fields.Add(new SettingField
{
Path = "notifications.telegram.enabled",
Label = "Telegram attivo",
Initial = t.Enabled ? "sì" : "no",
Kind = SettingKind.Boolean,
Tooltip = "Con 'sì' e un token nell'ambiente il bot manda le notifiche alla chat indicata e ne accetta i comandi.",
});
g.Fields.Add(new SettingField
{
Path = "notifications.telegram.chatId",
Label = "Chat id",
Initial = t.ChatId,
Kind = SettingKind.Text,
AllowEmpty = true,
Tooltip = "L'id numerico della chat (scrivi al bot @userinfobot per conoscere il tuo). Solo questa chat riceve i messaggi e può dare comandi.",
});
g.Fields.Add(new SettingField
{
Path = "notifications.telegram.hourlyStatus",
Label = "Stato ogni ora",
Initial = t.HourlyStatus ? "sì" : "no",
Kind = SettingKind.Boolean,
Tooltip = "Allo scoccare di ogni ora UTC: equity, P&L, basket aperti, orfane, drawdown, prossimo evento, stato API.",
});
g.Fields.Add(new SettingField
{
Path = "notifications.telegram.eventAlerts",
Label = "Eventi",
Initial = t.EventAlerts ? "sì" : "no",
Kind = SettingKind.Boolean,
Tooltip = "Apertura e chiusura dei basket, ordini risolti, kill-switch, equity stop, recupero, errori persistenti.",
});
g.Fields.Add(new SettingField
{
Path = "notifications.telegram.dailySummaryUtcHour",
Label = "Riepilogo giornaliero alle",
Initial = t.DailySummaryUtcHour.ToString(CultureInfo.CurrentCulture),
Kind = SettingKind.Integer,
Suffix = "ora UTC (1 = spento)",
Minimum = -1,
Maximum = 23,
Tooltip = "P&L del giorno, numero di basket, win rate, costi, drawdown.",
});
g.Fields.Add(new SettingField
{
Path = "notifications.telegram.commands",
Label = "Comandi dalla chat",
Initial = t.Commands ? "sì" : "no",
Kind = SettingKind.Boolean,
Tooltip = "/stato, /posizioni, /storico 7d, /pausa, /riprendi, /chiudi <basket>, /kill CONFERMO, /reset <motivazione>. Ogni comando finisce nel ledger.",
});
return g;
}
private static SettingGroup Logging(BotConfig config) private static SettingGroup Logging(BotConfig config)
{ {
SettingGroup g = new( SettingGroup g = new(
@@ -0,0 +1,422 @@
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
namespace Encelado.Core.Notifications;
/// <summary>How to reach the Telegram Bot API for one chat (§7 of the 5.0 plan).</summary>
public sealed class TelegramOptions
{
public bool Enabled { get; set; }
/// <summary>From <c>TELEGRAM_BOT_TOKEN</c>; never in a file of the repository.</summary>
public string BotToken { get; set; } = string.Empty;
/// <summary>The one chat the bot talks to and listens to (<c>TELEGRAM_CHAT_ID</c> or <c>notifications.telegram.chatId</c>).</summary>
public string ChatId { get; set; } = string.Empty;
public bool HourlyStatus { get; set; } = true;
public bool EventAlerts { get; set; } = true;
/// <summary>UTC hour of the daily summary; negative disables it.</summary>
public int DailySummaryUtcHour { get; set; } = 21;
/// <summary>Whether the bot answers commands from the chat (long polling).</summary>
public bool Commands { get; set; } = true;
public string BaseUrl { get; set; } = "https://api.telegram.org";
public bool IsUsable => Enabled && BotToken.Length > 0 && ChatId.Length > 0;
}
/// <summary>
/// The Telegram channel: <c>sendMessage</c> with HTML, one message per second at most,
/// bounded retries with backoff, a queue that never blocks the engine, and — when asked —
/// one long-polling task on <c>getUpdates</c> that accepts commands from the configured
/// chat only. Only <see cref="HttpClient"/>: no library.
/// </summary>
public sealed class TelegramNotifier : INotifier, IAsyncDisposable
{
/// <summary>Telegram's own limit for one message.</summary>
public const int MaxMessageLength = 4096;
private readonly TelegramOptions _options;
private readonly HttpClient _client;
private readonly Action<string> _log;
private readonly Channel<(NotificationKind Kind, string Title, string Text)> _queue = Channel.CreateBounded<(NotificationKind, string, string)>(new BoundedChannelOptions(500) { FullMode = BoundedChannelFullMode.DropOldest });
private readonly CancellationTokenSource _cts = new();
private readonly Task _sender;
private Task? _poller;
private DateTime _lastSentUtc = DateTime.MinValue;
private DateTime? _lastDeliveredUtc;
private string _lastError = string.Empty;
private int _sent;
private int _failed;
private long _offset;
public TelegramNotifier(TelegramOptions options, HttpMessageHandler? handler = null, Action<string>? log = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_log = log ?? (static _ => { });
_client = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: true);
_client.Timeout = TimeSpan.FromSeconds(40);
_sender = Task.Run(SendLoopAsync);
}
/// <summary>Minimum spacing between two deliveries.</summary>
public TimeSpan MinInterval { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>Waits between attempts; three attempts by default.</summary>
public TimeSpan[] RetryDelays { get; init; } = [TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)];
/// <summary>Long-polling wait, in seconds, asked of Telegram.</summary>
public int PollTimeoutSeconds { get; init; } = 25;
/// <summary>Answers a command text from the authorised chat. Set by the host before <see cref="StartCommands"/>.</summary>
public Func<string, CancellationToken, Task<string>>? CommandHandler { get; set; }
public int Queued => _queue.Reader.Count;
public int Sent => _sent;
public int Failed => _failed;
public string Status
{
get
{
if (!_options.IsUsable)
{
return "Telegram non configurato (token o chat mancanti)";
}
string last = _lastDeliveredUtc is { } t ? string.Create(CultureInfo.InvariantCulture, $"ultimo invio {t:HH:mm:ss} UTC") : "nessun invio";
return string.Create(CultureInfo.InvariantCulture, $"Telegram: {last}, coda {Queued}, inviati {_sent}, falliti {_failed}{(_lastError.Length > 0 ? ", ultimo errore: " + _lastError : string.Empty)}");
}
}
public void Notify(NotificationKind kind, string title, string text)
{
if (!_options.IsUsable)
{
return;
}
if (kind == NotificationKind.Event && !_options.EventAlerts)
{
return;
}
if (kind == NotificationKind.Hourly && !_options.HourlyStatus)
{
return;
}
_queue.Writer.TryWrite((kind, title ?? string.Empty, text ?? string.Empty));
}
/// <summary>Starts the single long-polling task, when commands are enabled.</summary>
public void StartCommands()
{
if (_options.IsUsable && _options.Commands && _poller is null)
{
_poller = Task.Run(PollLoopAsync);
}
}
// -----------------------------------------------------------------------
// Sending
// -----------------------------------------------------------------------
private async Task SendLoopAsync()
{
try
{
await foreach ((NotificationKind kind, string title, string text) in _queue.Reader.ReadAllAsync(_cts.Token).ConfigureAwait(false))
{
foreach (string chunk in Chunks(Compose(kind, title, text)))
{
await DeliverAsync(chunk, _cts.Token).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
}
/// <summary>One delivery with throttling and retries. Returns whether Telegram accepted it.</summary>
public async Task<bool> DeliverAsync(string html, CancellationToken ct)
{
for (int attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
TimeSpan sinceLast = DateTime.UtcNow - _lastSentUtc;
if (sinceLast < MinInterval)
{
await Task.Delay(MinInterval - sinceLast, ct).ConfigureAwait(false);
}
_lastSentUtc = DateTime.UtcNow;
TimeSpan? retryAfter = null;
try
{
using StringContent content = new(SendMessageJson(_options.ChatId, html), Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _client.PostAsync($"{_options.BaseUrl.TrimEnd('/')}/bot{_options.BotToken}/sendMessage", content, ct).ConfigureAwait(false);
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
_sent++;
_lastDeliveredUtc = DateTime.UtcNow;
_lastError = string.Empty;
return true;
}
_lastError = $"HTTP {(int)response.StatusCode}: {Describe(body)}";
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
retryAfter = RetryAfter(body);
}
else if ((int)response.StatusCode is >= 400 and < 500)
{
// A bad request will not get better by retrying (wrong token, chat, or markup).
_failed++;
_log($"Telegram: messaggio rifiutato ({_lastError})");
return false;
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
{
_lastError = ex.Message;
}
if (attempt < RetryDelays.Length)
{
await Task.Delay(retryAfter ?? RetryDelays[attempt], ct).ConfigureAwait(false);
}
}
_failed++;
_log($"Telegram: invio fallito dopo {RetryDelays.Length + 1} tentativi ({_lastError})");
return false;
}
/// <summary>The <c>sendMessage</c> body, written by hand: no reflection, so the Core stays trim- and AOT-safe.</summary>
public static string SendMessageJson(string chatId, string html)
{
using MemoryStream ms = new();
using (Utf8JsonWriter w = new(ms))
{
w.WriteStartObject();
w.WriteString("chat_id", chatId);
w.WriteString("text", html);
w.WriteString("parse_mode", "HTML");
w.WriteBoolean("disable_web_page_preview", true);
w.WriteEndObject();
}
return Encoding.UTF8.GetString(ms.ToArray());
}
// -----------------------------------------------------------------------
// Commands
// -----------------------------------------------------------------------
private async Task PollLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
try
{
await PollOnceAsync(_cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
_lastError = ex.Message;
try
{
await Task.Delay(TimeSpan.FromSeconds(10), _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
}
}
/// <summary>One <c>getUpdates</c> round: every message from the authorised chat is answered, everything else is dropped.</summary>
public async Task<int> PollOnceAsync(CancellationToken ct)
{
string url = $"{_options.BaseUrl.TrimEnd('/')}/bot{_options.BotToken}/getUpdates?offset={_offset.ToString(CultureInfo.InvariantCulture)}&timeout={PollTimeoutSeconds}&allowed_updates=%5B%22message%22%5D";
using HttpResponseMessage response = await _client.GetAsync(url, ct).ConfigureAwait(false);
string body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_lastError = $"getUpdates HTTP {(int)response.StatusCode}: {Describe(body)}";
return 0;
}
int handled = 0;
using JsonDocument doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("result", out JsonElement result) || result.ValueKind != JsonValueKind.Array)
{
return 0;
}
foreach (JsonElement update in result.EnumerateArray())
{
if (update.TryGetProperty("update_id", out JsonElement id))
{
_offset = Math.Max(_offset, id.GetInt64() + 1);
}
if (!update.TryGetProperty("message", out JsonElement message) || !message.TryGetProperty("text", out JsonElement textEl))
{
continue;
}
string chat = message.TryGetProperty("chat", out JsonElement c) && c.TryGetProperty("id", out JsonElement cid) ? cid.GetRawText().Trim('"') : string.Empty;
string text = textEl.GetString() ?? string.Empty;
if (!chat.Equals(_options.ChatId, StringComparison.Ordinal))
{
_log($"Telegram: comando ignorato da una chat non autorizzata ({chat}): {Truncate(text)}");
continue;
}
string reply;
try
{
reply = CommandHandler is null ? "comandi non attivi" : await CommandHandler(text, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
reply = $"errore: {Escape(ex.Message)}";
}
handled++;
foreach (string chunk in Chunks(reply))
{
await DeliverAsync(chunk, ct).ConfigureAwait(false);
}
}
return handled;
}
// -----------------------------------------------------------------------
// Text
// -----------------------------------------------------------------------
/// <summary>HTML for Telegram: the title in bold, then the text as given (already escaped by the caller).</summary>
public static string Compose(NotificationKind kind, string title, string text)
{
string prefix = kind switch
{
NotificationKind.Alert => "⚠️ ",
NotificationKind.Hourly => "🕐 ",
NotificationKind.Daily => "📅 ",
_ => string.Empty,
};
return title.Length > 0 ? $"{prefix}<b>{Escape(title)}</b>\n{text}" : text;
}
/// <summary>Splits on line breaks so no message exceeds Telegram's limit.</summary>
public static IEnumerable<string> Chunks(string text)
{
if (text.Length <= MaxMessageLength)
{
yield return text;
yield break;
}
StringBuilder sb = new();
foreach (string line in text.Split('\n'))
{
if (sb.Length + line.Length + 1 > MaxMessageLength)
{
yield return sb.ToString();
sb.Clear();
}
if (line.Length > MaxMessageLength)
{
yield return line[..MaxMessageLength];
continue;
}
if (sb.Length > 0)
{
sb.Append('\n');
}
sb.Append(line);
}
if (sb.Length > 0)
{
yield return sb.ToString();
}
}
public static string Escape(string? text) =>
(text ?? string.Empty).Replace("&", "&amp;", StringComparison.Ordinal).Replace("<", "&lt;", StringComparison.Ordinal).Replace(">", "&gt;", StringComparison.Ordinal);
private static TimeSpan? RetryAfter(string body)
{
try
{
using JsonDocument doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("parameters", out JsonElement p) && p.TryGetProperty("retry_after", out JsonElement r))
{
return TimeSpan.FromSeconds(Math.Clamp(r.GetDouble(), 1, 120));
}
}
catch (JsonException)
{
// Fall back on the default delays.
}
return null;
}
private static string Describe(string body)
{
try
{
using JsonDocument doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("description", out JsonElement d) ? d.GetString() ?? body : Truncate(body);
}
catch (JsonException)
{
return Truncate(body);
}
}
private static string Truncate(string s) => s.Length > 120 ? s[..120] + "…" : s;
public async ValueTask DisposeAsync()
{
_queue.Writer.TryComplete();
await _cts.CancelAsync().ConfigureAwait(false);
try
{
await Task.WhenAll(_sender, _poller ?? Task.CompletedTask).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
{
// Shutting down: nothing to do.
}
_cts.Dispose();
_client.Dispose();
}
}
@@ -0,0 +1,190 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Text;
using Encelado.Bot.Baskets;
using Encelado.Bot.Engine;
using Encelado.Core.Notifications;
namespace Encelado.Tests;
/// <summary>(s) The texts on a fixed snapshot.</summary>
public class TelegramReportTests
{
[Fact]
public void TheHourlyStatusCarriesTheNumbersOfTheDashboard()
{
BotSnapshot s = TestSnapshots.Populated();
string text = TelegramReports.Hourly(s, "Telegram: ultimo invio 09:00:00 UTC, coda 0");
Assert.Contains("<b>attivo</b>", text, StringComparison.Ordinal);
Assert.Contains("109,450.25", text, StringComparison.Ordinal);
Assert.Contains("P&amp;L oggi <b>+120.50</b>", text, StringComparison.Ordinal);
Assert.Contains("aperto (conto) -61.20", text, StringComparison.Ordinal);
Assert.Contains("Basket aperti 1/3", text, StringComparison.Ordinal);
Assert.Contains("orfane <b>1</b>", text, StringComparison.Ordinal);
Assert.Contains("EURUSD/USDCHF (EURCHF) z +2.14", text, StringComparison.Ordinal);
Assert.Contains("Core CPI", text, StringComparison.Ordinal);
Assert.Contains("Drawdown 1.04 %", text, StringComparison.Ordinal);
Assert.DoesNotContain("<script", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ThePeriodSummaryCountsWinsCostsAndReasons()
{
DateTime t = new(2026, 9, 23, 8, 0, 0, DateTimeKind.Utc);
List<BasketOutcomeRow> rows =
[
new("B1", "r", "EURUSD/USDCHF", "Demo", "MODERATE", t, t.AddHours(2), true, -2.1, -0.2, 12.5, 9.1, 8, 3.2, 3.4, 0.5, 0, 8, "tp_pips", 10_000, double.NaN, "x"),
new("B2", "r", "AUDUSD/USDCAD", "Demo", "MODERATE", t, t.AddHours(3), false, 2.2, 3.6, -20, -23.5, -15, 3.1, 3.5, 0.4, 0, 12, "stop_z", 10_000, double.NaN, "y"),
new("B3", "r", "EURUSD/USDCHF", "Demo", "MODERATE", t, t.AddHours(5), true, -2.0, -0.1, 6, 2.9, 4, 3.0, 3.1, 0.3, 0, 20, "tp_pips", 10_000, double.NaN, "z"),
];
string text = TelegramReports.Period("oggi", rows, 0.0123);
Assert.Contains("<b>oggi</b>: 3 basket chiusi, 2 vinti (67 %)", text, StringComparison.Ordinal);
Assert.Contains("costi 10.00", text, StringComparison.Ordinal);
Assert.Contains("netto <b>-11.50</b> USD", text, StringComparison.Ordinal);
Assert.Contains("tp_pips 2, stop_z 1", text, StringComparison.Ordinal);
Assert.Contains("drawdown attuale 1.23 %", text, StringComparison.Ordinal);
}
[Fact]
public void HtmlIsEscapedAndLongTextsAreChunked()
{
Assert.Equal("a &lt;b&gt; &amp; c", TelegramNotifier.Escape("a <b> & c"));
string longText = string.Join('\n', Enumerable.Range(0, 300).Select(static i => new string('x', 40) + i));
List<string> chunks = [.. TelegramNotifier.Chunks(longText)];
Assert.True(chunks.Count >= 3);
Assert.All(chunks, static c => Assert.True(c.Length <= TelegramNotifier.MaxMessageLength));
Assert.Equal(longText, string.Join('\n', chunks));
}
}
/// <summary>(t) Throttling and retries against a fake Telegram; (u) commands from a stranger's chat are dropped.</summary>
public class TelegramTransportTests
{
private sealed class FakeTelegram : HttpMessageHandler
{
public readonly List<(DateTime At, string Path, string Body)> Requests = [];
public Queue<HttpStatusCode> SendStatuses { get; } = new();
public string UpdatesBody { get; set; } = """{"ok":true,"result":[]}""";
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken);
string path = request.RequestUri!.PathAndQuery;
lock (Requests)
{
Requests.Add((DateTime.UtcNow, path, body));
}
if (path.Contains("/getUpdates", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(UpdatesBody, Encoding.UTF8, "application/json") };
}
HttpStatusCode status = SendStatuses.Count > 0 ? SendStatuses.Dequeue() : HttpStatusCode.OK;
string reply = status == HttpStatusCode.OK ? """{"ok":true,"result":{"message_id":1}}"""
: status == HttpStatusCode.TooManyRequests ? """{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 1","parameters":{"retry_after":1}}"""
: """{"ok":false,"description":"boom"}""";
return new HttpResponseMessage(status) { Content = new StringContent(reply, Encoding.UTF8, "application/json") };
}
}
private static TelegramOptions Options() => new() { Enabled = true, BotToken = "t0k3n", ChatId = "111", Commands = true };
[Fact]
public async Task MessagesAreSpacedByTheMinimumInterval()
{
FakeTelegram fake = new();
await using TelegramNotifier n = new(Options(), fake) { MinInterval = TimeSpan.FromMilliseconds(300), RetryDelays = [] };
Stopwatch sw = Stopwatch.StartNew();
Assert.True(await n.DeliverAsync("uno", CancellationToken.None));
Assert.True(await n.DeliverAsync("due", CancellationToken.None));
Assert.True(await n.DeliverAsync("tre", CancellationToken.None));
sw.Stop();
Assert.Equal(3, fake.Requests.Count);
Assert.True(sw.Elapsed >= TimeSpan.FromMilliseconds(580), $"tre invii in {sw.Elapsed.TotalMilliseconds:F0} ms: il limite di un messaggio per intervallo non è rispettato");
Assert.All(fake.Requests, r => Assert.Contains("\"chat_id\":\"111\"", r.Body, StringComparison.Ordinal));
Assert.All(fake.Requests, r => Assert.Contains("\"parse_mode\":\"HTML\"", r.Body, StringComparison.Ordinal));
Assert.Equal(3, n.Sent);
}
[Fact]
public async Task AServerErrorIsRetriedAndARateLimitHonoursRetryAfter()
{
FakeTelegram fake = new();
fake.SendStatuses.Enqueue(HttpStatusCode.InternalServerError);
fake.SendStatuses.Enqueue(HttpStatusCode.TooManyRequests);
fake.SendStatuses.Enqueue(HttpStatusCode.OK);
await using TelegramNotifier n = new(Options(), fake) { MinInterval = TimeSpan.Zero, RetryDelays = [TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(50)] };
Stopwatch sw = Stopwatch.StartNew();
bool ok = await n.DeliverAsync("riprova", CancellationToken.None);
Assert.True(ok);
Assert.Equal(3, fake.Requests.Count);
Assert.True(sw.Elapsed >= TimeSpan.FromMilliseconds(1000), "il retry_after di un secondo del 429 va rispettato");
Assert.Equal(1, n.Sent);
Assert.Equal(0, n.Failed);
}
[Fact]
public async Task ABadRequestIsNotRetried()
{
FakeTelegram fake = new();
fake.SendStatuses.Enqueue(HttpStatusCode.BadRequest);
await using TelegramNotifier n = new(Options(), fake) { MinInterval = TimeSpan.Zero, RetryDelays = [TimeSpan.FromMilliseconds(50)] };
Assert.False(await n.DeliverAsync("<b>rotto", CancellationToken.None));
Assert.Single(fake.Requests);
Assert.Equal(1, n.Failed);
Assert.Contains("boom", n.Status, StringComparison.Ordinal);
}
[Fact]
public async Task CommandsFromAnUnauthorisedChatAreDroppedAndTheOthersAnswered()
{
FakeTelegram fake = new()
{
UpdatesBody = """
{"ok":true,"result":[
{"update_id":10,"message":{"message_id":1,"chat":{"id":222},"text":"/kill CONFERMO"}},
{"update_id":11,"message":{"message_id":2,"chat":{"id":111},"text":"/stato"}},
{"update_id":12,"message":{"message_id":3,"chat":{"id":111}}}
]}
""",
};
List<string> received = [];
await using TelegramNotifier n = new(Options(), fake) { MinInterval = TimeSpan.Zero, RetryDelays = [] };
n.CommandHandler = (text, _) =>
{
received.Add(text);
return Task.FromResult("risposta a " + text);
};
int handled = await n.PollOnceAsync(CancellationToken.None);
Assert.Equal(1, handled);
Assert.Equal(["/stato"], received);
List<(DateTime At, string Path, string Body)> sends = [.. fake.Requests.Where(static r => r.Path.Contains("/sendMessage", StringComparison.Ordinal))];
Assert.Single(sends);
Assert.Contains("risposta a /stato", sends[0].Body, StringComparison.Ordinal);
// The next poll starts after the last update seen.
await n.PollOnceAsync(CancellationToken.None);
Assert.Contains(fake.Requests, static r => r.Path.Contains("offset=13", StringComparison.Ordinal));
}
[Fact]
public void NothingIsQueuedWhenTheChannelIsNotUsable()
{
TelegramOptions off = new() { Enabled = true, BotToken = string.Empty, ChatId = "111" };
Assert.False(off.IsUsable);
NullNotifier.Instance.Notify(NotificationKind.Alert, "x", "y");
Assert.Contains("non configurate", NullNotifier.Instance.Status, StringComparison.Ordinal);
}
}