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.
## 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.
+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).",
"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
}
}
}
+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),
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<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/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
+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).
## 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`
```
+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`) |
| `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) |
+12 -2
View File
@@ -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<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
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
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_<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 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
@@ -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;
/// <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 Task<CommandResult> 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<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:
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)
// -----------------------------------------------------------------------
@@ -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);
@@ -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();
@@ -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,
@@ -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;
/// <summary>Everything the engine knows about one basket.</summary>
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 ? "" : "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";
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 <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)
{
@@ -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;
}
@@ -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();
/// <summary>Where the bot tells the outside world what it did (Telegram).</summary>
public NotificationsOptions Notifications { get; set; } = new();
public BotConfig Validate()
{
Etoro.Validate();
Run.Validate();
Ui.Validate();
Logging.Validate();
Notifications.Validate();
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>
/// 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
@@ -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
}
}
}
""";
@@ -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<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)
{
// 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");
@@ -14,12 +14,12 @@ namespace Encelado.Bot.Engine;
/// session does; a new instance is simpler and cannot leak stale state.
/// </para>
/// </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 Lock _gate = 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 CancellationTokenSource? _engineCts;
@@ -30,6 +30,9 @@ public sealed class BotSupervisor(BotConfig config, Func<BotConfig, bool, IEngin
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>
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>
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>
/// 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"/>
+14 -1
View File
@@ -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)
{
@@ -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 <basket>, /kill CONFERMO, /reset <motivazione>. Ogni comando finisce nel ledger.",
});
return g;
}
private static SettingGroup Logging(BotConfig config)
{
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);
}
}