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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
```
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ? "sì" : "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 ? "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));
|
||||
}
|
||||
}
|
||||
@@ -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&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 <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";
|
||||
}
|
||||
@@ -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&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"/>
|
||||
|
||||
@@ -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("&", "&", 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();
|
||||
}
|
||||
}
|
||||
@@ -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&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 <b> & 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user