diff --git a/Encelado/CHANGELOG.md b/Encelado/CHANGELOG.md index fafdb66..09eccf7 100644 --- a/Encelado/CHANGELOG.md +++ b/Encelado/CHANGELOG.md @@ -2,6 +2,14 @@ Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto. +## 2026-09-23 — 5.0, Fase 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 - `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. diff --git a/Encelado/config/encelado.json b/Encelado/config/encelado.json index 307a50b..bc55215 100644 --- a/Encelado/config/encelado.json +++ b/Encelado/config/encelado.json @@ -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).", "statusLines": 200, "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 + } } } diff --git a/Encelado/docs/ARCHITECTURE.md b/Encelado/docs/ARCHITECTURE.md index 8dc9ecb..595259c 100644 --- a/Encelado/docs/ARCHITECTURE.md +++ b/Encelado/docs/ARCHITECTURE.md @@ -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), 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) -src/Encelado.Core/Notifications/ INotifier, NullNotifier (dalla Fase 5 TelegramNotifier) +src/Encelado.Core/Notifications/ INotifier, NullNotifier, TelegramNotifier (coda Channel, 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/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), @@ -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), .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), + 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) 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 @@ -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`. - `IContextProvider` (Bot): calendario, notizie e sentiment per basket (`FeedContextProvider`; `EmptyContextProvider` nei test). - `IModel`: `Predict(features)`, `Update(features, label)`, JSON, implementato da `OnlineLogistic` e `SmallMlp`. -- `IEngine` (Bot): `RunAsync`, `CloseAllAsync`, `ExecuteAsync(EngineCommand)` con `Close`, `KillSwitch` (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). ### 2.5 Vincoli e limiti scoperti in Fase 0 diff --git a/Encelado/docs/DATA_SOURCES.md b/Encelado/docs/DATA_SOURCES.md index 56570b5..781a865 100644 --- a/Encelado/docs/DATA_SOURCES.md +++ b/Encelado/docs/DATA_SOURCES.md @@ -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). +## 3.1 Telegram Bot API (dalla 5.0) + +| Rotta | Uso | Note | +|---|---|---| +| `POST https://api.telegram.org/bot/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/getUpdates?offset=&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` ``` diff --git a/Encelado/docs/LEDGER_SCHEMA.md b/Encelado/docs/LEDGER_SCHEMA.md index ac2cf45..5e375c6 100644 --- a/Encelado/docs/LEDGER_SCHEMA.md +++ b/Encelado/docs/LEDGER_SCHEMA.md @@ -16,7 +16,7 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15 | `cross` | testo | cross sintetico (`EURCHF`) | | `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` | | `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` | -| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; dalla 5.0 anche le righe di evento (senza feature, solo `ts`, `run_id`, `evento`, `basket_id`, campi propri e `motivazione`): `pending` (gamba senza esito, con `leg`, `client_ref`, `order_id`), `pending_risolto` (con `esito`, `fonte`, `position_id`), `orfana_adottata` e `orfana_chiusa` (con `position_id`, `strumento`, `pnl`, `exit_reason`), `movimento_di_cassa` (con `importo`, `saldo_prima`, `saldo_dopo`, `chiusure_nel_frattempo`, `cassa_cumulata`), `margin_guard` (con `equity`, `margine_usato`), `kill_switch_avviato`, `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` | | `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) | diff --git a/Encelado/docs/RUNBOOK.md b/Encelado/docs/RUNBOOK.md index d0555f7..3b8440f 100644 --- a/Encelado/docs/RUNBOOK.md +++ b/Encelado/docs/RUNBOOK.md @@ -1,6 +1,6 @@ # 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 @@ -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. 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à @@ -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. +## 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/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 `, `/kill CONFERMO` (senza la parola non fa niente), `/reset ` (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 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. diff --git a/Encelado/docs/STATE.md b/Encelado/docs/STATE.md index d9123b1..00d9635 100644 --- a/Encelado/docs/STATE.md +++ b/Encelado/docs/STATE.md @@ -1,14 +1,15 @@ # 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 -**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) - **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_.csv`; test (r); 211 verdi. - **Fase 3**: `FlattenProcedure` (annulla, chiudi, verifica), kill-switch che chiude basket + gambe in attesa + orfane (esterne opzionali) e dichiara `Halted-Residuo` quando il conto non è piatto, comando `residuo`, reset in cinque passi rifiutato con residui, `INotifier`; test (v)-(x); 204 verdi. - **Fase 2**: sezione `risk`, sizing = min(rischio, margine) con `sizing_bound`/`marginUsd` nel ledger, ricontrollo del disponibile prima della gamba B, esecuzione dei segnali per |z| con rilettura del conto, margin guard, backtest con gli stessi limiti; test (y), (z); 196 verdi. @@ -16,9 +17,9 @@ Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-4 concluse). ## Prossimi passi -1. **Fase 5 — Telegram** (§7): `TelegramNotifier`, stato orario, eventi, riepilogo giornaliero, comandi in ingresso. Test (s)-(u). -2. **Dopo la risposta a D-28/D-29**: Fasi 6-9 (Engine/Server, web UI M3, Docker, Unraid, skill, 5.0.0). -3. Riaccendere il Demo per 24 ore di verifica (vedi sopra), provare il kill-switch a mano con la verifica di piattezza e fermare il bot un'ora con un basket aperto per vedere il recupero. +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. 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. 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`. ## Problemi aperti diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Commands.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Commands.cs index 4bebe83..9452f2d 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Commands.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Commands.cs @@ -4,10 +4,11 @@ using Encelado.Bot.Engine; using Encelado.Bot.Logging; using Encelado.Core.Baskets; using Encelado.Core.Broker; +using Encelado.Core.Notifications; namespace Encelado.Bot.Baskets; -/// Commands from the window or the console, executed on the engine's own thread. +/// Commands from the window, the console or Telegram, executed on the engine's own thread and written to the ledger. public sealed partial class BasketEngine { public Task ExecuteAsync(EngineCommand command, CancellationToken ct) @@ -33,6 +34,13 @@ public sealed partial class BasketEngine 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); } } @@ -75,8 +83,38 @@ public sealed partial class BasketEngine _decider.SetPreset(preset); 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})"); + _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"); + 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 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: 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) // ----------------------------------------------------------------------- diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Pending.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Pending.cs index 07efee1..535338f 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Pending.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Pending.cs @@ -2,6 +2,7 @@ using System.Globalization; using Encelado.Bot.Logging; using Encelado.Core.Baskets; using Encelado.Core.Broker; +using Encelado.Core.Notifications; namespace Encelado.Bot.Baskets; @@ -79,6 +80,7 @@ public sealed partial class BasketEngine private async Task OnOrderResolvedAsync(TrackedOrder order, OrderOutcome outcome, CancellationToken ct) { 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 => { w.WriteString("basket", order.Basket); diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs index 41bf55b..22f9086 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs @@ -3,6 +3,7 @@ using System.Text; using Encelado.Bot.Logging; using Encelado.Core.Baskets; using Encelado.Core.Broker; +using Encelado.Core.Notifications; namespace Encelado.Bot.Baskets; @@ -229,6 +230,7 @@ public sealed partial class BasketEngine { 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")}")); + _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 => { 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})"); Log.Error($"MARGIN GUARD: {why}", null); + _notifier.Notify(NotificationKind.Alert, "Margin guard", TelegramEscape(why)); _ledger.Note(_runId, "margin_guard", worst.PositionBasketId, why, w => { 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"; Log.Error(_entriesBlocked, null); + _notifier.Notify(NotificationKind.Alert, "Orfana non chiudibile", TelegramEscape(_entriesBlocked)); } return false; @@ -462,6 +466,7 @@ public sealed partial class BasketEngine _equityStopped = true; _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); + _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}"); await CloseAllAsync("equity stop", ct).ConfigureAwait(false); SaveState(); diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Snapshot.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Snapshot.cs index 2d43a42..bd1b81e 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Snapshot.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Snapshot.cs @@ -85,7 +85,7 @@ public sealed partial class BasketEngine EquityStopped = _equityStopped, KillSwitched = _killSwitched, 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, Baskets = rows, Quotes = quotes, diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs index 0336709..96c9c35 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs @@ -106,6 +106,11 @@ public sealed partial class BasketEngine : IEngine private DateTime _lastQuoteUtc; private int _consecutiveApiErrors; private string _username = string.Empty; + private DateTime _startedUtc; + private long _lastHourlyBucket = -1; + private DateOnly _lastDailySummary; + private DateOnly _dailyLossNotified; + private bool _pausedByOperator; /// Everything the engine knows about one basket. private sealed class BasketSlot @@ -277,6 +282,7 @@ public sealed partial class BasketEngine : IEngine public async Task RunAsync(CancellationToken ct) { Log.Info($"motore basket: {_mode} su {_broker.Name}, preset {PresetLabel}, strategia {_configHash}, run {_runId}"); + _startedUtc = DateTime.UtcNow; AcquireInstanceLock(); 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"); + _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); } @@ -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)"); Log.Warn(_entriesBlocked); + _notifier.Notify(NotificationKind.Alert, "Scarto orologio", TelegramEscape(_entriesBlocked)); } if (File.Exists(_stopFile)) @@ -514,6 +523,7 @@ public sealed partial class BasketEngine : IEngine await DrainCommandsAsync(ct).ConfigureAwait(false); RollSessionIfNeeded(now); ExpireWarmupBlock(now); + NotifyScheduled(now); // Orders without an outcome come first: their resolution changes what the // reconciliation and the decisions below see. @@ -581,6 +591,41 @@ public sealed partial class BasketEngine : IEngine { SaveState(); 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 ? "sì" : "no")})")); + } + } + + private static string TelegramEscape(string? s) => Core.Notifications.TelegramNotifier.Escape(s); + + /// The hourly status at the top of every UTC hour and the daily summary at the configured hour. + 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 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"; 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; @@ -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())); double dailyPnl = _todayRealized + _account.UnrealizedPnl; 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 { @@ -1066,6 +1118,7 @@ public sealed partial class BasketEngine : IEngine 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"); 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) { _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}"; 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 => { w.WriteString("basket", slot.Name); @@ -1107,6 +1161,11 @@ public sealed partial class BasketEngine : IEngine string evento = outcome.Unwound ? "leg_risk_unwind" : "rifiuto"; slot.Intent = $"NON APERTO — {outcome.Error}"; 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) { _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, $"[{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 {x.RealizedPnlUsd:+0.00;-0.00} USD, {x.PipsTotal:+0.0;-0.0} pip, {p.BarsHeld} barre, motivo {reasonCode}\n{TelegramEscape(reason)}")); if (d is not null) { diff --git a/Encelado/src/Encelado.Bot/Baskets/HeadlessRunner.cs b/Encelado/src/Encelado.Bot/Baskets/HeadlessRunner.cs index 81394d9..357268c 100644 --- a/Encelado/src/Encelado.Bot/Baskets/HeadlessRunner.cs +++ b/Encelado/src/Encelado.Bot/Baskets/HeadlessRunner.cs @@ -4,6 +4,7 @@ using Encelado.Bot.Configuration; using Encelado.Bot.Engine; using Encelado.Bot.Logging; using Encelado.Core.Baskets; +using Encelado.Core.Notifications; namespace Encelado.Bot.Baskets; @@ -83,7 +84,15 @@ public static class HeadlessRunner } 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(); Console.CancelKeyPress += (_, e) => { @@ -138,6 +147,11 @@ public static class HeadlessRunner } await supervisor.StopAsync().ConfigureAwait(false); + if (notifier is IAsyncDisposable disposable) + { + await disposable.DisposeAsync().ConfigureAwait(false); + } + await Log.FlushAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); return 0; } diff --git a/Encelado/src/Encelado.Bot/Baskets/TelegramCommands.cs b/Encelado/src/Encelado.Bot/Baskets/TelegramCommands.cs new file mode 100644 index 0000000..649ed84 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Baskets/TelegramCommands.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using Encelado.Bot.Engine; +using Encelado.Bot.Logging; +using Encelado.Core.Notifications; + +namespace Encelado.Bot.Baskets; + +/// +/// 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. +/// +public sealed class TelegramCommands(BotSupervisor supervisor, Func notifierStatus) +{ + /// The phrase that arms /kill, the same as the console's confirmation habit. + public const string KillPhrase = "CONFERMO"; + + public async Task 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 /kill {KillPhrase} 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); +} + +/// Builds the notifier the configuration asks for: Telegram when usable, nothing otherwise. +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 ? "sì" : "no")}, eventi {(t.EventAlerts ? "sì" : "no")}, riepilogo alle {t.DailySummaryUtcHour:00}:00 UTC, comandi {(t.Commands ? "sì" : "no")}")); + return new TelegramNotifier(t, null, static m => Log.Warn(m)); + } +} diff --git a/Encelado/src/Encelado.Bot/Baskets/TelegramReports.cs b/Encelado/src/Encelado.Bot/Baskets/TelegramReports.cs new file mode 100644 index 0000000..1aa4eb1 --- /dev/null +++ b/Encelado/src/Encelado.Bot/Baskets/TelegramReports.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Text; +using Encelado.Bot.Engine; +using Encelado.Core.Notifications; + +namespace Encelado.Bot.Baskets; + +/// +/// 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. +/// +public static class TelegramReports +{ + private static readonly CultureInfo It = CultureInfo.InvariantCulture; + + /// The hourly status (also the answer to /stato). + 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, $"{E(state)} · {E(s.Mode)} · {E(s.ExecutionMode)} · preset {E(s.Preset)} · attivo da {E(Uptime(s.Uptime))}\n"); + sb.Append(CultureInfo.InvariantCulture, $"Equity {s.Equity:N2} · saldo {s.Balance:N2} · disponibile {s.AvailableBalance:N2} · margine usato {s.UsedMargin:N2}\n"); + sb.Append(CultureInfo.InvariantCulture, $"P&L oggi {s.TodayPnl:+0.00;-0.00;0.00} ({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 {s.OrphanLegs} · 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(); + } + + /// The answer to /posizioni. + 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, $"• {E(b.Name)} {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.PnlUsd:+0.00;-0.00} 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(); + } + + /// The daily summary and the answer to /storico: the closed baskets of a period. + public static string Period(string label, IReadOnlyList 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, $"{E(label)}: {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 {net:+0.00;-0.00} USD, media {net / n:+0.00;-0.00} per basket"); + } + + sb.Append(CultureInfo.InvariantCulture, $"; drawdown attuale {drawdownPct:P2}"); + Dictionary 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(); + } + + /// The list of commands, for /aiuto and for anything unrecognised. + public const string Help = "Comandi: /stato, /posizioni, /storico 7d, /pausa, /riprendi, /chiudi <basket>, /kill CONFERMO, /reset <motivazione>"; + + 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"; +} diff --git a/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs b/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs index d5f5082..5bb9985 100644 --- a/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs +++ b/Encelado/src/Encelado.Bot/Configuration/BotConfig.cs @@ -22,16 +22,39 @@ public sealed class BotConfig public LoggingOptions Logging { get; set; } = new(); + /// Where the bot tells the outside world what it did (Telegram). + public NotificationsOptions Notifications { get; set; } = new(); + public BotConfig Validate() { Etoro.Validate(); Run.Validate(); Ui.Validate(); Logging.Validate(); + Notifications.Validate(); return this; } } +/// The notification channels (§7 of the 5.0 plan). Token and chat id come from the environment first. +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."); + } + } +} + /// /// Execution mode and folders. Relative folders resolve against the configuration's own /// directory (Documenti\Encelado), so data, knowledge and reports sit next to the diff --git a/Encelado/src/Encelado.Bot/Configuration/ConfigDefaults.cs b/Encelado/src/Encelado.Bot/Configuration/ConfigDefaults.cs index 56c22db..264b40f 100644 --- a/Encelado/src/Encelado.Bot/Configuration/ConfigDefaults.cs +++ b/Encelado/src/Encelado.Bot/Configuration/ConfigDefaults.cs @@ -65,6 +65,7 @@ public static class ConfigDefaults config.Run = factory.Run; config.Ui = factory.Ui; config.Logging = factory.Logging; + config.Notifications = factory.Notifications; // 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 @@ -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).", "statusLines": 200, "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 + } } } """; diff --git a/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs b/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs index 47248af..e1eb673 100644 --- a/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs +++ b/Encelado/src/Encelado.Bot/Configuration/ConfigLoader.cs @@ -101,6 +101,9 @@ public static class ConfigLoader case "logging": ReadLogging(config, section.Value, warnings); break; + case "notifications": + ReadNotifications(config, section.Value, warnings); + break; case "$schema": break; default: @@ -212,8 +215,52 @@ public static class ConfigLoader } } + private static void ReadNotifications(BotConfig config, JsonElement e, List 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) { + // 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. string? apiKey = Environment.GetEnvironmentVariable("ETORO_API_KEY"); string? userKey = Environment.GetEnvironmentVariable("ETORO_USER_KEY"); diff --git a/Encelado/src/Encelado.Bot/Engine/BotSupervisor.cs b/Encelado/src/Encelado.Bot/Engine/BotSupervisor.cs index 734320c..3d2c78e 100644 --- a/Encelado/src/Encelado.Bot/Engine/BotSupervisor.cs +++ b/Encelado/src/Encelado.Bot/Engine/BotSupervisor.cs @@ -14,12 +14,12 @@ namespace Encelado.Bot.Engine; /// session does; a new instance is simpler and cannot leak stale state. /// /// -public sealed class BotSupervisor(BotConfig config, Func? factory = null) : IAsyncDisposable +public sealed class BotSupervisor(BotConfig config, Func? factory = null, Core.Notifications.INotifier? notifier = null) : IAsyncDisposable { private readonly int _eventCapacity = Math.Max(20, config.Logging.StatusLines); private readonly Lock _gate = new(); private readonly Queue _events = new(); - private readonly Func _factory = factory ?? (static (c, confirmed) => new BasketEngine(c, confirmed)); + private readonly Func _factory = factory ?? ((c, confirmed) => new BasketEngine(c, confirmed, null, notifier)); private IEngine? _engine; private CancellationTokenSource? _engineCts; @@ -30,6 +30,9 @@ public sealed class BotSupervisor(BotConfig config, Func config; + /// The channel the engines notify on; the host owns its lifetime. + public Core.Notifications.INotifier Notifier { get; } = notifier ?? Core.Notifications.NullNotifier.Instance; + /// Set by the shell once the operator has confirmed the live mode at start. public bool StartConfirmed { get; set; } diff --git a/Encelado/src/Encelado.Bot/Engine/IEngine.cs b/Encelado/src/Encelado.Bot/Engine/IEngine.cs index 00189a1..dc57531 100644 --- a/Encelado/src/Encelado.Bot/Engine/IEngine.cs +++ b/Encelado/src/Encelado.Bot/Engine/IEngine.cs @@ -23,6 +23,14 @@ public enum EngineCommandKind /// Closes what a kill-switch left on the account: Argument = a position id, or all. CloseResidue, + /// The closed baskets of a period (Argument = 1d, 7d, 30d): counts and P&L in the message. + History, + + /// Blocks new entries until ; exits keep running. + Pause, + + Resume, + /// /// The one-off clean-up of orphan legs (§5.5 of the 5.0 plan). Argument: /// list returns the orphans and the foreign positions as diff --git a/Encelado/src/Encelado.Bot/MainWindow.xaml.cs b/Encelado/src/Encelado.Bot/MainWindow.xaml.cs index 5e802d7..23fd0e2 100644 --- a/Encelado/src/Encelado.Bot/MainWindow.xaml.cs +++ b/Encelado/src/Encelado.Bot/MainWindow.xaml.cs @@ -28,6 +28,7 @@ public partial class MainWindow : Window, IUiActions private readonly BotConfig _config = App.Config; private readonly MainViewModel _vm; private readonly BotSupervisor _supervisor; + private readonly Core.Notifications.INotifier _notifier; private readonly DispatcherTimer _timer; private readonly DashboardPage _dashboard = new(); @@ -49,7 +50,15 @@ public partial class MainWindow : Window, IUiActions 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.EventLogged += _vm.Log.Enqueue; @@ -685,6 +694,10 @@ public partial class MainWindow : Window, IUiActions try { await _supervisor.DisposeAsync().ConfigureAwait(true); + if (_notifier is IAsyncDisposable disposable) + { + await disposable.DisposeAsync().ConfigureAwait(true); + } } catch (Exception ex) { diff --git a/Encelado/src/Encelado.Bot/Ui/SettingsCatalogue.cs b/Encelado/src/Encelado.Bot/Ui/SettingsCatalogue.cs index 83bcead..7243cd8 100644 --- a/Encelado/src/Encelado.Bot/Ui/SettingsCatalogue.cs +++ b/Encelado/src/Encelado.Bot/Ui/SettingsCatalogue.cs @@ -22,6 +22,7 @@ public static class SettingsCatalogue Execution(config), Etoro(config), Window(config), + Notifications(config), Logging(config), ]; @@ -190,6 +191,75 @@ public static class SettingsCatalogue 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 , /kill CONFERMO, /reset . Ogni comando finisce nel ledger.", + }); + + return g; + } + private static SettingGroup Logging(BotConfig config) { SettingGroup g = new( diff --git a/Encelado/src/Encelado.Core/Notifications/TelegramNotifier.cs b/Encelado/src/Encelado.Core/Notifications/TelegramNotifier.cs new file mode 100644 index 0000000..a4fa4ed --- /dev/null +++ b/Encelado/src/Encelado.Core/Notifications/TelegramNotifier.cs @@ -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; + +/// How to reach the Telegram Bot API for one chat (§7 of the 5.0 plan). +public sealed class TelegramOptions +{ + public bool Enabled { get; set; } + + /// From TELEGRAM_BOT_TOKEN; never in a file of the repository. + public string BotToken { get; set; } = string.Empty; + + /// The one chat the bot talks to and listens to (TELEGRAM_CHAT_ID or notifications.telegram.chatId). + public string ChatId { get; set; } = string.Empty; + + public bool HourlyStatus { get; set; } = true; + + public bool EventAlerts { get; set; } = true; + + /// UTC hour of the daily summary; negative disables it. + public int DailySummaryUtcHour { get; set; } = 21; + + /// Whether the bot answers commands from the chat (long polling). + 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; +} + +/// +/// The Telegram channel: sendMessage 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 getUpdates that accepts commands from the configured +/// chat only. Only : no library. +/// +public sealed class TelegramNotifier : INotifier, IAsyncDisposable +{ + /// Telegram's own limit for one message. + public const int MaxMessageLength = 4096; + + private readonly TelegramOptions _options; + private readonly HttpClient _client; + private readonly Action _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? 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); + } + + /// Minimum spacing between two deliveries. + public TimeSpan MinInterval { get; init; } = TimeSpan.FromSeconds(1); + + /// Waits between attempts; three attempts by default. + public TimeSpan[] RetryDelays { get; init; } = [TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)]; + + /// Long-polling wait, in seconds, asked of Telegram. + public int PollTimeoutSeconds { get; init; } = 25; + + /// Answers a command text from the authorised chat. Set by the host before . + public Func>? 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)); + } + + /// Starts the single long-polling task, when commands are enabled. + 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. + } + } + + /// One delivery with throttling and retries. Returns whether Telegram accepted it. + public async Task 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; + } + + /// The sendMessage body, written by hand: no reflection, so the Core stays trim- and AOT-safe. + 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; + } + } + } + } + + /// One getUpdates round: every message from the authorised chat is answered, everything else is dropped. + public async Task 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 + // ----------------------------------------------------------------------- + + /// HTML for Telegram: the title in bold, then the text as given (already escaped by the caller). + 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}{Escape(title)}\n{text}" : text; + } + + /// Splits on line breaks so no message exceeds Telegram's limit. + public static IEnumerable 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("&", "&", StringComparison.Ordinal).Replace("<", "<", StringComparison.Ordinal).Replace(">", ">", 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(); + } +} diff --git a/Encelado/tests/Encelado.Tests/TelegramTests.cs b/Encelado/tests/Encelado.Tests/TelegramTests.cs new file mode 100644 index 0000000..f329e06 --- /dev/null +++ b/Encelado/tests/Encelado.Tests/TelegramTests.cs @@ -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; + +/// (s) The texts on a fixed snapshot. +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("attivo", text, StringComparison.Ordinal); + Assert.Contains("109,450.25", text, StringComparison.Ordinal); + Assert.Contains("P&L oggi +120.50", text, StringComparison.Ordinal); + Assert.Contains("aperto (conto) -61.20", text, StringComparison.Ordinal); + Assert.Contains("Basket aperti 1/3", text, StringComparison.Ordinal); + Assert.Contains("orfane 1", 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(" 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("oggi: 3 basket chiusi, 2 vinti (67 %)", text, StringComparison.Ordinal); + Assert.Contains("costi 10.00", text, StringComparison.Ordinal); + Assert.Contains("netto -11.50 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 <b> & c", TelegramNotifier.Escape("a & c")); + string longText = string.Join('\n', Enumerable.Range(0, 300).Select(static i => new string('x', 40) + i)); + List 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)); + } +} + +/// (t) Throttling and retries against a fake Telegram; (u) commands from a stranger's chat are dropped. +public class TelegramTransportTests +{ + private sealed class FakeTelegram : HttpMessageHandler + { + public readonly List<(DateTime At, string Path, string Body)> Requests = []; + public Queue SendStatuses { get; } = new(); + public string UpdatesBody { get; set; } = """{"ok":true,"result":[]}"""; + + protected override async Task 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("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 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); + } +}