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:
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto.
|
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
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -63,12 +63,22 @@
|
|||||||
"volAverageDays": 30,
|
"volAverageDays": 30,
|
||||||
"mlMinProbability": 0.55,
|
"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,
|
"equityStopPct": 9,
|
||||||
"dailyLossPct": 3,
|
"dailyLossPct": 3,
|
||||||
"legTimeoutSec": 5,
|
"legTimeoutSec": 5,
|
||||||
"clockSkewMaxSeconds": 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": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.",
|
||||||
"baskets": [
|
"baskets": [
|
||||||
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
|
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15
|
|||||||
| `cross` | testo | cross sintetico (`EURCHF`) |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` |
|
||||||
| `buy_cross` | bool | verso deciso (compra il cross = compra entrambe le gambe nei cinque basket) |
|
| `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) |
|
| `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 |
|
| `p_ML`, `mlActive` | numero, bool | probabilità del meta-modello e se è gate o ombra |
|
||||||
| `equity`, `openBaskets` | numero | equity e basket aperti al momento |
|
| `equity`, `openBaskets` | numero | equity e basket aperti al momento |
|
||||||
| `priceA`, `priceB`, `pipsOpen`, `pnlOpenUsd`, `barsHeld` | numero | stato della posizione (se aperta) |
|
| `priceA`, `priceB`, `pipsOpen`, `pnlOpenUsd`, `barsHeld` | numero | stato della posizione (se aperta) |
|
||||||
| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage` | numero | sizing (solo su `Enter`) |
|
| `unitsA`, `unitsB`, `notionalUsd`, `lossAtStopUsd`, `effectiveLeverage`, `marginUsd` | numero | sizing (solo su `Enter`); `marginUsd` = margine previsto delle due gambe (5.0) |
|
||||||
| `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` |
|
| `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 |
|
| `motivazione` | testo | la frase, in italiano, con i numeri |
|
||||||
|
|
||||||
## `data/ledger/baskets.csv`
|
## `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
|
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)
|
## `data/ledger/orders.jsonl` (dalla 5.0)
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Cost gate | TP ≥ 3 × costo; spread ≤ 2 × mediana 24 h | `strategy.json` → `costMultiple`, `spreadMedianMultiple` | operatore |
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
# Stato del lavoro
|
# 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
|
## 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)
|
## 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 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.
|
- **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
|
## 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).
|
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. **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. 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. Riaccendere il Demo solo dopo la Fase 3, per 24 ore di verifica: contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti.
|
4. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`.
|
||||||
|
|
||||||
## Problemi aperti
|
## Problemi aperti
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
**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.
|
**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))];
|
List<long> vanished = [.. _lastPositionIds.Where(id => !onVenue.Contains(id))];
|
||||||
_lastPositionIds = onVenue;
|
_lastPositionIds = onVenue;
|
||||||
await RefreshAccountAsync(await ClosedNetAsync(vanished, ct).ConfigureAwait(false), ct).ConfigureAwait(false);
|
await RefreshAccountAsync(await ClosedNetAsync(vanished, ct).ConfigureAwait(false), ct).ConfigureAwait(false);
|
||||||
|
await MarginGuardAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// 1. The baskets the engine holds.
|
// 1. The baskets the engine holds.
|
||||||
foreach (BasketSlot slot in _slots)
|
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>
|
/// <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;
|
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)
|
foreach (BasketSlot slot in _slots)
|
||||||
{
|
{
|
||||||
if (!slot.Enabled)
|
if (!slot.Enabled)
|
||||||
@@ -654,7 +658,7 @@ public sealed partial class BasketEngine : IEngine
|
|||||||
if (bucket != slot.LastBarBucket)
|
if (bucket != slot.LastBarBucket)
|
||||||
{
|
{
|
||||||
slot.LastBarBucket = bucket;
|
slot.LastBarBucket = bucket;
|
||||||
await EvaluateAsync(slot, now, isBarClose: true, ct).ConfigureAwait(false);
|
await EvaluateAsync(slot, now, isBarClose: true, ct, entries).ConfigureAwait(false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,6 +668,49 @@ public sealed partial class BasketEngine : IEngine
|
|||||||
await EvaluateAsync(slot, now, isBarClose: false, ct).ConfigureAwait(false);
|
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)
|
private void OnBarClosed(SymbolSeries s, in BidAskBar bar)
|
||||||
@@ -719,7 +766,7 @@ public sealed partial class BasketEngine : IEngine
|
|||||||
// Decisions
|
// 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())
|
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)
|
if (isBarClose)
|
||||||
{
|
{
|
||||||
Log.Info($"[{slot.Name}] {slot.Intent}");
|
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;
|
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()),
|
OpenBaskets = _slots.Count(static s => s.Position is not null || s.State == BasketState.Entering || s.State.IsPending()),
|
||||||
SameCrossOpen = sameCrossOpen,
|
SameCrossOpen = sameCrossOpen,
|
||||||
DailyLossHit = dailyLossHit,
|
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,
|
EquityStopped = _equityStopped,
|
||||||
KillSwitched = _killSwitched,
|
KillSwitched = _killSwitched,
|
||||||
EntriesBlockedReason = _entriesBlocked ?? StaleQuoteReason(slot, now),
|
EntriesBlockedReason = _entriesBlocked ?? StaleQuoteReason(slot, now),
|
||||||
|
|||||||
@@ -442,6 +442,8 @@ public sealed class Ledger : IDisposable
|
|||||||
Num(w, "notionalUsd", s.NotionalUsdA + s.NotionalUsdB);
|
Num(w, "notionalUsd", s.NotionalUsdA + s.NotionalUsdB);
|
||||||
Num(w, "lossAtStopUsd", s.LossAtStopUsd);
|
Num(w, "lossAtStopUsd", s.LossAtStopUsd);
|
||||||
Num(w, "effectiveLeverage", s.EffectiveLeverage);
|
Num(w, "effectiveLeverage", s.EffectiveLeverage);
|
||||||
|
Num(w, "marginUsd", s.MarginUsd);
|
||||||
|
w.WriteString("sizing_bound", s.Bound);
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteStartArray("reasonCodes");
|
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));
|
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
|
public double Equity
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ public static class BasketBacktest
|
|||||||
p.BarsHeld++;
|
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,
|
DailyLossHit = dailyLoss,
|
||||||
EquityStopped = equityStopped,
|
EquityStopped = equityStopped,
|
||||||
@@ -368,7 +368,7 @@ public static class BasketBacktest
|
|||||||
{
|
{
|
||||||
if (slot.Position is { } p && slot.A.HasQuote && slot.B.HasQuote)
|
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();
|
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));
|
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;
|
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));
|
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);
|
int open = slots.Count(static o => o.Position is not null);
|
||||||
@@ -469,6 +469,10 @@ public static class BasketBacktest
|
|||||||
PeakEquity = peak,
|
PeakEquity = peak,
|
||||||
OpenBaskets = open,
|
OpenBaskets = open,
|
||||||
SameCrossOpen = sameCrossOpen,
|
SameCrossOpen = sameCrossOpen,
|
||||||
|
AvailableMargin = broker.Available,
|
||||||
|
UsedMargin = broker.UsedMargin,
|
||||||
|
LeverageA = BasketExecutor.ChooseLeverage(slot.A.Instrument, leverage),
|
||||||
|
LeverageB = BasketExecutor.ChooseLeverage(slot.B.Instrument, leverage),
|
||||||
IsBarClose = isBarClose,
|
IsBarClose = isBarClose,
|
||||||
PipValueUsdA = double.IsNaN(pipA) ? 0 : pipA,
|
PipValueUsdA = double.IsNaN(pipA) ? 0 : pipA,
|
||||||
PipValueUsdB = double.IsNaN(pipB) ? 0 : pipB,
|
PipValueUsdB = double.IsNaN(pipB) ? 0 : pipB,
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ public static class BasketTrials
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(c);
|
ArgumentNullException.ThrowIfNull(c);
|
||||||
BasketStrategyConfig copy = BasketStrategyConfig.ParseText(BasketStrategyConfig.DefaultJson, out _);
|
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.Preset = c.Preset;
|
||||||
copy.SignalMode = c.SignalMode;
|
copy.SignalMode = c.SignalMode;
|
||||||
copy.ExitMode = c.ExitMode;
|
copy.ExitMode = c.ExitMode;
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ public sealed record BasketContext
|
|||||||
|
|
||||||
public bool DailyLossHit { get; init; }
|
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 EquityStopped { get; init; }
|
||||||
|
|
||||||
public bool KillSwitched { 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.OpenBaskets >= _preset.MaxBaskets) { codes.Add("max_baskets"); }
|
||||||
if (ctx.SameCrossOpen && _cfg.SameCrossPolicy == SameCrossPolicy.Exclusive) { codes.Add("same_cross"); }
|
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).
|
// Meta-model (8).
|
||||||
if (ctx.MlActive && double.IsFinite(ctx.PMl) && ctx.PMl < _cfg.MlMinProbability) { codes.Add("ml_gate"); }
|
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);
|
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 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
|
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(
|
SizingResult sizing = VolParitySizing.Compute(
|
||||||
ctx.Equity, _preset.RiskPerBasketPct, e.Z, _preset.ZStop, e.SigmaX, e.AtrPipsA, e.AtrPipsB,
|
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,
|
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,
|
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)
|
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.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,
|
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,
|
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)
|
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)
|
if (_cfg.InvertSignal)
|
||||||
@@ -558,6 +579,25 @@ public sealed class BasketDecider
|
|||||||
return new BasketDecision(DecisionKind.Enter, buyCross, sizing, ["enter"], why, withCost, cost);
|
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) =>
|
private double TpPips(BasketEvaluation e) =>
|
||||||
_cfg.TpMode == TpMode.AtrMultiple && double.IsFinite(e.AtrPipsA) ? Math.Max(1, _cfg.TpAtrMultiple * e.AtrPipsA) : _preset.TpPips;
|
_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",
|
"session" => "fuori dalle sessioni consentite",
|
||||||
"max_baskets" => F($"{ctx.OpenBaskets} basket aperti su {_preset.MaxBaskets}"),
|
"max_baskets" => F($"{ctx.OpenBaskets} basket aperti su {_preset.MaxBaskets}"),
|
||||||
"same_cross" => $"un altro basket sullo stesso cross sintetico {ctx.Cross.Symbol} è aperto",
|
"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}"),
|
"ml_gate" => F($"p_ML {ctx.PMl:0.00} sotto {_cfg.MlMinProbability:0.00}"),
|
||||||
"cost_gate" => cost.Reason,
|
"cost_gate" => cost.Reason,
|
||||||
_ => c,
|
_ => 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.##}"));
|
_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);
|
OrderRequest reqB = Request(ctx.B, buyB, unitsB, quoteB, plan.Motivazione);
|
||||||
plan.ClientRefB = reqB.ClientRef;
|
plan.ClientRefB = reqB.ClientRef;
|
||||||
TrackedOrder trackB = Track(reqB, ctx.Name, plan.BasketId, OrderLeg.B, quoteB, plan.Motivazione);
|
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
|
// 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
|
// 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.
|
// 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 maxPct = s.Instrument.MaxStopLossPct > 0 ? s.Instrument.MaxStopLossPct : 50;
|
||||||
double minPct = s.Instrument.MinStopLossPct;
|
double minPct = s.Instrument.MinStopLossPct;
|
||||||
double pct = Math.Clamp(Math.Min(maxPct, Math.Max(minPct + 1, 5.0 * _cfg.MaxLossPerBasketPct)), minPct + 0.5, maxPct);
|
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);
|
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)
|
if (instrument.AllowedLeverages.Length == 0)
|
||||||
{
|
{
|
||||||
return wanted;
|
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&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>
|
/// <summary>One basket: two pairs. The synthetic cross and the leg signs are derived, never configured.</summary>
|
||||||
public sealed class BasketDefinition
|
public sealed class BasketDefinition
|
||||||
{
|
{
|
||||||
@@ -180,6 +218,9 @@ public sealed class BasketStrategyConfig
|
|||||||
|
|
||||||
public int ClockSkewMaxSeconds { get; set; } = 5;
|
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) ----
|
// ---- overrides of the preset (NaN / 0 = take the preset's value) ----
|
||||||
public double ZInOverride { get; set; } = double.NaN;
|
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 (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 (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"); }
|
if (Baskets.Count == 0) { throw Bad("baskets", "almeno un basket"); }
|
||||||
|
Risk.Validate();
|
||||||
|
|
||||||
foreach (BasketDefinition b in Baskets)
|
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, $"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, $"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, $"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)
|
foreach (BasketDefinition b in Baskets)
|
||||||
{
|
{
|
||||||
sb.Append(CultureInfo.InvariantCulture, $"{b.A}/{b.B}={(b.Enabled ? 1 : 0)};");
|
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 "maxadds": c.MaxAddsOverride = Int(p); break;
|
||||||
case "zstop": c.ZStopOverride = Num(p); break;
|
case "zstop": c.ZStopOverride = Num(p); break;
|
||||||
case "baskets": c.Baskets = ReadBaskets(p.Value, warnings); 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;
|
default: warnings.Add($"chiave sconosciuta '{p.Name}' in strategy.json"); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,6 +500,37 @@ public sealed class BasketStrategyConfig
|
|||||||
return list;
|
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)
|
private static List<(int, int)> ReadSessions(JsonElement e, List<string> warnings)
|
||||||
{
|
{
|
||||||
List<(int, int)> list = [];
|
List<(int, int)> list = [];
|
||||||
@@ -569,12 +644,22 @@ public sealed class BasketStrategyConfig
|
|||||||
"volAverageDays": 30,
|
"volAverageDays": 30,
|
||||||
"mlMinProbability": 0.55,
|
"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,
|
"equityStopPct": 9,
|
||||||
"dailyLossPct": 3,
|
"dailyLossPct": 3,
|
||||||
"legTimeoutSec": 5,
|
"legTimeoutSec": 5,
|
||||||
"clockSkewMaxSeconds": 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": "I cinque basket della specifica. Il cross sintetico e il verso delle gambe sono derivati dai codici delle valute, non configurati.",
|
||||||
"baskets": [
|
"baskets": [
|
||||||
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
|
{ "a": "EURUSD", "b": "USDCHF", "enabled": true, "note": "cross sintetico EURCHF" },
|
||||||
|
|||||||
@@ -13,14 +13,23 @@ public sealed record SizingResult(
|
|||||||
double EffectiveLeverage,
|
double EffectiveLeverage,
|
||||||
string Reason)
|
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>
|
/// <summary>
|
||||||
/// Volatility-parity sizing (§4.3): leg B carries the same ATR-dollar risk as leg A,
|
/// 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
|
/// 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
|
/// equity. Then the caps: effective leverage on the total notional, the margin the two
|
||||||
/// exposure and maximum units, and the optional meta-model scale.
|
/// 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>
|
/// <para>
|
||||||
/// The loss at the stop is estimated as the average notional times the move of the
|
/// 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
|
/// 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 maxUnitsA,
|
||||||
double maxUnitsB,
|
double maxUnitsB,
|
||||||
double maxEffectiveLeverage,
|
double maxEffectiveLeverage,
|
||||||
double scale = 1.0)
|
double scale = 1.0,
|
||||||
|
double maxMarginUsd = double.PositiveInfinity,
|
||||||
|
int leverageA = 1,
|
||||||
|
int leverageB = 1)
|
||||||
{
|
{
|
||||||
if (equity <= 0)
|
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)"));
|
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.
|
// Average notional such that N·Δz·σ_X = budget.
|
||||||
double moveAtStop = deltaZ * sigmaX;
|
double moveAtStop = deltaZ * sigmaX;
|
||||||
double averageNotional = budget / moveAtStop;
|
double averageNotional = budget / moveAtStop;
|
||||||
@@ -103,47 +120,64 @@ public static class VolParitySizing
|
|||||||
double notionalA = unitsA * notionalPerUnitA;
|
double notionalA = unitsA * notionalPerUnitA;
|
||||||
double notionalB = unitsB * notionalPerUnitB;
|
double notionalB = unitsB * notionalPerUnitB;
|
||||||
double leverage = (notionalA + notionalB) / equity;
|
double leverage = (notionalA + notionalB) / equity;
|
||||||
|
string bound = "risk";
|
||||||
|
|
||||||
if (leverage > maxEffectiveLeverage)
|
if (leverage > maxEffectiveLeverage)
|
||||||
{
|
{
|
||||||
double shrink = maxEffectiveLeverage / leverage;
|
Shrink(maxEffectiveLeverage / leverage);
|
||||||
unitsA *= shrink;
|
bound = "leverage";
|
||||||
unitsB *= shrink;
|
}
|
||||||
notionalA *= shrink;
|
|
||||||
notionalB *= shrink;
|
// Margin: what the venue locks for the two legs at their order leverage.
|
||||||
leverage = maxEffectiveLeverage;
|
double margin = Margin(notionalA, notionalB);
|
||||||
|
if (double.IsFinite(maxMarginUsd) && margin > maxMarginUsd)
|
||||||
|
{
|
||||||
|
Shrink(maxMarginUsd / margin);
|
||||||
|
bound = "margin";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxUnitsA > 0 && unitsA > maxUnitsA)
|
if (maxUnitsA > 0 && unitsA > maxUnitsA)
|
||||||
{
|
{
|
||||||
double shrink = maxUnitsA / unitsA;
|
Shrink(maxUnitsA / unitsA);
|
||||||
unitsA *= shrink;
|
bound = "units";
|
||||||
unitsB *= shrink;
|
|
||||||
notionalA *= shrink;
|
|
||||||
notionalB *= shrink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxUnitsB > 0 && unitsB > maxUnitsB)
|
if (maxUnitsB > 0 && unitsB > maxUnitsB)
|
||||||
{
|
{
|
||||||
double shrink = maxUnitsB / unitsB;
|
Shrink(maxUnitsB / unitsB);
|
||||||
unitsA *= shrink;
|
bound = "units";
|
||||||
unitsB *= shrink;
|
|
||||||
notionalA *= shrink;
|
|
||||||
notionalB *= shrink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notionalA < minExposureUsd || notionalB < minExposureUsd)
|
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);
|
unitsA = Math.Round(unitsA, 2);
|
||||||
unitsB = Math.Round(unitsB, 2);
|
unitsB = Math.Round(unitsB, 2);
|
||||||
double lossAtStop = ((notionalA + notionalB) / 2) * moveAtStop + Math.Max(0, costUsd);
|
double lossAtStop = ((notionalA + notionalB) / 2) * moveAtStop + Math.Max(0, costUsd);
|
||||||
leverage = (notionalA + notionalB) / equity;
|
leverage = (notionalA + notionalB) / equity;
|
||||||
|
margin = Margin(notionalA, notionalB);
|
||||||
|
|
||||||
return new SizingResult(true, unitsA, unitsB, notionalA, notionalB, lossAtStop, leverage,
|
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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user