diff --git a/Encelado/CHANGELOG.md b/Encelado/CHANGELOG.md index 31fee17..178fae8 100644 --- a/Encelado/CHANGELOG.md +++ b/Encelado/CHANGELOG.md @@ -2,6 +2,17 @@ Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto. +## 2026-09-23 — 5.0, Fase 2: il margine come vincolo di primo livello + +- Sezione `risk` in `strategy.json` (`maxMarginUsePct` 40, `maxMarginPerBasketPct` 12, `marginBufferPct` 25, `closeForeignOnKill` false, `marginCallBlockRatio` 1,5, `marginCallCloseRatio` 1,2), letta, validata e nell'hash della configurazione. +- Sizing = min(rischio, margine): `VolParitySizing` riceve il margine massimo e le leve delle gambe, dice quale vincolo ha deciso (`sizing_bound`) e quanto margine impegna (`marginUsd`), entrambi nel ledger; sotto l'esposizione minima l'ingresso è rifiutato con `min_exposure`. +- Il decisore calcola il margine disponibile per un basket (per basket, totale, disponibile / buffer) e blocca con `margin` o `margin_guard`. +- L'esecutore rilegge il conto prima della gamba B e, se il disponibile non copre `marginB × 1,25`, non la manda e richiude A. +- I segnali della stessa barra vengono eseguiti per |z| decrescente, rileggendo il conto e ridecidendo prima di ciascuno. +- Margin guard alla riconciliazione: sotto 1,5 entrate bloccate, sotto 1,2 chiusura del basket con il P&L peggiore. +- Il backtest passa al decisore disponibile, margine usato e leve del simulatore: applica gli stessi limiti. +- Test (y), (z) e altri quattro: 196 verdi. + ## 2026-09-23 — 5.0, Fasi 0-1: post-mortem degli ordini pendenti, registro degli ordini, orfane adottate - **Diagnosi verificata** sul codice e sul conto demo via API: il lookup per `referenceId` fallisce perché il server registra un riferimento nullo per gli ordini v2; 21 gambe singole del bot fra il 16 e il 21/9, chiuse a mano il 21/9; i primi due ordini hanno impegnato tutta l'equity come margine e da lì il server ha ridotto ogni ordine a 2 000 USD di margine. `docs/PIANO_5.0.md`, `docs/POSTMORTEM_ordini_pendenti.md`, domande D-26…D-37. diff --git a/Encelado/config/strategy.json b/Encelado/config/strategy.json index 96ba187..6ab80d4 100644 --- a/Encelado/config/strategy.json +++ b/Encelado/config/strategy.json @@ -63,12 +63,22 @@ "volAverageDays": 30, "mlMinProbability": 0.55, - "_sicurezza": "equityStopPct: perdita dal picco di equity oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.", + "_sicurezza": "equityStopPct: perdita dal picco di equity (al netto dei movimenti di cassa) oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.", "equityStopPct": 9, "dailyLossPct": 3, "legTimeoutSec": 5, "clockSkewMaxSeconds": 5, + "_risk": "Margine (5.0, §10). La size di un basket è il minimo fra la size a rischio e quella a margine. maxMarginUsePct: margine totale impegnato dopo l'apertura, in % dell'equity. maxMarginPerBasketPct: margine delle due gambe di un basket, in % dell'equity. marginBufferPct: il disponibile deve superare il margine richiesto di questa percentuale prima di inviare A e, ricontrollato, prima di inviare B (altrimenti A viene richiusa). closeForeignOnKill: il kill-switch chiude anche le posizioni non aperte dal bot. marginCallBlockRatio / marginCallCloseRatio: sotto equity/margine usato = 1,5 niente entrate; sotto 1,2 si chiude il basket con il P&L peggiore.", + "risk": { + "maxMarginUsePct": 40, + "maxMarginPerBasketPct": 12, + "marginBufferPct": 25, + "closeForeignOnKill": false, + "marginCallBlockRatio": 1.5, + "marginCallCloseRatio": 1.2 + }, + "_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.", "baskets": [ { "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" }, diff --git a/Encelado/docs/LEDGER_SCHEMA.md b/Encelado/docs/LEDGER_SCHEMA.md index 323601a..72a8356 100644 --- a/Encelado/docs/LEDGER_SCHEMA.md +++ b/Encelado/docs/LEDGER_SCHEMA.md @@ -16,7 +16,7 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15 | `cross` | testo | cross sintetico (`EURCHF`) | | `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` | | `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` | -| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; dalla 5.0 anche le righe di evento (senza feature, solo `ts`, `run_id`, `evento`, `basket_id`, campi propri e `motivazione`): `pending` (gamba senza esito, con `leg`, `client_ref`, `order_id`), `pending_risolto` (con `esito`, `fonte`, `position_id`), `orfana_adottata` e `orfana_chiusa` (con `position_id`, `strumento`, `pnl`, `exit_reason`), `movimento_di_cassa` (con `importo`, `saldo_prima`, `saldo_dopo`, `chiusure_nel_frattempo`, `cassa_cumulata`), `kill_switch_avviato` | +| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; dalla 5.0 anche le righe di evento (senza feature, solo `ts`, `run_id`, `evento`, `basket_id`, campi propri e `motivazione`): `pending` (gamba senza esito, con `leg`, `client_ref`, `order_id`), `pending_risolto` (con `esito`, `fonte`, `position_id`), `orfana_adottata` e `orfana_chiusa` (con `position_id`, `strumento`, `pnl`, `exit_reason`), `movimento_di_cassa` (con `importo`, `saldo_prima`, `saldo_dopo`, `chiusure_nel_frattempo`, `cassa_cumulata`), `margin_guard` (con `equity`, `margine_usato`), `kill_switch_avviato` | | `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) | @@ -36,8 +36,9 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15 | `p_ML`, `mlActive` | numero, bool | probabilità del meta-modello e se è gate o ombra | | `equity`, `openBaskets` | numero | equity e basket aperti al momento | | `priceA`, `priceB`, `pipsOpen`, `pnlOpenUsd`, `barsHeld` | numero | stato della posizione (se aperta) | -| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage` | numero | sizing (solo su `Enter`) | -| `reasonCodes` | array | codici: `no_signal`, `rho_low`, `rho_short_low`, `half_life`, `blackout_before`, `blackout_after`, `weekend`, `just_opened`, `session`, `max_baskets`, `same_cross`, `ml_gate`, `cost_gate`, `sizing`, `kill_switch`, `equity_stop`, `daily_loss`, `entries_blocked`, `data_quality`, `warmup`, `not_bar_close`, `enter`, `add`, `hold`, `tp_pips`, `tp_z`, `stop_z`, `stop_max_loss`, `spread_anomaly`, `time_stop`, `rho_break` | +| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage`, `marginUsd` | numero | sizing (solo su `Enter`); `marginUsd` = margine previsto delle due gambe (5.0) | +| `sizing_bound` | testo | quale vincolo ha deciso la size (5.0): `risk`, `margin`, `leverage`, `units` | +| `reasonCodes` | array | codici: `no_signal`, `rho_low`, `rho_short_low`, `half_life`, `blackout_before`, `blackout_after`, `weekend`, `just_opened`, `session`, `max_baskets`, `same_cross`, `ml_gate`, `cost_gate`, `sizing`, `kill_switch`, `equity_stop`, `daily_loss`, `entries_blocked`, `data_quality`, `warmup`, `not_bar_close`, `enter`, `add`, `hold`, `tp_pips`, `tp_z`, `stop_z`, `stop_max_loss`, `spread_anomaly`, `time_stop`, `rho_break`; dalla 5.0 `margin` (nessun margine per un nuovo basket), `margin_guard` (equity / margine usato sotto la soglia), `min_exposure` (la size a margine non raggiunge l'esposizione minima) | | `motivazione` | testo | la frase, in italiano, con i numeri | ## `data/ledger/baskets.csv` @@ -48,7 +49,7 @@ Una riga per basket chiuso. `label = 1` se `pnl_net_usd > 0`, altrimenti 0: è l basket_id;run_id;basket;mode;preset;opened_utc;closed_utc;buy_cross;entry_z;exit_z;pnl_gross_usd;pnl_net_usd;pips_gross;cost_pips;cost_usd;slippage_pips;adds;bars_held;exit_reason;equity_at_entry;p_ml_at_entry;label;durata_min;motivazione ``` -`pips_gross` è la somma dei pip delle due gambe ai prezzi di esecuzione (la colonna "Pips" della UI), `cost_pips` il costo stimato all'ingresso, `slippage_pips` la differenza fra quotazione vista e prezzo eseguito sommata sulle gambe, `exit_reason` uno dei codici sopra più `manual`, `closed_by_broker`, `leg_closed_by_broker`, `end_of_data`, e dalla 5.0 `leg_risk_unwind` (gamba A eseguita in ritardo e richiusa), `orphan_closed` (gamba orfana del bot adottata e chiusa alla riconciliazione), `bonifica_orfana` (chiusa dalla bonifica con conferma), `kill_switch`/`equity_stop` anche per le gambe singole. Le righe di una gamba singola hanno `entry_z`, `exit_z`, `pips_gross` e `cost_*` vuoti e `buy_cross` = verso della gamba. +`pips_gross` è la somma dei pip delle due gambe ai prezzi di esecuzione (la colonna "Pips" della UI), `cost_pips` il costo stimato all'ingresso, `slippage_pips` la differenza fra quotazione vista e prezzo eseguito sommata sulle gambe, `exit_reason` uno dei codici sopra più `manual`, `closed_by_broker`, `leg_closed_by_broker`, `end_of_data`, e dalla 5.0 `leg_risk_unwind` (gamba A eseguita in ritardo e richiusa, o B non coperta dal margine), `margin_guard` (chiuso dalla guardia del margine), `orphan_closed` (gamba orfana del bot adottata e chiusa alla riconciliazione), `bonifica_orfana` (chiusa dalla bonifica con conferma), `kill_switch`/`equity_stop` anche per le gambe singole. Le righe di una gamba singola hanno `entry_z`, `exit_z`, `pips_gross` e `cost_*` vuoti e `buy_cross` = verso della gamba. ## `data/ledger/orders.jsonl` (dalla 5.0) diff --git a/Encelado/docs/RISK_RULES.md b/Encelado/docs/RISK_RULES.md index 540be81..fc8ab28 100644 --- a/Encelado/docs/RISK_RULES.md +++ b/Encelado/docs/RISK_RULES.md @@ -14,6 +14,12 @@ Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e c | Basket aperti | 2 / 3 / 5 (preset) | `strategy.json` → preset o `maxBaskets` | operatore | | Un solo basket per cross sintetico | `Exclusive` | `strategy.json` → `sameCrossPolicy` | operatore (`Half` dimezza la size di entrambi) | | Leva effettiva massima | 10:1 sul nozionale complessivo | `strategy.json` → `maxEffectiveLeverage` | operatore, tetto 30 | +| Margine per basket | il margine delle due gambe (nozionale / leva dell'ordine) ≤ **12 %** dell'equity; la size è il **minimo** fra la size a rischio e quella a margine e il ledger scrive quale vincolo ha deciso (`sizing_bound`) e il margine previsto (`marginUsd`) | `strategy.json` → `risk.maxMarginPerBasketPct` | operatore | +| Margine totale | margine impegnato dopo l'apertura ≤ **40 %** dell'equity | `strategy.json` → `risk.maxMarginUsePct` | operatore | +| Buffer di cassa | `available ≥ (marginA + marginB) × 1,25` prima di inviare A (entra nel calcolo della size); `available ≥ marginB × 1,25` ricontrollato con A già sul conto prima di inviare B, altrimenti A viene richiusa (`leg_risk_unwind`) | `strategy.json` → `risk.marginBufferPct` | operatore | +| Esposizione minima | se la size a margine scende sotto i 1000 USD di esposizione minima di eToro su una gamba, niente ingresso (`min_exposure`) | codice + eligibility | nessuno | +| Ordine dei segnali | più segnali sulla stessa barra vengono eseguiti uno alla volta per \|z\| decrescente, rileggendo il conto prima di ciascuno: il secondo basket vede il margine impegnato dal primo | codice | nessuno | +| Margin guard | `equity / margine usato < 1,5` → niente nuove entrate (`margin_guard`); `< 1,2` → chiusura del basket con il P&L peggiore (riga `margin_guard` nel ledger) | `strategy.json` → `risk.marginCallBlockRatio`, `risk.marginCallCloseRatio` | operatore | | Leva dichiarata per gamba | 10 | `strategy.json` → `orderLeverage` | operatore; la leva effettiva resta governata dal sizing | | Stop nativo su ogni gamba | sì, sempre (eToro lo richiede su short e leva > 1) | codice (`BasketExecutor.Request`) | nessuno; la distanza deriva da `maxLossPerBasketPct` entro i limiti di eligibility | | Cost gate | TP ≥ 3 × costo; spread ≤ 2 × mediana 24 h | `strategy.json` → `costMultiple`, `spreadMedianMultiple` | operatore | diff --git a/Encelado/docs/STATE.md b/Encelado/docs/STATE.md index 7c00e64..25abc60 100644 --- a/Encelado/docs/STATE.md +++ b/Encelado/docs/STATE.md @@ -1,22 +1,23 @@ # Stato del lavoro -Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-1 concluse). +Aggiornato: 2026-09-23 (sessione 5.0, Fasi 0-2 concluse). ## Fase in corso -**Piano 5.0, Fase 2** (`docs/PIANO_5.0.md`). Le Fasi 0 e 1 sono committate: post-mortem, registro degli ordini, stati `PendingA`/`PendingB`, classificazione e chiusura delle orfane, picco al netto dei movimenti di cassa, bonifica. Il forward test in Demo **non è ripartito**: la regola finale del piano dice che nessuna nuova funzione va in Demo finché un ordine dall'esito ignoto può restare sul conto senza padrone; la Fase 1 chiude quella falla, ma le Fasi 2 (margine) e 3 (kill-switch reale) vanno finite prima di riaccendere il bot, perché senza limiti di margine il primo basket può ancora impegnare tutta l'equity. +**Piano 5.0, Fase 3** (`docs/PIANO_5.0.md`). Le Fasi 0-2 sono committate: post-mortem, registro degli ordini, stati `PendingA`/`PendingB`, classificazione e chiusura delle orfane, picco al netto dei movimenti di cassa, bonifica, limiti di margine. Il forward test in Demo **non è ripartito**: la regola finale del piano dice che nessuna nuova funzione va in Demo finché un ordine dall'esito ignoto può restare sul conto senza padrone; le Fasi 1-2 chiudono quella falla e quella del margine, la Fase 3 (kill-switch reale) va finita prima di riaccendere il bot. ## 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 2**: sezione `risk`, sizing = min(rischio, margine) con `sizing_bound`/`marginUsd` nel ledger, ricontrollo del disponibile prima della gamba B, esecuzione dei segnali per |z| con rilettura del conto, margin guard, backtest con gli stessi limiti; test (y), (z); 196 verdi. - **Fase 1**: `OrderTracker` + `pending_orders.json` + `orders.jsonl`; `EtoroBroker.OpenAsync` per `orderId` con riconoscimento dalla posizione; `BasketExecutor` con `ResumeAfterAAsync`/`CompleteAfterBAsync`/`UnwindLegAsync`; stati `PendingA`/`PendingB` persistiti; `PositionClassifier` e chiusura delle orfane; `EquityTracker`; contatori e P&L del conto nello snapshot e in dashboard; `--bonifica`; bandit senza applicazione automatica; `BasketEngine` in sei file parziali; ADR-0009; 18 test nuovi, 190 verdi. ## Prossimi passi -1. **Fase 2 — margine** (§10): sezione `risk` in `strategy.json` (`maxMarginUsePct` 40, `maxMarginPerBasketPct` 12, `marginBufferPct` 25, `closeForeignOnKill` false), sizing = min(rischio, margine) con `sizing_bound` nel ledger, ricontrollo di `available` prima di B, ordine dei segnali per |z|, margin guard (1,5 blocca, 1,2 chiude il peggiore). Test (y), (z). -2. **Fase 3 — kill-switch e ripristino** (§9): cancellazione dei pendenti, chiusura di basket + orfane, esterne opzionali, verifica di piattezza, `Halted-Residuo`; procedura di ripristino in cinque passi. Test (v)-(x). -3. Poi Fasi 4-5 (recupero dopo inattività, Telegram) e, **dopo la risposta a D-28/D-29**, le Fasi 6-9 (Engine/Server, web UI, Docker, Unraid, skill). -4. Riaccendere il Demo solo dopo la Fase 3, per 24 ore di verifica: contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti. +1. **Fase 3 — kill-switch e ripristino** (§9): cancellazione dei pendenti, chiusura di basket + orfane, esterne opzionali, verifica di piattezza, `Halted-Residuo`; procedura di ripristino in cinque passi. Test (v)-(x). +2. Poi Fasi 4-5 (recupero dopo inattività, Telegram) e, **dopo la risposta a D-28/D-29**, le Fasi 6-9 (Engine/Server, web UI, Docker, Unraid, skill). +3. Riaccendere il Demo solo dopo la Fase 3, per 24 ore di verifica: contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti, `sizing_bound = margin` sui primi ingressi. +4. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`. ## Problemi aperti diff --git a/Encelado/docs/STRATEGY.md b/Encelado/docs/STRATEGY.md index e6b6281..9f9afbe 100644 --- a/Encelado/docs/STRATEGY.md +++ b/Encelado/docs/STRATEGY.md @@ -20,7 +20,7 @@ In tutti e cinque la valuta comune ha ruoli opposti nelle due coppie, quindi `X **Cancelli all'ingresso** (§5.3): correlazione rolling `ρ_W ≤ −0,6`, semiperiodo fra 5 e 120 barre, forza di trend sotto soglia, blackout del calendario, fine settimana, cost gate (`TP ≥ 3 × costo`, spread ≤ 2 × mediana 24 h), massimo di basket aperti, un solo basket per cross sintetico, quote fresche, orologio allineato, nessun blocco attivo. -**Sizing** (vol-parity): unità inversamente proporzionali all'ATR di ogni gamba, rischio totale alla distanza dello stop = `riskPerBasketPct` dell'equity (0,5 % Moderate), esposizione minima di eToro 1000 USD per gamba, leva effettiva ≤ 10. +**Sizing** (vol-parity): unità inversamente proporzionali all'ATR di ogni gamba, rischio totale alla distanza dello stop = `riskPerBasketPct` dell'equity (0,5 % Moderate), esposizione minima di eToro 1000 USD per gamba, leva effettiva ≤ 10. **Dalla 5.0** la size è il minimo fra quella a rischio e quella a margine (`risk` in `strategy.json`: 12 % dell'equity per basket, 40 % in totale, disponibile con buffer del 25 %): il 16/9 un solo basket sizato a rischio aveva impegnato il 100 % del margine. Il backtest applica gli stessi limiti dalla 5.0; i numeri di §3 sono della 4.0.0 (senza limite di margine) e vanno rifatti prima di trarne altre conclusioni: con meno nozionale il P&L per basket si riduce in valore assoluto, il segno del verdetto no. **Uscite** (§5.4): take-profit di basket in pip (10 nel Moderate) **oppure** rientro dello z sotto `z_out` (0,25), a seconda di `exitMode`; stop di basket a `|z| ≥ z_stop` (3,5) o perdita ≥ 1,5 % dell'equity; time-stop dopo 4 giorni; spread anomalo per 3 barre consecutive; correlazione rotta; kill-switch ed equity stop. diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs index 756aa23..84ab7ae 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.Reconcile.cs @@ -123,6 +123,7 @@ public sealed partial class BasketEngine List vanished = [.. _lastPositionIds.Where(id => !onVenue.Contains(id))]; _lastPositionIds = onVenue; await RefreshAccountAsync(await ClosedNetAsync(vanished, ct).ConfigureAwait(false), ct).ConfigureAwait(false); + await MarginGuardAsync(ct).ConfigureAwait(false); // 1. The baskets the engine holds. foreach (BasketSlot slot in _slots) @@ -290,6 +291,50 @@ public sealed partial class BasketEngine } } + /// + /// The guard against a margin call (§10): under marginCallBlockRatio of equity + /// over used margin no new entry; under marginCallCloseRatio the open basket + /// with the worst result is closed (margin_guard). + /// + private async Task MarginGuardAsync(CancellationToken ct) + { + const string prefix = "margine: "; + double ratio = _account.UsedMargin > 0 ? _account.Equity / _account.UsedMargin : double.PositiveInfinity; + if (ratio < _strategy.Risk.MarginCallCloseRatio) + { + BasketSlot? worst = _slots.Where(static s => s.Position is not null && s.State == BasketState.Open && !s.Busy && s.A.HasQuote && s.B.HasQuote) + .OrderBy(static s => double.IsFinite(s.LastEvaluation.PnlOpenUsd) ? s.LastEvaluation.PnlOpenUsd : 0).FirstOrDefault(); + if (worst is not null) + { + 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); + _ledger.Note(_runId, "margin_guard", worst.PositionBasketId, why, w => + { + w.WriteString("basket", worst.Name); + w.WriteNumber("equity", Math.Round(_account.Equity, 2)); + w.WriteNumber("margine_usato", Math.Round(_account.UsedMargin, 2)); + }); + BasketContext ctx = await BuildContextAsync(worst, DateTime.UtcNow, false, ct).ConfigureAwait(false); + await ExecuteExitAsync(worst, ctx, null, why, "margin_guard", ct).ConfigureAwait(false); + } + } + + if (ratio < _strategy.Risk.MarginCallBlockRatio) + { + string reason = string.Create(CultureInfo.InvariantCulture, $"{prefix}equity / margine usato {ratio:0.00} sotto {_strategy.Risk.MarginCallBlockRatio:0.00}"); + if (_entriesBlocked is null) + { + _entriesBlocked = reason; + Log.Warn($"{reason}: nuove entrate bloccate"); + } + } + else if (_entriesBlocked is not null && _entriesBlocked.StartsWith(prefix, StringComparison.Ordinal)) + { + _entriesBlocked = null; + Log.Info("margine rientrato: entrate riabilitate"); + } + } + /// Whether the engine and the account have disagreed longer than the grace period. private bool IsUnreconciled => _unreconciledSince is { } since && DateTime.UtcNow - since > UnreconciledGrace; diff --git a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs index e40b12f..8b6e513 100644 --- a/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs +++ b/Encelado/src/Encelado.Bot/Baskets/BasketEngine.cs @@ -639,7 +639,11 @@ public sealed partial class BasketEngine : IEngine } } - // Exits are watched on every quote; entries only on bar closes. + // Exits are watched on every quote; entries only on bar closes. Entries decided on + // the same bar are collected and executed afterwards, largest |z| first, with the + // account re-read before each one (§10: the second basket sees the margin the + // first one took). + List<(BasketSlot Slot, BasketDecision Decision)> entries = []; foreach (BasketSlot slot in _slots) { if (!slot.Enabled) @@ -654,7 +658,7 @@ public sealed partial class BasketEngine : IEngine if (bucket != slot.LastBarBucket) { slot.LastBarBucket = bucket; - await EvaluateAsync(slot, now, isBarClose: true, ct).ConfigureAwait(false); + await EvaluateAsync(slot, now, isBarClose: true, ct, entries).ConfigureAwait(false); continue; } } @@ -664,6 +668,49 @@ public sealed partial class BasketEngine : IEngine await EvaluateAsync(slot, now, isBarClose: false, ct).ConfigureAwait(false); } } + + if (entries.Count > 0) + { + await ExecuteEntriesInOrderAsync(entries, ct).ConfigureAwait(false); + } + } + + /// + /// Runs the entries of one bar close one at a time, |z| descending. Before each, the + /// account is re-read and the decision re-taken on the fresh context: the margin the + /// previous basket locked is now in UsedMargin and out of Available, so + /// a basket that no longer fits is refused here, not by the venue. + /// + private async Task ExecuteEntriesInOrderAsync(List<(BasketSlot Slot, BasketDecision Decision)> entries, CancellationToken ct) + { + entries.Sort(static (x, y) => Math.Abs(y.Decision.Evaluation.Z).CompareTo(Math.Abs(x.Decision.Evaluation.Z))); + bool first = true; + foreach ((BasketSlot slot, BasketDecision decided) in entries) + { + if (slot.State != BasketState.Idle || slot.Position is not null) + { + continue; + } + + if (!first) + { + await RefreshAccountAsync(0, ct).ConfigureAwait(false); + } + + first = false; + BasketContext ctx = await BuildContextAsync(slot, DateTime.UtcNow, isBarClose: true, ct).ConfigureAwait(false); + ctx = ctx with { PMl = slot.PMl, MlActive = _learning.Active && double.IsFinite(slot.PMl) }; + BasketDecision d = _decider.Evaluate(ctx); + if (d.Kind != DecisionKind.Enter) + { + slot.Intent = $"NON APERTO — {d.Motivazione}"; + Log.Warn($"[{slot.Name}] segnale (z {decided.Evaluation.Z:+0.00;-0.00}) non eseguito dopo la rilettura del conto: {d.Motivazione}"); + _ledger.Decision(_runId, ModeLabel, PresetLabel, _configHash, ctx, d, "rifiuto", null, "rilettura del conto prima dell'invio"); + continue; + } + + await ExecuteEntryAsync(slot, ctx, d, ct).ConfigureAwait(false); + } } private void OnBarClosed(SymbolSeries s, in BidAskBar bar) @@ -719,7 +766,7 @@ public sealed partial class BasketEngine : IEngine // Decisions // ----------------------------------------------------------------------- - private async Task EvaluateAsync(BasketSlot slot, DateTime now, bool isBarClose, CancellationToken ct) + private async Task EvaluateAsync(BasketSlot slot, DateTime now, bool isBarClose, CancellationToken ct, List<(BasketSlot Slot, BasketDecision Decision)>? entries = null) { if (slot.Busy || slot.State is BasketState.Entering or BasketState.Exiting or BasketState.Adding || slot.State.IsPending()) { @@ -805,7 +852,14 @@ public sealed partial class BasketEngine : IEngine if (isBarClose) { Log.Info($"[{slot.Name}] {slot.Intent}"); - await ExecuteEntryAsync(slot, ctx, d, ct).ConfigureAwait(false); + if (entries is not null) + { + entries.Add((slot, d)); + } + else + { + await ExecuteEntryAsync(slot, ctx, d, ct).ConfigureAwait(false); + } } break; @@ -864,6 +918,10 @@ public sealed partial class BasketEngine : IEngine OpenBaskets = _slots.Count(static s => s.Position is not null || s.State == BasketState.Entering || s.State.IsPending()), SameCrossOpen = sameCrossOpen, DailyLossHit = dailyLossHit, + AvailableMargin = _account.TimeUtc == DateTime.MinValue ? double.NaN : _account.Available, + UsedMargin = _account.UsedMargin, + LeverageA = BasketExecutor.ChooseLeverage(slot.A.Instrument, _strategy.OrderLeverage), + LeverageB = BasketExecutor.ChooseLeverage(slot.B.Instrument, _strategy.OrderLeverage), EquityStopped = _equityStopped, KillSwitched = _killSwitched, EntriesBlockedReason = _entriesBlocked ?? StaleQuoteReason(slot, now), diff --git a/Encelado/src/Encelado.Bot/Baskets/Ledger.cs b/Encelado/src/Encelado.Bot/Baskets/Ledger.cs index f4f15cf..ca71538 100644 --- a/Encelado/src/Encelado.Bot/Baskets/Ledger.cs +++ b/Encelado/src/Encelado.Bot/Baskets/Ledger.cs @@ -442,6 +442,8 @@ public sealed class Ledger : IDisposable Num(w, "notionalUsd", s.NotionalUsdA + s.NotionalUsdB); Num(w, "lossAtStopUsd", s.LossAtStopUsd); Num(w, "effectiveLeverage", s.EffectiveLeverage); + Num(w, "marginUsd", s.MarginUsd); + w.WriteString("sizing_bound", s.Bound); } w.WriteStartArray("reasonCodes"); diff --git a/Encelado/src/Encelado.Core/Baskets/Backtest/BacktestBroker.cs b/Encelado/src/Encelado.Core/Baskets/Backtest/BacktestBroker.cs index 5c468ca..e90bda8 100644 --- a/Encelado/src/Encelado.Core/Baskets/Backtest/BacktestBroker.cs +++ b/Encelado/src/Encelado.Core/Baskets/Backtest/BacktestBroker.cs @@ -132,6 +132,11 @@ public sealed class BacktestBroker : IBroker return Task.FromResult(new AccountSnapshot(_now, "USD", _balance, equity, Math.Max(0, equity - margin), margin, unrealized)); } + /// Cash a new order can use: equity minus the margin locked. + public double Available => GetAccountAsync(CancellationToken.None).Result.Available; + + public double UsedMargin => GetAccountAsync(CancellationToken.None).Result.UsedMargin; + public double Equity { get diff --git a/Encelado/src/Encelado.Core/Baskets/Backtest/BasketBacktest.cs b/Encelado/src/Encelado.Core/Baskets/Backtest/BasketBacktest.cs index 1bfffca..beb6668 100644 --- a/Encelado/src/Encelado.Core/Baskets/Backtest/BasketBacktest.cs +++ b/Encelado/src/Encelado.Core/Baskets/Backtest/BasketBacktest.cs @@ -328,7 +328,7 @@ public static class BasketBacktest p.BarsHeld++; } - BasketContext ctx = Context(slot, t, broker, equityNow, peak, slots, isBarClose: true) with + BasketContext ctx = Context(slot, t, broker, equityNow, peak, slots, isBarClose: true, config.OrderLeverage) with { DailyLossHit = dailyLoss, EquityStopped = equityStopped, @@ -368,7 +368,7 @@ public static class BasketBacktest { if (slot.Position is { } p && slot.A.HasQuote && slot.B.HasQuote) { - BasketContext ctx = Context(slot, broker.Now, broker, broker.Equity, peak, slots, isBarClose: false); + BasketContext ctx = Context(slot, broker.Now, broker, broker.Equity, peak, slots, isBarClose: false, config.OrderLeverage); ExitOutcome x = executor.CloseAsync(ctx, p, "fine dei dati", CancellationToken.None).GetAwaiter().GetResult(); trades.Add(new BacktestTrade(slot.Name, p.OpenedUtc, broker.Now, p.BuyCross, p.EntryZ, slot.PendingZ, x.RealizedPnlUsd, x.PipsTotal, p.EntryCostPips, p.Adds, p.BarsHeld, "end_of_data", p.EquityAtEntry)); slot.Position = null; @@ -448,7 +448,7 @@ public static class BasketBacktest } } - private static BasketContext Context(Slot slot, DateTime t, BacktestBroker broker, double equity, double peak, List slots, bool isBarClose) + private static BasketContext Context(Slot slot, DateTime t, BacktestBroker broker, double equity, double peak, List slots, bool isBarClose, int leverage = 10) { bool sameCrossOpen = slots.Any(o => o != slot && o.Cross.Symbol == slot.Cross.Symbol && (o.Position is not null || o.Pending?.Kind == DecisionKind.Enter)); int open = slots.Count(static o => o.Position is not null); @@ -469,6 +469,10 @@ public static class BasketBacktest PeakEquity = peak, OpenBaskets = open, SameCrossOpen = sameCrossOpen, + AvailableMargin = broker.Available, + UsedMargin = broker.UsedMargin, + LeverageA = BasketExecutor.ChooseLeverage(slot.A.Instrument, leverage), + LeverageB = BasketExecutor.ChooseLeverage(slot.B.Instrument, leverage), IsBarClose = isBarClose, PipValueUsdA = double.IsNaN(pipA) ? 0 : pipA, PipValueUsdB = double.IsNaN(pipB) ? 0 : pipB, diff --git a/Encelado/src/Encelado.Core/Baskets/Backtest/BasketTrials.cs b/Encelado/src/Encelado.Core/Baskets/Backtest/BasketTrials.cs index 5cb6423..674136c 100644 --- a/Encelado/src/Encelado.Core/Baskets/Backtest/BasketTrials.cs +++ b/Encelado/src/Encelado.Core/Baskets/Backtest/BasketTrials.cs @@ -90,6 +90,15 @@ public static class BasketTrials { ArgumentNullException.ThrowIfNull(c); BasketStrategyConfig copy = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _); + copy.Risk = new RiskOptions + { + MaxMarginUsePct = c.Risk.MaxMarginUsePct, + MaxMarginPerBasketPct = c.Risk.MaxMarginPerBasketPct, + MarginBufferPct = c.Risk.MarginBufferPct, + CloseForeignOnKill = c.Risk.CloseForeignOnKill, + MarginCallBlockRatio = c.Risk.MarginCallBlockRatio, + MarginCallCloseRatio = c.Risk.MarginCallCloseRatio, + }; copy.Preset = c.Preset; copy.SignalMode = c.SignalMode; copy.ExitMode = c.ExitMode; diff --git a/Encelado/src/Encelado.Core/Baskets/BasketDecider.cs b/Encelado/src/Encelado.Core/Baskets/BasketDecider.cs index 187dcea..0cac5b4 100644 --- a/Encelado/src/Encelado.Core/Baskets/BasketDecider.cs +++ b/Encelado/src/Encelado.Core/Baskets/BasketDecider.cs @@ -30,6 +30,17 @@ public sealed record BasketContext public bool DailyLossHit { get; init; } + /// Cash the venue lets a new order use (NaN when the engine does not know: no margin cap is applied). + public double AvailableMargin { get; init; } = double.NaN; + + /// Margin locked by every position on the account right now. + public double UsedMargin { get; init; } + + /// The leverage each leg's order will carry (the venue locks notional / leverage). + public int LeverageA { get; init; } = 1; + + public int LeverageB { get; init; } = 1; + public bool EquityStopped { get; init; } public bool KillSwitched { get; init; } @@ -497,6 +508,14 @@ public sealed class BasketDecider if (ctx.OpenBaskets >= _preset.MaxBaskets) { codes.Add("max_baskets"); } if (ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Exclusive) { codes.Add("same_cross"); } + // Margin (§10 of the 5.0 plan): the guard against a margin call, then the room a + // new basket may take: per basket, in total, and against the cash available with + // the buffer. The smallest of the three is the cap the sizing gets. + double marginRatio = ctx.UsedMargin > 0 ? ctx.Equity / ctx.UsedMargin : double.PositiveInfinity; + if (marginRatio < _cfg.Risk.MarginCallBlockRatio) { codes.Add("margin_guard"); } + double maxMargin = MarginRoom(ctx); + if (maxMargin <= 0) { codes.Add("margin"); } + // Meta-model (8). if (ctx.MlActive && double.IsFinite(ctx.PMl) && ctx.PMl < _cfg.MlMinProbability) { codes.Add("ml_gate"); } @@ -520,14 +539,15 @@ public sealed class BasketDecider return Skip(withCost, codes, Explain(codes, ctx, e, cost), cost); } - // Sizing (§4.3). + // Sizing (§4.3), the smallest of risk and margin (§10). double scale = ctx.MlActive && double.IsFinite(ctx.PMl) ? Math.Clamp((2 * ctx.PMl) - 1, 0.25, 1) : 1; double costUsd = cost.CostPips * ctx.PipValueUsdA; // per unit of A; scaled below once units are known — first pass uses a small placeholder SizingResult sizing = VolParitySizing.Compute( ctx.Equity, _preset.RiskPerBasketPct, e.Z, _preset.ZStop, e.SigmaX, e.AtrPipsA, e.AtrPipsB, ctx.PipValueUsdA, ctx.PipValueUsdB, e.PriceA, e.PriceB, ctx.UsdPerQuoteA, ctx.UsdPerQuoteB, 0, ctx.A.Instrument.MinExposure, ctx.A.Instrument.MaxUnitsPerOrder, ctx.B.Instrument.MaxUnitsPerOrder, - _cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale); + _cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale, + maxMargin, ctx.LeverageA, ctx.LeverageB); if (sizing.Ok) { @@ -537,12 +557,13 @@ public sealed class BasketDecider ctx.Equity, _preset.RiskPerBasketPct, e.Z, _preset.ZStop, e.SigmaX, e.AtrPipsA, e.AtrPipsB, ctx.PipValueUsdA, ctx.PipValueUsdB, e.PriceA, e.PriceB, ctx.UsdPerQuoteA, ctx.UsdPerQuoteB, costUsd, ctx.A.Instrument.MinExposure, ctx.A.Instrument.MaxUnitsPerOrder, ctx.B.Instrument.MaxUnitsPerOrder, - _cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale); + _cfg.MaxEffectiveLeverage, ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Half ? scale * 0.5 : scale, + maxMargin, ctx.LeverageA, ctx.LeverageB); } if (!sizing.Ok) { - return Skip(withCost, ["sizing"], $"size non calcolabile: {sizing.Reason}", cost); + return Skip(withCost, [sizing.Bound == "min_exposure" ? "min_exposure" : sizing.Bound == "margin" ? "margin" : "sizing"], $"size non calcolabile: {sizing.Reason}", cost); } if (_cfg.InvertSignal) @@ -558,6 +579,25 @@ public sealed class BasketDecider return new BasketDecision(DecisionKind.Enter, buyCross, sizing, ["enter"], why, withCost, cost); } + /// + /// The margin a new basket may lock: the per-basket cap, what is left under the total + /// cap, and the cash available divided by the buffer. Infinity when the context does + /// not know the account (the backtest before 5.0, the unit tests). + /// + public double MarginRoom(BasketContext ctx) + { + ArgumentNullException.ThrowIfNull(ctx); + if (!double.IsFinite(ctx.AvailableMargin)) + { + return double.PositiveInfinity; + } + + double perBasket = ctx.Equity * _cfg.Risk.MaxMarginPerBasketPct / 100.0; + double total = Math.Max(0, (ctx.Equity * _cfg.Risk.MaxMarginUsePct / 100.0) - Math.Max(0, ctx.UsedMargin)); + double cash = Math.Max(0, ctx.AvailableMargin) / _cfg.Risk.BufferFactor; + return Math.Min(perBasket, Math.Min(total, cash)); + } + private double TpPips(BasketEvaluation e) => _cfg.TpMode == TpMode.AtrMultiple && double.IsFinite(e.AtrPipsA) ? Math.Max(1, _cfg.TpAtrMultiple * e.AtrPipsA) : _preset.TpPips; @@ -602,6 +642,8 @@ public sealed class BasketDecider "session" => "fuori dalle sessioni consentite", "max_baskets" => F($"{ctx.OpenBaskets} basket aperti su {_preset.MaxBaskets}"), "same_cross" => $"un altro basket sullo stesso cross sintetico {ctx.Cross.Symbol} è aperto", + "margin_guard" => F($"equity / margine usato {ctx.Equity / Math.Max(1e-9, ctx.UsedMargin):0.00} sotto {_cfg.Risk.MarginCallBlockRatio:0.00}: rischio di margin call"), + "margin" => F($"nessun margine per un nuovo basket: usato {ctx.UsedMargin:F0} USD su un tetto di {ctx.Equity * _cfg.Risk.MaxMarginUsePct / 100.0:F0} ({_cfg.Risk.MaxMarginUsePct:0} % dell'equity), disponibile {ctx.AvailableMargin:F0} USD con buffer {_cfg.Risk.MarginBufferPct:0} %"), "ml_gate" => F($"p_ML {ctx.PMl:0.00} sotto {_cfg.MlMinProbability:0.00}"), "cost_gate" => cost.Reason, _ => c, diff --git a/Encelado/src/Encelado.Core/Baskets/BasketExecutor.cs b/Encelado/src/Encelado.Core/Baskets/BasketExecutor.cs index 914ccbb..fd362bb 100644 --- a/Encelado/src/Encelado.Core/Baskets/BasketExecutor.cs +++ b/Encelado/src/Encelado.Core/Baskets/BasketExecutor.cs @@ -187,6 +187,29 @@ public sealed class BasketExecutor _log(string.Create(CultureInfo.InvariantCulture, $"[{ctx.Name}] la gamba A è stata eseguita per {legA.Units:0.##} unità su {plan.UnitsA:0.##} richieste: la gamba B scende a {unitsB:0.##}")); } + // The cash available is re-read with leg A already on the account: if it no longer + // covers B's margin with the buffer, B does not go out and A comes back (§10). + double marginB = PipMath.NotionalUsd(ctx.B.Symbol, unitsB, quoteB, _mid) / Math.Max(1, ChooseLeverage(ctx.B.Instrument, _cfg.OrderLeverage)); + if (double.IsFinite(marginB) && marginB > 0) + { + AccountSnapshot? account = null; + try + { + account = await _broker.GetAccountAsync(ct).ConfigureAwait(false); + } + catch (BrokerException ex) + { + _log($"[{ctx.Name}] conto non letto prima della gamba B ({ex.Message}): procedo con l'ultimo disponibile noto"); + } + + if (account is not null && account.Available < marginB * _cfg.Risk.BufferFactor) + { + string why = string.Create(CultureInfo.InvariantCulture, $"margine insufficiente per la gamba B: disponibile {account.Available:F0} USD, servono {marginB * _cfg.Risk.BufferFactor:F0} ({marginB:F0} × {_cfg.Risk.BufferFactor:0.00})"); + _log($"[{ctx.Name}] {why}: chiudo subito la gamba A (leg_risk_unwind)"); + return await UnwindAAsync(ctx, plan, legA, why, t0, ct).ConfigureAwait(false); + } + } + OrderRequest reqB = Request(ctx.B, buyB, unitsB, quoteB, plan.Motivazione); plan.ClientRefB = reqB.ClientRef; TrackedOrder trackB = Track(reqB, ctx.Name, plan.BasketId, OrderLeg.B, quoteB, plan.Motivazione); @@ -585,7 +608,7 @@ public sealed class BasketExecutor // The venue wants a native stop on every short and on every leveraged order: put it // at the distance the basket's own max loss implies (as a fraction of margin, within // the venue's bounds), which is far outside the basket stop the bot applies itself. - int leverage = ChooseLeverage(s.Instrument); + int leverage = ChooseLeverage(s.Instrument, _cfg.OrderLeverage); double maxPct = s.Instrument.MaxStopLossPct > 0 ? s.Instrument.MaxStopLossPct : 50; double minPct = s.Instrument.MinStopLossPct; double pct = Math.Clamp(Math.Min(maxPct, Math.Max(minPct + 1, 5.0 * _cfg.MaxLossPerBasketPct)), minPct + 0.5, maxPct); @@ -594,9 +617,10 @@ public sealed class BasketExecutor return new OrderRequest(Guid.NewGuid().ToString("D"), s.Instrument.Id, s.Symbol, isBuy, Math.Round(units, 2), leverage, stop, null, reason.Length > 160 ? reason[..160] : reason); } - private int ChooseLeverage(Instrument instrument) + /// The largest leverage the venue allows on the instrument that does not exceed the configured one. + public static int ChooseLeverage(Instrument instrument, int wanted) { - int wanted = _cfg.OrderLeverage; + ArgumentNullException.ThrowIfNull(instrument); if (instrument.AllowedLeverages.Length == 0) { return wanted; diff --git a/Encelado/src/Encelado.Core/Baskets/BasketStrategyConfig.cs b/Encelado/src/Encelado.Core/Baskets/BasketStrategyConfig.cs index e5518f7..28af572 100644 --- a/Encelado/src/Encelado.Core/Baskets/BasketStrategyConfig.cs +++ b/Encelado/src/Encelado.Core/Baskets/BasketStrategyConfig.cs @@ -51,6 +51,44 @@ public static class BasketPresets } } +/// +/// The margin rules of §10 of the 5.0 plan (strategy.jsonrisk). Every +/// number is a share of equity; the buffer is what the cash available must exceed the +/// margin by before an order goes out. +/// +public sealed class RiskOptions +{ + /// Margin locked by every position after the new basket, at most this share of equity. + public double MaxMarginUsePct { get; set; } = 40; + + /// Margin of the two legs of one basket, at most this share of equity. + public double MaxMarginPerBasketPct { get; set; } = 12; + + /// Cash available must be at least (marginA + marginB) × (1 + buffer) before A, and marginB × (1 + buffer) before B. + public double MarginBufferPct { get; set; } = 25; + + /// Whether the kill-switch also closes positions the bot did not open. + public bool CloseForeignOnKill { get; set; } + + /// Below this equity / used-margin ratio no new entry is allowed. + public double MarginCallBlockRatio { get; set; } = 1.5; + + /// Below this equity / used-margin ratio the basket with the worst P&L is closed. + public double MarginCallCloseRatio { get; set; } = 1.2; + + public double BufferFactor => 1 + (MarginBufferPct / 100.0); + + public void Validate() + { + if (MaxMarginUsePct is <= 0 or > 100) { throw new InvalidOperationException("strategy.json: 'risk.maxMarginUsePct' deve essere fra 0 e 100."); } + if (MaxMarginPerBasketPct is <= 0 or > 100) { throw new InvalidOperationException("strategy.json: 'risk.maxMarginPerBasketPct' deve essere fra 0 e 100."); } + if (MaxMarginPerBasketPct > MaxMarginUsePct) { throw new InvalidOperationException("strategy.json: 'risk.maxMarginPerBasketPct' non può superare 'risk.maxMarginUsePct'."); } + if (MarginBufferPct is < 0 or > 200) { throw new InvalidOperationException("strategy.json: 'risk.marginBufferPct' deve essere fra 0 e 200."); } + if (MarginCallCloseRatio is < 1 or > 10) { throw new InvalidOperationException("strategy.json: 'risk.marginCallCloseRatio' deve essere fra 1 e 10."); } + if (MarginCallBlockRatio < MarginCallCloseRatio || MarginCallBlockRatio > 10) { throw new InvalidOperationException("strategy.json: 'risk.marginCallBlockRatio' deve essere fra marginCallCloseRatio e 10."); } + } +} + /// One basket: two pairs. The synthetic cross and the leg signs are derived, never configured. public sealed class BasketDefinition { @@ -180,6 +218,9 @@ public sealed class BasketStrategyConfig public int ClockSkewMaxSeconds { get; set; } = 5; + /// The margin rules (§10 of the 5.0 plan). + public RiskOptions Risk { get; set; } = new(); + // ---- overrides of the preset (NaN / 0 = take the preset's value) ---- public double ZInOverride { get; set; } = double.NaN; @@ -259,6 +300,7 @@ public sealed class BasketStrategyConfig if (DailyLossPct is <= 0 or > 50) { throw Bad("dailyLossPct", "fra 0 e 50"); } if (LotMultiplier is < 1 or > 1.5) { throw Bad("lotMultiplier", "fra 1,0 e 1,5"); } if (Baskets.Count == 0) { throw Bad("baskets", "almeno un basket"); } + Risk.Validate(); foreach (BasketDefinition b in Baskets) { @@ -303,6 +345,7 @@ public sealed class BasketStrategyConfig sb.Append(CultureInfo.InvariantCulture, $"zOut={ZOut};dIn={DIn};anchor={AnchorBars};grid={GridStepZ};lotMul={LotMultiplier};maxLoss={MaxLossPerBasketPct};rhoBreak={RhoBreak}x{RhoBreakBars};hold={MaxHoldingBars};"); sb.Append(CultureInfo.InvariantCulture, $"cost={CostMultiple};spreadMed={SpreadMedianMultiple};slip={SlippagePipsPerLeg};on={OvernightPipsPerDay};blackout={BlackoutBeforeMin}/{BlackoutAfterMin};fri={FridayCutoffUtcHour};open={OpenDelayMinutes};"); sb.Append(CultureInfo.InvariantCulture, $"lev={MaxEffectiveLeverage}/{OrderLeverage};vol={VolScaleMin}-{VolScaleMax}/{VolAverageDays};pMin={MlMinProbability};eqStop={EquityStopPct};daily={DailyLossPct};"); + sb.Append(CultureInfo.InvariantCulture, $"margin={Risk.MaxMarginUsePct}/{Risk.MaxMarginPerBasketPct}/{Risk.MarginBufferPct};mcall={Risk.MarginCallBlockRatio}/{Risk.MarginCallCloseRatio};"); foreach (BasketDefinition b in Baskets) { sb.Append(CultureInfo.InvariantCulture, $"{b.A}/{b.B}={(b.Enabled ? 1 : 0)};"); @@ -404,6 +447,7 @@ public sealed class BasketStrategyConfig case "maxadds": c.MaxAddsOverride = Int(p); break; case "zstop": c.ZStopOverride = Num(p); break; case "baskets": c.Baskets = ReadBaskets(p.Value, warnings); break; + case "risk": c.Risk = ReadRisk(p.Value, warnings); break; default: warnings.Add($"chiave sconosciuta '{p.Name}' in strategy.json"); break; } } @@ -456,6 +500,37 @@ public sealed class BasketStrategyConfig return list; } + private static RiskOptions ReadRisk(JsonElement e, List warnings) + { + RiskOptions r = new(); + if (e.ValueKind != JsonValueKind.Object) + { + warnings.Add("'risk' deve essere un oggetto: uso i valori di fabbrica"); + return r; + } + + foreach (JsonProperty p in e.EnumerateObject()) + { + if (p.Name.StartsWith('_')) + { + continue; + } + + switch (p.Name.ToLowerInvariant()) + { + case "maxmarginusepct": r.MaxMarginUsePct = Num(p); break; + case "maxmarginperbasketpct": r.MaxMarginPerBasketPct = Num(p); break; + case "marginbufferpct": r.MarginBufferPct = Num(p); break; + case "closeforeignonkill": r.CloseForeignOnKill = Bool(p); break; + case "margincallblockratio": r.MarginCallBlockRatio = Num(p); break; + case "margincallcloseratio": r.MarginCallCloseRatio = Num(p); break; + default: warnings.Add($"chiave sconosciuta 'risk.{p.Name}' in strategy.json"); break; + } + } + + return r; + } + private static List<(int, int)> ReadSessions(JsonElement e, List warnings) { List<(int, int)> list = []; @@ -569,12 +644,22 @@ public sealed class BasketStrategyConfig "volAverageDays": 30, "mlMinProbability": 0.55, - "_sicurezza": "equityStopPct: perdita dal picco di equity oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.", + "_sicurezza": "equityStopPct: perdita dal picco di equity (al netto dei movimenti di cassa) oltre la quale il bot chiude tutto e si blocca (reset manuale con motivazione). dailyLossPct: perdita giornaliera oltre la quale niente nuove entrate fino al giorno dopo.", "equityStopPct": 9, "dailyLossPct": 3, "legTimeoutSec": 5, "clockSkewMaxSeconds": 5, + "_risk": "Margine (5.0, §10). La size di un basket è il minimo fra la size a rischio e quella a margine. maxMarginUsePct: margine totale impegnato dopo l'apertura, in % dell'equity. maxMarginPerBasketPct: margine delle due gambe di un basket, in % dell'equity. marginBufferPct: il disponibile deve superare il margine richiesto di questa percentuale prima di inviare A e, ricontrollato, prima di inviare B (altrimenti A viene richiusa). closeForeignOnKill: il kill-switch chiude anche le posizioni non aperte dal bot. marginCallBlockRatio / marginCallCloseRatio: sotto equity/margine usato = 1,5 niente entrate; sotto 1,2 si chiude il basket con il P&L peggiore.", + "risk": { + "maxMarginUsePct": 40, + "maxMarginPerBasketPct": 12, + "marginBufferPct": 25, + "closeForeignOnKill": false, + "marginCallBlockRatio": 1.5, + "marginCallCloseRatio": 1.2 + }, + "_baskets": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.", "baskets": [ { "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" }, diff --git a/Encelado/src/Encelado.Core/Baskets/VolParitySizing.cs b/Encelado/src/Encelado.Core/Baskets/VolParitySizing.cs index 4816f22..ae7e1bb 100644 --- a/Encelado/src/Encelado.Core/Baskets/VolParitySizing.cs +++ b/Encelado/src/Encelado.Core/Baskets/VolParitySizing.cs @@ -13,14 +13,23 @@ public sealed record SizingResult( double EffectiveLeverage, string Reason) { - public static SizingResult Fail(string reason) => new(false, 0, 0, 0, 0, 0, 0, reason); + /// Which constraint decided the size: risk, margin, leverage, units; min_exposure when nothing fits. + public string Bound { get; init; } = "risk"; + + /// Margin the two legs will lock at the venue (notional / leverage per leg), in USD. + public double MarginUsd { get; init; } + + public static SizingResult Fail(string reason, string bound = "") => new(false, 0, 0, 0, 0, 0, 0, reason) { Bound = bound }; } /// /// Volatility-parity sizing (§4.3): leg B carries the same ATR-dollar risk as leg A, /// and leg A is sized so the loss at the basket stop equals RiskPerBasket% of -/// equity. Then the caps: effective leverage on the total notional, the venue's minimum -/// exposure and maximum units, and the optional meta-model scale. +/// equity. Then the caps: effective leverage on the total notional, the margin the two +/// legs would lock (§10 of the 5.0 plan: per basket, in total, and against the cash +/// available with a buffer), the venue's maximum units, the minimum exposure. The +/// size is the smallest the constraints allow, and the result says which one +/// decided. /// /// The loss at the stop is estimated as the average notional times the move of the /// synthetic cross from the entry z to the stop z: (z_stop − |z|) × σ_X, where @@ -50,7 +59,10 @@ public static class VolParitySizing double maxUnitsA, double maxUnitsB, double maxEffectiveLeverage, - double scale = 1.0) + double scale = 1.0, + double maxMarginUsd = double.PositiveInfinity, + int leverageA = 1, + int leverageB = 1) { if (equity <= 0) { @@ -85,6 +97,11 @@ public static class VolParitySizing return SizingResult.Fail(F($"il rischio consentito ({riskUsd:F2} USD) non copre i costi stimati ({costUsd:F2} USD)")); } + if (double.IsFinite(maxMarginUsd) && maxMarginUsd <= 0) + { + return SizingResult.Fail("nessun margine disponibile per un nuovo basket", "margin"); + } + // Average notional such that N·Δz·σ_X = budget. double moveAtStop = deltaZ * sigmaX; double averageNotional = budget / moveAtStop; @@ -103,47 +120,64 @@ public static class VolParitySizing double notionalA = unitsA * notionalPerUnitA; double notionalB = unitsB * notionalPerUnitB; double leverage = (notionalA + notionalB) / equity; + string bound = "risk"; if (leverage > maxEffectiveLeverage) { - double shrink = maxEffectiveLeverage / leverage; - unitsA *= shrink; - unitsB *= shrink; - notionalA *= shrink; - notionalB *= shrink; - leverage = maxEffectiveLeverage; + Shrink(maxEffectiveLeverage / leverage); + bound = "leverage"; + } + + // Margin: what the venue locks for the two legs at their order leverage. + double margin = Margin(notionalA, notionalB); + if (double.IsFinite(maxMarginUsd) && margin > maxMarginUsd) + { + Shrink(maxMarginUsd / margin); + bound = "margin"; } if (maxUnitsA > 0 && unitsA > maxUnitsA) { - double shrink = maxUnitsA / unitsA; - unitsA *= shrink; - unitsB *= shrink; - notionalA *= shrink; - notionalB *= shrink; + Shrink(maxUnitsA / unitsA); + bound = "units"; } if (maxUnitsB > 0 && unitsB > maxUnitsB) { - double shrink = maxUnitsB / unitsB; - unitsA *= shrink; - unitsB *= shrink; - notionalA *= shrink; - notionalB *= shrink; + Shrink(maxUnitsB / unitsB); + bound = "units"; } if (notionalA < minExposureUsd || notionalB < minExposureUsd) { - return SizingResult.Fail(F($"una gamba scende sotto l'esposizione minima del broker ({minExposureUsd:F0} USD): A {notionalA:F0}, B {notionalB:F0}; il rischio per basket è troppo piccolo per questo conto")); + string why = bound == "margin" + ? F($"il margine consentito ({maxMarginUsd:F0} USD) non copre l'esposizione minima del broker ({minExposureUsd:F0} USD) su entrambe le gambe: A {notionalA:F0}, B {notionalB:F0}") + : F($"una gamba scende sotto l'esposizione minima del broker ({minExposureUsd:F0} USD): A {notionalA:F0}, B {notionalB:F0}; il rischio per basket è troppo piccolo per questo conto"); + return SizingResult.Fail(why, "min_exposure"); } unitsA = Math.Round(unitsA, 2); unitsB = Math.Round(unitsB, 2); double lossAtStop = ((notionalA + notionalB) / 2) * moveAtStop + Math.Max(0, costUsd); leverage = (notionalA + notionalB) / equity; + margin = Margin(notionalA, notionalB); return new SizingResult(true, unitsA, unitsB, notionalA, notionalB, lossAtStop, leverage, - F($"rischio {riskUsd:F2} USD ({riskPerBasketPct:0.##} % × scala {scale:0.00}), Δz {deltaZ:0.00} × σ_X {sigmaX:0.0000} = {moveAtStop:0.0000}; A {unitsA:0.##} unità ({notionalA:F0} USD), B {unitsB:0.##} unità ({notionalB:F0} USD), leva effettiva {leverage:0.0}")); + F($"rischio {riskUsd:F2} USD ({riskPerBasketPct:0.##} % × scala {scale:0.00}), Δz {deltaZ:0.00} × σ_X {sigmaX:0.0000} = {moveAtStop:0.0000}; A {unitsA:0.##} unità ({notionalA:F0} USD), B {unitsB:0.##} unità ({notionalB:F0} USD), leva effettiva {leverage:0.0}, margine {margin:F0} USD, vincolo {bound}")) + { + Bound = bound, + MarginUsd = margin, + }; + + void Shrink(double factor) + { + unitsA *= factor; + unitsB *= factor; + notionalA *= factor; + notionalB *= factor; + } + + double Margin(double nA, double nB) => (nA / Math.Max(1, leverageA)) + (nB / Math.Max(1, leverageB)); } private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture); diff --git a/Encelado/tests/Encelado.Tests/MarginTests.cs b/Encelado/tests/Encelado.Tests/MarginTests.cs new file mode 100644 index 0000000..00c56a7 --- /dev/null +++ b/Encelado/tests/Encelado.Tests/MarginTests.cs @@ -0,0 +1,188 @@ +using Encelado.Core.Baskets; +using Encelado.Core.Broker; + +namespace Encelado.Tests; + +/// (y) The margin, not the risk, decides the size on a large account; (z) a basket the cash does not cover is refused. +public class MarginSizingTests +{ + /// Aggressive preset on 100 000 USD: 1 % risk over a small stop move asks for a notional the margin rules do not allow. + [Fact] + public void OnALargeAccountTheMarginCapDecidesTheSizeNotTheRisk() + { + // Equity 100 000, risk 1 % = 1 000 USD, entry z 1.5, stop z 4.0, σ_X 0.001 → move 0.0025 → 400 000 USD of average notional by risk. + SizingResult byRisk = VolParitySizing.Compute( + equity: 100_000, riskPerBasketPct: 1.0, entryZ: 1.5, zStop: 4.0, sigmaX: 0.001, + atrPipsA: 10, atrPipsB: 10, pipValueUsdPerUnitA: 0.0001, pipValueUsdPerUnitB: 0.0001, + priceA: 1.10, priceB: 0.90, usdPerQuoteA: 1, usdPerQuoteB: 1.11, + costUsd: 0, minExposureUsd: 1000, maxUnitsA: 0, maxUnitsB: 0, maxEffectiveLeverage: 10); + Assert.True(byRisk.Ok, byRisk.Reason); + Assert.Equal("risk", byRisk.Bound); + Assert.True(byRisk.NotionalUsdA + byRisk.NotionalUsdB > 200_000, "senza vincolo di margine il nozionale supera 200 000 USD"); + + // The same with the 5.0 margin rules: 12 % of equity = 12 000 USD of margin at leverage 10 → 120 000 USD of notional at most. + SizingResult byMargin = VolParitySizing.Compute( + 100_000, 1.0, 1.5, 4.0, 0.001, 10, 10, 0.0001, 0.0001, 1.10, 0.90, 1, 1.11, 0, 1000, 0, 0, 10, + scale: 1.0, maxMarginUsd: 12_000, leverageA: 10, leverageB: 10); + Assert.True(byMargin.Ok, byMargin.Reason); + Assert.Equal("margin", byMargin.Bound); + Assert.InRange(byMargin.MarginUsd, 11_900, 12_000.5); + Assert.InRange(byMargin.NotionalUsdA + byMargin.NotionalUsdB, 119_000, 120_005); + Assert.True(byMargin.LossAtStopUsd < byRisk.LossAtStopUsd, "con meno nozionale la perdita allo stop è minore del budget di rischio"); + } + + [Fact] + public void TheMarginRoomIsTheSmallestOfPerBasketTotalAndCash() + { + BasketStrategyConfig cfg = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _); + BasketDecider decider = new(cfg); + BasketContext ctx = Context(equity: 100_000, available: 100_000, usedMargin: 0); + + Assert.Equal(12_000, decider.MarginRoom(ctx), 6); // per basket: 12 % + Assert.Equal(10_000, decider.MarginRoom(ctx with { UsedMargin = 30_000 }), 6); // total: 40 % − 30 000 + Assert.Equal(8_000, decider.MarginRoom(ctx with { AvailableMargin = 10_000 }), 6); // cash / 1.25 + Assert.Equal(0, decider.MarginRoom(ctx with { UsedMargin = 45_000 }), 6); // over the total cap: nothing + Assert.Equal(double.PositiveInfinity, decider.MarginRoom(ctx with { AvailableMargin = double.NaN })); // account unknown: no cap + } + + /// (z) The second basket: the cash left after the first does not cover 1.25 × its margin down to the minimum exposure. + [Fact] + public void ABasketTheCashDoesNotCoverIsRefusedAsMinExposure() + { + BasketStrategyConfig cfg = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _); + BasketDecider decider = new(cfg); + BasketContext ctx = Context(equity: 100_000, available: 200, usedMargin: 39_000); + double room = decider.MarginRoom(ctx); + Assert.Equal(160, room, 6); + + SizingResult r = VolParitySizing.Compute( + 100_000, 1.0, 1.5, 4.0, 0.001, 10, 10, 0.0001, 0.0001, 1.10, 0.90, 1, 1.11, 0, 1000, 0, 0, 10, + 1.0, room, 10, 10); + Assert.False(r.Ok); + Assert.Equal("min_exposure", r.Bound); + Assert.Contains("margine", r.Reason, StringComparison.Ordinal); + } + + [Fact] + public void TheRiskSectionIsReadFromStrategyJsonAndValidated() + { + BasketStrategyConfig cfg = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out List warnings); + Assert.DoesNotContain(warnings, static w => w.Contains("risk", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(40, cfg.Risk.MaxMarginUsePct); + Assert.Equal(12, cfg.Risk.MaxMarginPerBasketPct); + Assert.Equal(25, cfg.Risk.MarginBufferPct); + Assert.False(cfg.Risk.CloseForeignOnKill); + Assert.Equal(1.25, cfg.Risk.BufferFactor, 9); + + BasketStrategyConfig bad = BasketStrategyConfig.ParseText("""{"risk":{"maxMarginPerBasketPct":50,"maxMarginUsePct":40}}""", out _); + Assert.Throws(bad.Validate); + Assert.Contains("margin=40/12/25", cfg.Canonical(), StringComparison.Ordinal); + } + + private static BasketContext Context(double equity, double available, double usedMargin) + { + Instrument eurusd = new(1, "EURUSD", "EUR/USD", "Forex", 0.0001, 5, 0.01, 2_000_000, 1000, [1, 2, 5, 10], true, true, 0, 50, ""); + Instrument usdchf = new(6, "USDCHF", "USD/CHF", "Forex", 0.0001, 5, 0.01, 2_000_000, 1000, [1, 2, 5, 10], true, true, 0, 50, ""); + return new BasketContext + { + TimeUtc = new DateTime(2026, 9, 23, 10, 0, 0, DateTimeKind.Utc), + BasketId = "EURUSD/USDCHF", + Name = "EURUSD/USDCHF", + Cross = SyntheticCross.Derive("EURUSD", "USDCHF"), + A = new SymbolSeries(eurusd, TimeSpan.FromMinutes(15)), + B = new SymbolSeries(usdchf, TimeSpan.FromMinutes(15)), + Equity = equity, + AvailableMargin = available, + UsedMargin = usedMargin, + LeverageA = 10, + LeverageB = 10, + PipValueUsdA = 0.0001, + PipValueUsdB = 0.0001 / 0.9, + UsdPerQuoteA = 1, + UsdPerQuoteB = 1 / 0.9, + Mid = static s => s == "EURUSD" ? 1.10005 : s == "USDCHF" ? 0.90005 : null, + }; + } +} + +/// (z, execution side) Leg A is on the account and the cash no longer covers leg B: B is not sent, A is closed. +public class MarginBeforeLegBTests +{ + private sealed class PoorVenue : IBroker + { + private readonly SlowVenue _inner = new(); + public double Available { get; set; } = 10_000; + public List Events => _inner.Events; + public Dictionary Positions => _inner.Positions; + public HashSet Admitted => _inner.Admitted; + + public BrokerEnvironment Environment => _inner.Environment; + + public string Name => "poor"; + + public bool SupportsTrading => true; + + public TimeSpan ClockSkew => TimeSpan.Zero; + + public Task> GetInstrumentsAsync(IReadOnlyList symbols, CancellationToken ct) => _inner.GetInstrumentsAsync(symbols, ct); + + public Task> GetQuotesAsync(IReadOnlyList instrumentIds, CancellationToken ct) => _inner.GetQuotesAsync(instrumentIds, ct); + + public Task> GetCandlesAsync(long instrumentId, TimeSpan interval, int count, CancellationToken ct) => _inner.GetCandlesAsync(instrumentId, interval, count, ct); + + public Task GetAccountAsync(CancellationToken ct) => Task.FromResult(new AccountSnapshot(DateTime.UtcNow, "USD", 10_000, 10_000, Available, 10_000 - Available, 0)); + + public Task> GetPositionsAsync(CancellationToken ct) => _inner.GetPositionsAsync(ct); + + public Task OpenAsync(OrderRequest request, CancellationToken ct) => _inner.OpenAsync(request, ct); + + public Task LookupOrderAsync(string clientRef, CancellationToken ct) => _inner.LookupOrderAsync(clientRef, ct); + + public Task LookupOrderByIdAsync(long orderId, CancellationToken ct) => _inner.LookupOrderByIdAsync(orderId, ct); + + public Task CancelOrderAsync(long orderId, CancellationToken ct) => _inner.CancelOrderAsync(orderId, ct); + + public Task CloseAsync(long positionId, long instrumentId, CancellationToken ct) => _inner.CloseAsync(positionId, instrumentId, ct); + + public Task UpdateStopsAsync(long positionId, double? stopLoss, double? takeProfit, CancellationToken ct) => _inner.UpdateStopsAsync(positionId, stopLoss, takeProfit, ct); + + public Task GetCostAsync(OrderRequest request, CancellationToken ct) => _inner.GetCostAsync(request, ct); + + public Task> GetClosedTradesAsync(DateTime fromUtc, CancellationToken ct) => _inner.GetClosedTradesAsync(fromUtc, ct); + + public ValueTask DisposeAsync() => _inner.DisposeAsync(); + } + + [Fact] + public async Task WhenTheCashLeftAfterLegADoesNotCoverLegBTheEntryIsUnwound() + { + (BasketContext ctx, BasketDecision d, BasketStrategyConfig cfg) = ExecutionScenery.Entry(); + PoorVenue venue = new() { Available = 100 }; + venue.Admitted.Add(100); + venue.Admitted.Add(101); + BasketExecutor executor = new(venue, cfg, ctx.Mid, static _ => { }, new OrderTracker(string.Empty), "Demo"); + + EntryOutcome outcome = await executor.OpenAsync(ctx, d, cfg.Effective(), "B1", CancellationToken.None); + + Assert.False(outcome.Ok); + Assert.True(outcome.Unwound, outcome.Error); + Assert.Contains("margine insufficiente", outcome.Error, StringComparison.Ordinal); + Assert.Empty(venue.Positions); + Assert.DoesNotContain(venue.Events, static e => e.StartsWith("open USDCHF", StringComparison.Ordinal)); + } + + [Fact] + public async Task WhenTheCashCoversLegBTheBasketOpens() + { + (BasketContext ctx, BasketDecision d, BasketStrategyConfig cfg) = ExecutionScenery.Entry(); + PoorVenue venue = new() { Available = 5_000 }; + venue.Admitted.Add(100); + venue.Admitted.Add(101); + BasketExecutor executor = new(venue, cfg, ctx.Mid, static _ => { }, new OrderTracker(string.Empty), "Demo"); + + EntryOutcome outcome = await executor.OpenAsync(ctx, d, cfg.Effective(), "B1", CancellationToken.None); + + Assert.True(outcome.Ok, outcome.Error); + Assert.Equal(2, venue.Positions.Count); + } +}