5.0 Fase 2: il margine come vincolo di primo livello del sizing

Un solo basket sizato a rischio aveva impegnato il 100 % del margine del conto
(16/9). Ora strategy.json ha la sezione risk (12 % dell'equity per basket, 40 %
in totale, disponibile con buffer del 25 %), la size è il minimo fra rischio e
margine e il ledger scrive quale vincolo ha deciso; il conto viene riletto
prima della gamba B e, se non copre, A viene richiusa; i segnali della stessa
barra vanno per |z| decrescente con rilettura del conto; sotto equity/margine
1,5 niente entrate, sotto 1,2 si chiude il basket peggiore. Il backtest applica
gli stessi limiti. Test (y), (z).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:01:58 +02:00
co-authored by Claude Fable 5.1
parent 13d64d1830
commit 327d3c6981
17 changed files with 574 additions and 49 deletions
+11
View File
@@ -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.
+11 -1
View File
@@ -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" },
+5 -4
View File
@@ -16,7 +16,7 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15
| `cross` | testo | cross sintetico (`EURCHF`) |
| `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` |
| `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` |
| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; dalla 5.0 anche le righe di evento (senza feature, solo `ts`, `run_id`, `evento`, `basket_id`, campi propri e `motivazione`): `pending` (gamba senza esito, con `leg`, `client_ref`, `order_id`), `pending_risolto` (con `esito`, `fonte`, `position_id`), `orfana_adottata` e `orfana_chiusa` (con `position_id`, `strumento`, `pnl`, `exit_reason`), `movimento_di_cassa` (con `importo`, `saldo_prima`, `saldo_dopo`, `chiusure_nel_frattempo`, `cassa_cumulata`), `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)
+6
View File
@@ -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 |
+7 -6
View File
@@ -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 2margine**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 3kill-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
+1 -1
View File
@@ -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.
@@ -123,6 +123,7 @@ public sealed partial class BasketEngine
List<long> 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
}
}
/// <summary>
/// The guard against a margin call (§10): under <c>marginCallBlockRatio</c> of equity
/// over used margin no new entry; under <c>marginCallCloseRatio</c> the open basket
/// with the worst result is closed (<c>margin_guard</c>).
/// </summary>
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");
}
}
/// <summary>Whether the engine and the account have disagreed longer than the grace period.</summary>
private bool IsUnreconciled => _unreconciledSince is { } since && DateTime.UtcNow - since > UnreconciledGrace;
@@ -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);
}
}
/// <summary>
/// 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 <c>UsedMargin</c> and out of <c>Available</c>, so
/// a basket that no longer fits is refused here, not by the venue.
/// </summary>
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),
@@ -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");
@@ -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));
}
/// <summary>Cash a new order can use: equity minus the margin locked.</summary>
public double Available => GetAccountAsync(CancellationToken.None).Result.Available;
public double UsedMargin => GetAccountAsync(CancellationToken.None).Result.UsedMargin;
public double Equity
{
get
@@ -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<Slot> slots, bool isBarClose)
private static BasketContext Context(Slot slot, DateTime t, BacktestBroker broker, double equity, double peak, List<Slot> 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,
@@ -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;
@@ -30,6 +30,17 @@ public sealed record BasketContext
public bool DailyLossHit { get; init; }
/// <summary>Cash the venue lets a new order use (NaN when the engine does not know: no margin cap is applied).</summary>
public double AvailableMargin { get; init; } = double.NaN;
/// <summary>Margin locked by every position on the account right now.</summary>
public double UsedMargin { get; init; }
/// <summary>The leverage each leg's order will carry (the venue locks notional / leverage).</summary>
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);
}
/// <summary>
/// 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).
/// </summary>
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,
@@ -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)
/// <summary>The largest leverage the venue allows on the instrument that does not exceed the configured one.</summary>
public static int ChooseLeverage(Instrument instrument, int wanted)
{
int wanted = _cfg.OrderLeverage;
ArgumentNullException.ThrowIfNull(instrument);
if (instrument.AllowedLeverages.Length == 0)
{
return wanted;
@@ -51,6 +51,44 @@ public static class BasketPresets
}
}
/// <summary>
/// The margin rules of §10 of the 5.0 plan (<c>strategy.json</c> → <c>risk</c>). Every
/// number is a share of equity; the buffer is what the cash available must exceed the
/// margin by before an order goes out.
/// </summary>
public sealed class RiskOptions
{
/// <summary>Margin locked by every position after the new basket, at most this share of equity.</summary>
public double MaxMarginUsePct { get; set; } = 40;
/// <summary>Margin of the two legs of one basket, at most this share of equity.</summary>
public double MaxMarginPerBasketPct { get; set; } = 12;
/// <summary>Cash available must be at least (marginA + marginB) × (1 + buffer) before A, and marginB × (1 + buffer) before B.</summary>
public double MarginBufferPct { get; set; } = 25;
/// <summary>Whether the kill-switch also closes positions the bot did not open.</summary>
public bool CloseForeignOnKill { get; set; }
/// <summary>Below this equity / used-margin ratio no new entry is allowed.</summary>
public double MarginCallBlockRatio { get; set; } = 1.5;
/// <summary>Below this equity / used-margin ratio the basket with the worst P&amp;L is closed.</summary>
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."); }
}
}
/// <summary>One basket: two pairs. The synthetic cross and the leg signs are derived, never configured.</summary>
public sealed class BasketDefinition
{
@@ -180,6 +218,9 @@ public sealed class BasketStrategyConfig
public int ClockSkewMaxSeconds { get; set; } = 5;
/// <summary>The margin rules (§10 of the 5.0 plan).</summary>
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<string> 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<string> 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" },
@@ -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);
/// <summary>Which constraint decided the size: <c>risk</c>, <c>margin</c>, <c>leverage</c>, <c>units</c>; <c>min_exposure</c> when nothing fits.</summary>
public string Bound { get; init; } = "risk";
/// <summary>Margin the two legs will lock at the venue (notional / leverage per leg), in USD.</summary>
public double MarginUsd { get; init; }
public static SizingResult Fail(string reason, string bound = "") => new(false, 0, 0, 0, 0, 0, 0, reason) { Bound = bound };
}
/// <summary>
/// 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 <c>RiskPerBasket%</c> 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 <b>smallest</b> the constraints allow, and the result says which one
/// decided.
/// <para>
/// 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: <c>(z_stop |z|) × σ_X</c>, 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);
@@ -0,0 +1,188 @@
using Encelado.Core.Baskets;
using Encelado.Core.Broker;
namespace Encelado.Tests;
/// <summary>(y) The margin, not the risk, decides the size on a large account; (z) a basket the cash does not cover is refused.</summary>
public class MarginSizingTests
{
/// <summary>Aggressive preset on 100 000 USD: 1 % risk over a small stop move asks for a notional the margin rules do not allow.</summary>
[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
}
/// <summary>(z) The second basket: the cash left after the first does not cover 1.25 × its margin down to the minimum exposure.</summary>
[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<string> 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<InvalidOperationException>(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,
};
}
}
/// <summary>(z, execution side) Leg A is on the account and the cash no longer covers leg B: B is not sent, A is closed.</summary>
public class MarginBeforeLegBTests
{
private sealed class PoorVenue : IBroker
{
private readonly SlowVenue _inner = new();
public double Available { get; set; } = 10_000;
public List<string> Events => _inner.Events;
public Dictionary<long, BrokerPosition> Positions => _inner.Positions;
public HashSet<long> Admitted => _inner.Admitted;
public BrokerEnvironment Environment => _inner.Environment;
public string Name => "poor";
public bool SupportsTrading => true;
public TimeSpan ClockSkew => TimeSpan.Zero;
public Task<IReadOnlyList<Instrument>> GetInstrumentsAsync(IReadOnlyList<string> symbols, CancellationToken ct) => _inner.GetInstrumentsAsync(symbols, ct);
public Task<IReadOnlyList<QuoteSnapshot>> GetQuotesAsync(IReadOnlyList<long> instrumentIds, CancellationToken ct) => _inner.GetQuotesAsync(instrumentIds, ct);
public Task<IReadOnlyList<Core.Baskets.Data.BidAskBar>> GetCandlesAsync(long instrumentId, TimeSpan interval, int count, CancellationToken ct) => _inner.GetCandlesAsync(instrumentId, interval, count, ct);
public Task<AccountSnapshot> GetAccountAsync(CancellationToken ct) => Task.FromResult(new AccountSnapshot(DateTime.UtcNow, "USD", 10_000, 10_000, Available, 10_000 - Available, 0));
public Task<IReadOnlyList<BrokerPosition>> GetPositionsAsync(CancellationToken ct) => _inner.GetPositionsAsync(ct);
public Task<OrderOutcome> OpenAsync(OrderRequest request, CancellationToken ct) => _inner.OpenAsync(request, ct);
public Task<OrderOutcome?> LookupOrderAsync(string clientRef, CancellationToken ct) => _inner.LookupOrderAsync(clientRef, ct);
public Task<OrderOutcome?> LookupOrderByIdAsync(long orderId, CancellationToken ct) => _inner.LookupOrderByIdAsync(orderId, ct);
public Task<bool> CancelOrderAsync(long orderId, CancellationToken ct) => _inner.CancelOrderAsync(orderId, ct);
public Task<CloseOutcome> CloseAsync(long positionId, long instrumentId, CancellationToken ct) => _inner.CloseAsync(positionId, instrumentId, ct);
public Task<bool> UpdateStopsAsync(long positionId, double? stopLoss, double? takeProfit, CancellationToken ct) => _inner.UpdateStopsAsync(positionId, stopLoss, takeProfit, ct);
public Task<CostEstimate?> GetCostAsync(OrderRequest request, CancellationToken ct) => _inner.GetCostAsync(request, ct);
public Task<IReadOnlyList<ClosedTrade>> 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);
}
}