Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e471f12c | ||
|
|
e9ace47501 | ||
|
|
d7559d36a5 | ||
|
|
9c1bab5224 | ||
|
|
a69efe7df3 | ||
|
|
21fa04690a | ||
|
|
9f040ade76 | ||
|
|
c5f2b7d852 | ||
|
|
aaec965241 | ||
|
|
327d3c6981 | ||
|
|
13d64d1830 | ||
|
|
f97f1e1fc3 | ||
|
|
b8c3c75647 | ||
|
|
4c26fd3209 | ||
|
|
b39e08b15c | ||
|
|
a4e297f77a | ||
|
|
9453e20cfc | ||
|
|
e080a0e867 | ||
|
|
2b4c53ec70 | ||
|
|
ebc391eadd | ||
|
|
f96ed670ca | ||
|
|
61f1e59964 |
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: encelado-diagnose
|
||||
description: Riconcilia ledger, registro degli ordini, heartbeat e posizioni del conto eToro e produce un rapporto CSV con separatore ; e colonna motivazione.
|
||||
---
|
||||
|
||||
# Diagnosi di una sessione di Encelado
|
||||
|
||||
Cartella dati: `Documenti\Encelado\data` su Windows, `/data/data` nel container. Se l'utente indica un'altra cartella, usa quella.
|
||||
|
||||
1. **Ledger delle decisioni** `data/ledger/decisions.jsonl` (e i mensili `decisions_YYYYMM.jsonl`): conta gli `evento` (`segnale_ingresso`, `rifiuto`, `ingresso`, `pending`, `pending_risolto`, `leg_risk_unwind`, `uscita`, `orfana_adottata`, `orfana_chiusa`, `movimento_di_cassa`, `kill_switch_*`, `recupero_*`, `comando`, `correzione`). Per ogni `rifiuto` riporta `basket`, `ts`, `unitsA`, `unitsB`, `sizing_bound`, `motivazione`.
|
||||
2. **Registro degli ordini** `data/ledger/orders.jsonl` e `data/state/pending_orders.json`: per ogni `client_ref` prendi l'ultima riga; elenca gli ordini con `esito = Pending` o `stato = Unknown` (sono le anomalie), quelli con `unita_eseguite ≠ unita_richieste` (il server ha ridotto l'ordine), gli slippage oltre 1 pip.
|
||||
3. **Basket** `data/ledger/baskets.csv`: numero, win rate, P&L netto, `exit_reason` (attenzione a `orphan_closed`, `bonifica_orfana`, `leg_risk_unwind`, `margin_guard`, `kill_switch`).
|
||||
4. **Heartbeat** `data/state/heartbeat.json` e **stato** `data/state/baskets_state.json`: ultimo battito, run, basket aperti e in attesa, `haltResidue`, `cumulativeCashFlow`, `peakNetEquity`.
|
||||
5. **Conto eToro** (solo se le chiavi sono disponibili nell'ambiente `ETORO_API_KEY`/`ETORO_USER_KEY` o via collegamento MCP, e solo in lettura): posizioni aperte (`GET api/v1/trading/info/demo/pnl`), storico (`GET api/v1/trading/info/trade/demo/history?minDate=…`). Classifica ogni posizione come `basket` (id nello stato), `orfana-bot` (id nel registro, o strumento + verso + orario entro 90 s da una riga del ledger), `esterna`.
|
||||
6. Scrivi `reports/diagnosi_<data>.csv` con separatore `;`, intestazione e colonna finale `motivazione`: una riga per anomalia (`tipo;riferimento;quando;dettaglio;motivazione`) e in fondo le righe di riepilogo. Nessuna riga del ledger va modificata: le correzioni sono righe nuove.
|
||||
7. Riassumi all'utente: cosa non torna, cosa è già stato gestito dal bot, cosa richiede una mano (chiusure su eToro, reset).
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: encelado-docs
|
||||
description: Chiude una sessione di lavoro su Encelado: aggiorna STATE, CHANGELOG, ADR e QUESTIONS, poi propone il messaggio di commit.
|
||||
---
|
||||
|
||||
# Chiusura della sessione: documenti e commit
|
||||
|
||||
1. `docs/STATE.md`: data, fase in corso, cosa è stato fatto in questa sessione (con i numeri: test, file, commit), prossimi passi numerati, problemi aperti, domande in attesa. È la memoria fra una sessione e l'altra: chi legge solo questo deve poter ripartire.
|
||||
2. `CHANGELOG.md`: una voce in alto con la data e il titolo della sessione; punti brevi su cosa cambia e perché, non l'elenco dei file.
|
||||
3. ADR: una scelta non ovvia = un file `docs/adr/ADR-NNNN-<slug>.md` con Contesto, Decisione, Alternative scartate, Conseguenze. Aggiorna la tabella in `CLAUDE.md` se compare un documento nuovo.
|
||||
4. `docs/QUESTIONS.md`: ogni dubbio è una riga numerata (D-xx) con il default applicato; le risposte arrivate si segnano con la data.
|
||||
5. Se sono cambiati schema del ledger, rotte dell'API, regole di rischio o file in `Documenti\Encelado`: `docs/LEDGER_SCHEMA.md`, `docs/DATA_SOURCES.md`, `docs/RISK_RULES.md`, `docs/RUNBOOK.md`, `docs/KNOWN_ISSUES.md` (togli ciò che si chiude, aggiungi ciò che resta).
|
||||
6. Verifica (`/encelado-verify`), poi proponi il messaggio di commit: titolo di una riga che dice cosa cambia, corpo che dice perché; un commit per sessione, un commit per fase se la sessione ne copre più d'una. Il push lo decide l'utente.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: encelado-release
|
||||
description: Rilascio di Encelado: verifica, zip portabile, immagine Docker, tag, push dell'immagine sul registro di Gitea e release con il template Unraid, in quest'ordine e solo dopo il commit e il push del ramo.
|
||||
---
|
||||
|
||||
# Rilascio di Encelado
|
||||
|
||||
Presupposti: albero pulito, ultima sessione committata e pushata, `/encelado-verify` verde, Docker in esecuzione, `build/gitea.json` presente (mai committato: vedi `build/gitea.example.json`; il token deve avere anche `package: read and write`), versione decisa dall'utente (semver; la 5.0.0 è la prima con web UI e Docker).
|
||||
|
||||
1. `dotnet msbuild build/Release.proj -t:Verifica`.
|
||||
2. `dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=<x.y.z>` (note in `ENCELADO_NOTE`): pubblica la cartella portabile, costruisce l'immagine `192.168.30.23:3000/alby96/encelado:<x.y.z>` e `:latest` (i test girano anche dentro la build), crea il tag, lo spinge e lo verifica sul remoto, fa il push dell'immagine sul registro di Gitea, crea la release con lo zip portabile e il template Unraid allegati. La versione viene dal tag, non da un file.
|
||||
3. Template Unraid: aggiorna `deploy/unraid/encelado.xml` se sono cambiate variabili, porte o volumi; l'`Icon` è `assets/encelado.png` raw su Gitea (D-29). Il file finisce comunque allegato alla release con la versione nel nome.
|
||||
4. Su Unraid: *Docker ▸ Controlla aggiornamenti*; il container riparte con SIGTERM pulito e recupero dopo inattività.
|
||||
5. `CHANGELOG.md` e `docs/STATE.md` riportano la versione rilasciata; il tag e la release hanno lo stesso testo del CHANGELOG.
|
||||
|
||||
Senza Docker: `-p:SaltaDocker=true` produce solo lo zip (non è un rilascio completo, e va detto). Non modificare `build/Release.proj` se non richiesto: la catena è condivisa con Mimante e AutoBidder.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: encelado-start
|
||||
description: Avvia una sessione di lavoro su Encelado: legge lo stato, compila, riassume fase e problemi aperti, chiede cosa fare.
|
||||
---
|
||||
|
||||
# Avvio di una sessione su Encelado
|
||||
|
||||
1. Leggi, in quest'ordine: `CLAUDE.md`, `docs/STATE.md`, `docs/PIANO_5.0.md` (se la 5.0 non è conclusa), `docs/QUESTIONS.md` (le domande in attesa), `docs/KNOWN_ISSUES.md`.
|
||||
2. Controlla l'albero di lavoro: `git status --short` e `git log --oneline -5`. Un albero sporco va capito prima di toccare qualcosa.
|
||||
3. Compila: `dotnet build Encelado.slnx`. Se non compila, la prima cosa da fare è farlo compilare, e va detto subito.
|
||||
4. Riassumi in dieci righe: fase in corso, cosa è stato fatto nell'ultima sessione, prossimi passi, domande senza risposta, problemi aperti, esito della compilazione.
|
||||
5. Chiedi all'utente cosa fare in questa sessione. Non iniziare una fase del piano senza che sia chiaro quale.
|
||||
|
||||
Regole che valgono sempre: niente pacchetti NuGet nell'applicazione, UTC ovunque, ledger append-only, mai un ordine reale senza flag e frase `CONFERMO LIVE`, un commit a fine sessione dopo la verifica.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: encelado-ui
|
||||
description: Regole per toccare l'interfaccia web di Encelado: i token Material 3 e le linee guida di docs/UI_GUIDELINES.md, nessuna libreria, stesse informazioni della dashboard, test dell'HTML e dello stream.
|
||||
---
|
||||
|
||||
# Lavorare sull'interfaccia di Encelado
|
||||
|
||||
L'interfaccia è servita dal bot (`src/Encelado.Server/Web/wwwroot/`: `index.html`, `login.html`, `app.css`, `app.js`, `icon.svg`, `manifest.webmanifest`, incorporati come `EmbeddedResource`). Le API stanno in `Web/WebHost.cs`, lo snapshot JSON in `Engine/SnapshotJson.cs`, i dati finti in `Engine/SampleSnapshot.cs`. Leggi `docs/UI_GUIDELINES.md` prima di scrivere una riga. In sintesi:
|
||||
|
||||
- HTML (scritto come XHTML: tag chiusi, attributi con valore) + CSS + JavaScript vanilla; nessun framework, nessun font remoto, nessun `<script src=http…>`, nessun `@import`.
|
||||
- La UI non decide niente: legge lo snapshot (`/api/stream`, SSE, uno al secondo; `?once=1` per una sola lettura) e manda comandi (`POST /api/commands/<nome>`). Un campo nuovo nello snapshot passa da `BotSnapshot` → `SnapshotJson` → `SampleSnapshot` → `app.js` → test.
|
||||
- Ruoli di colore Material 3 (`--primary`, `--surface-container-*`, `--outline`, `--error`; `--up`/`--down` solo per P&L e stati), tema scuro di default e chiaro selezionabile (`ui.theme`), contrasto ≥ 4,5:1, cifre tabulari.
|
||||
- Componenti propri: navigation rail (80/256 px, drawer sotto 600 px), top app bar, cards, data table compatta (cards sotto 600 px), chips, buttons, dialoghi nativi `<dialog>` per conferme (kill-switch con «chiudi anche le esterne», reset con motivazione, `CONFERMO LIVE`), snackbar, linear progress, tooltip con formula e fonte su ogni numero.
|
||||
- Ogni importo convertito nella valuta di visualizzazione porta nel tooltip il valore in USD e il tasso usato; il ledger resta in USD.
|
||||
- Accessibilità: tastiera completa, `aria-label`, focus visibile, `prefers-reduced-motion`, `prefers-color-scheme`.
|
||||
|
||||
Prima di dichiarare finita una modifica:
|
||||
|
||||
1. `dotnet test tests/Encelado.Tests --filter "FullyQualifiedName~EmbeddedUi|FullyQualifiedName~WebHost"` verde (HTML ben formato e senza riferimenti esterni, snapshot JSON, token, stream, storico).
|
||||
2. `dotnet run --project src/Encelado.Server -- --sample --port 8085` e controllo a occhio nel browser (dashboard, storico, log, impostazioni, larghezza da telefono).
|
||||
3. Se una pagina è cambiata, rifai gli screenshot in `docs/img/` (comando in `docs/UI_GUIDELINES.md`) e chiedi la revisione visiva all'utente.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: encelado-verify
|
||||
description: La verifica completa di Encelado prima di un commit: compilazione, test (inclusi HTML incorporato e stream), controllo dei file di configurazione e catena di rilascio.
|
||||
---
|
||||
|
||||
# Verifica di Encelado
|
||||
|
||||
Esegui nell'ordine e riporta l'esito di ciascun passo con i numeri (errori, avvisi, test superati/non superati), senza abbellire:
|
||||
|
||||
1. `dotnet build Encelado.slnx` — deve chiudere con 0 errori e 0 avvisi (`TreatWarningsAsErrors` è attivo: un avviso è un errore). Se un server locale tiene bloccata `Encelado.Server.dll`, fermalo prima (`stop` sulla sua console o Ctrl+C).
|
||||
2. `dotnet test tests/Encelado.Tests --no-build` — tutti verdi. Un test rosso si legge e si corregge; non si esclude.
|
||||
3. Coerenza dei file di fabbrica: `config/strategy.json` deve essere identico a `BasketStrategyConfig.DefaultJson` e `config/encelado.json` a `ConfigDefaults.Json` (ci sono test che lo verificano: se falliscono, rigenera i file dalle costanti, non il contrario).
|
||||
4. Interfaccia web: `EmbeddedUiTests` (HTML ben formato, nessun riferimento esterno, snapshot JSON) e `WebHostTests` (token, cookie, stream con uno snapshot al secondo, storico CSV) sono nella suite del passo 2; se hai toccato una pagina, anche il controllo a occhio con `--sample` (`/encelado-ui`).
|
||||
5. `dotnet msbuild build/Release.proj -t:Verifica` — la catena di rilascio compila e testa nella cartella di verifica.
|
||||
6. Se hai toccato `Dockerfile`, `deploy/` o la catena: `dotnet msbuild build/Release.proj -t:Docker` (o `docker build .`) deve produrre l'immagine; `docker run --rm <immagine> --health` non è un test valido a freddo (il server non gira): usa `docker compose up` e `docker inspect --format '{{.State.Health.Status}}' encelado`.
|
||||
7. Riepilogo: una tabella `passo;esito;dettaglio`. Se un passo fallisce, fermati lì e proponi la correzione.
|
||||
|
||||
Non toccare `build/` se non richiesto: è condiviso con Mimante e AutoBidder.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Ciò che non serve dentro il contesto di build. .git resta fuori: il commit
|
||||
# arriva dal build-arg GIT_COMMIT (vedi build/Release.proj e la Dockerfile).
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.claude
|
||||
**/bin
|
||||
**/obj
|
||||
**/TestResults
|
||||
bin
|
||||
obj
|
||||
results
|
||||
reports
|
||||
docs/img
|
||||
deploy/local
|
||||
build/gitea.json
|
||||
*.md
|
||||
!docs/**/*.md
|
||||
Modifiche.txt
|
||||
*.local.json
|
||||
.env
|
||||
@@ -0,0 +1,36 @@
|
||||
# Verifica a ogni push e pull request: compilazione con gli avvisi come errori e la
|
||||
# suite dei test. Un secondo lavoro costruisce l'immagine (senza pubblicarla) per
|
||||
# accorgersi subito di una Dockerfile rotta: richiede un runner con Docker.
|
||||
#
|
||||
# Runner: act_runner con l'etichetta ubuntu-latest (vedi docs/DOCKER.md, «Gitea Actions»).
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verifica:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
- name: Compilazione (avvisi = errori)
|
||||
run: dotnet build Encelado.slnx -c Release -nologo -warnaserror
|
||||
- name: Test
|
||||
run: dotnet test tests/Encelado.Tests -c Release --no-build --nologo -v q
|
||||
|
||||
immagine:
|
||||
runs-on: ubuntu-latest
|
||||
needs: verifica
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Costruzione dell'immagine (i test girano anche dentro la build)
|
||||
run: |
|
||||
docker build --pull \
|
||||
--build-arg VERSION=ci \
|
||||
--build-arg GIT_COMMIT="${GITHUB_SHA::12}" \
|
||||
-t encelado:ci .
|
||||
@@ -0,0 +1,78 @@
|
||||
# Rilascio a ogni tag vX.Y.Z: l'immagine sul registro dei container di Gitea
|
||||
# (<host>/alby96/encelado:X.Y.Z e :latest) e la release con il template Unraid allegato.
|
||||
#
|
||||
# È la stessa cosa che fa build/Release.proj -t:Rilascia dal PC; qui gira sul runner
|
||||
# quando il tag arriva sul server. Idempotente: un'immagine già presente viene
|
||||
# sovrascritta con lo stesso contenuto, una release già creata dalla catena viene
|
||||
# lasciata com'è e le manca solo l'allegato, che viene aggiunto se non c'è.
|
||||
#
|
||||
# Serve il segreto REGISTRY_TOKEN (Impostazioni ▸ Actions ▸ Segreti del repository):
|
||||
# un token dell'utente con i permessi package:write e repository:write.
|
||||
# Solo linux/amd64: la build esegue la suite dei test, e sotto QEMU (arm64) sarebbe
|
||||
# lentissima; il server Unraid è amd64.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
immagine:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Versione e registro dal tag e dall'indirizzo del server
|
||||
run: |
|
||||
echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
|
||||
echo "REGISTRY=$(echo "${{ gitea.server_url }}" | sed -E 's#^https?://##; s#/$##')" >> "$GITHUB_ENV"
|
||||
echo "OWNER=$(echo "${{ gitea.repository_owner }}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# Il registro di Gitea è in HTTP: buildx deve poterlo trattare come insicuro.
|
||||
driver-opts: network=host
|
||||
buildkitd-config-inline: |
|
||||
[registry."${{ env.REGISTRY }}"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
GIT_COMMIT=${{ gitea.sha }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/encelado:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.OWNER }}/encelado:latest
|
||||
|
||||
- name: Release con il template Unraid allegato
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
|
||||
run: |
|
||||
set -eu
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
ASSET="encelado-unraid-${VERSION}.xml"
|
||||
cp deploy/unraid/encelado.xml "$ASSET"
|
||||
ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1 || true)
|
||||
if [ -z "$ID" ]; then
|
||||
BODY=$(printf '{"tag_name":"%s","name":"Encelado %s","body":"Versione %s.\\n\\nImmagine: `%s/%s/encelado:%s` (anche `:latest`). Template Unraid allegato.","draft":false,"prerelease":false}' "$TAG" "$VERSION" "$VERSION" "$REGISTRY" "$OWNER" "$VERSION")
|
||||
ID=$(curl -sf -H "Authorization: token $TOKEN" -H "Content-Type: application/json" --data-binary "$BODY" "$API/releases" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)
|
||||
echo "release $TAG creata (id $ID)"
|
||||
else
|
||||
echo "release $TAG già presente (id $ID): aggiungo solo l'allegato mancante"
|
||||
fi
|
||||
if ! curl -sf -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" | grep -q "\"name\":\"$ASSET\""; then
|
||||
curl -sf -H "Authorization: token $TOKEN" -F "attachment=@$ASSET" "$API/releases/$ID/assets?name=$ASSET" > /dev/null
|
||||
echo "allegato $ASSET caricato"
|
||||
fi
|
||||
@@ -0,0 +1,24 @@
|
||||
# Build output
|
||||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
|
||||
# Runtime output — never commit logs or the trade journal
|
||||
logs/
|
||||
*.log
|
||||
*.jsonl
|
||||
|
||||
# Local configuration: credentials and machine-specific overrides live here
|
||||
*.local.json
|
||||
.env
|
||||
|
||||
# Il token di Gitea per la catena di rilascio. Il modello versionato è
|
||||
# build/gitea.example.json; questo file contiene una credenziale e non entra
|
||||
# mai nel repository.
|
||||
build/gitea.json
|
||||
|
||||
# Output dei test (coverlet)
|
||||
TestResults/
|
||||
|
||||
# Volumi di docker-compose per le prove in locale (configurazione e dati del container)
|
||||
deploy/local/
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"recommendations": [
|
||||
// Fornisce il debugger "coreclr" richiesto da launch.json.
|
||||
"ms-dotnettools.csharp",
|
||||
"ms-dotnettools.csdevkit",
|
||||
|
||||
// Colora la Dockerfile e la docker-compose.yml e mostra container e immagini
|
||||
// locali. L'immagine si costruisce con il task "immagine docker", che non
|
||||
// dipende da nessuna estensione.
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
// F5 avvia il server (motore + interfaccia web) come processo locale, fuori dal
|
||||
// container, con configurazione e dati nella sandbox deploy/local (ignorata da git,
|
||||
// stesse cartelle che il compose monta come /config e /data). Il browser si apre da
|
||||
// solo appena Kestrel è in ascolto; senza token il server ascolta solo su localhost e
|
||||
// non chiede il login. "Encelado (campione)" serve l'interfaccia con dati finti, senza
|
||||
// chiavi né mercato: è quello che si usa per lavorare sulle pagine.
|
||||
// Le chiavi eToro: variabili ETORO_API_KEY/ETORO_USER_KEY nell'ambiente di VS Code,
|
||||
// oppure Impostazioni ▸ Chiavi eToro con ENCELADO_KEY_PASSPHRASE impostata qui sotto.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Encelado (server)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll",
|
||||
"args": ["--no-autostart"],
|
||||
"cwd": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0",
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ENCELADO_WEB_PORT": "8080",
|
||||
"ENCELADO_CONFIG_DIR": "${workspaceFolder}/deploy/local/config",
|
||||
"ENCELADO_DATA_DIR": "${workspaceFolder}/deploy/local/data",
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "in ascolto su (http://\\S+)",
|
||||
"uriFormat": "%s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Encelado (campione)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll",
|
||||
"args": ["--sample", "--port", "8081"],
|
||||
"cwd": "${workspaceFolder}/src/Encelado.Server/bin/Debug/net10.0",
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false,
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "in ascolto su (http://\\S+)",
|
||||
"uriFormat": "%s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Backtest (baskets)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/tools/Encelado.Backtest/bin/Debug/net10.0/backtest.dll",
|
||||
"args": ["baskets", "--data", "${env:USERPROFILE}/Documents/Encelado/data/market", "--out", "results", "--quick"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
// Tutte le attività passano da build/Release.proj: la catena è un solo file
|
||||
// MSBuild versionato col codice, e qui restano soltanto i nomi e le domande.
|
||||
// MSBuild non può chiedere niente a nessuno — i prompt stanno in "inputs".
|
||||
//
|
||||
// È la stessa impostazione di Mimante/AutoBidder, con l'immagine Docker al
|
||||
// posto dell'installatore.
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"detail": "Compilazione di debug, per F5 e per il controllo rapido degli errori.",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Encelado.slnx",
|
||||
"-c",
|
||||
"Debug",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary"
|
||||
],
|
||||
"group": { "kind": "build", "isDefault": true },
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "verifica",
|
||||
"detail": "Compila e lancia i test. Da eseguire dopo ogni modifica.",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"msbuild",
|
||||
"${workspaceFolder}/build/Release.proj",
|
||||
"-t:Verifica",
|
||||
"-nologo",
|
||||
"-v:m"
|
||||
],
|
||||
"group": { "kind": "test", "isDefault": true },
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "backtest",
|
||||
"detail": "Ricerca sui basket: ticks (tick MT5 → barre), baskets (griglia, PSR/DSR, PBO, walk-forward), falsify (falsificazione), learn (ciclo di apprendimento in ombra su un ledger).",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"msbuild",
|
||||
"${workspaceFolder}/build/Release.proj",
|
||||
"-t:Backtest",
|
||||
"-p:Dati=${input:dati}",
|
||||
"-p:Comando=${input:comando}",
|
||||
"-nologo",
|
||||
"-v:m"
|
||||
],
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "immagine docker",
|
||||
"detail": "Verifica e costruisce l'immagine 192.168.30.23:3000/alby96/encelado:<versione> (e :latest) con la Dockerfile alla radice. Non pubblica niente.",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"msbuild",
|
||||
"${workspaceFolder}/build/Release.proj",
|
||||
"-t:Docker",
|
||||
"-p:Versione=${input:versione}",
|
||||
"-nologo",
|
||||
"-v:m"
|
||||
],
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "pacchetto",
|
||||
"detail": "Verifica, pubblica la cartella portabile (bin/installer/Encelado_<versione>_portabile.zip) e costruisce l'immagine. Crea il tag a pacchetto pronto. Non tocca Gitea.",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"msbuild",
|
||||
"${workspaceFolder}/build/Release.proj",
|
||||
"-t:Pacchetto",
|
||||
"-p:Versione=${input:versione}",
|
||||
"-nologo",
|
||||
"-v:m"
|
||||
],
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "rilascia su Gitea",
|
||||
"detail": "Verifica, pacchetto, immagine, tag, push dell'immagine sul registro di Gitea e release con lo zip e il template Unraid allegati. Richiede build/gitea.json.",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"msbuild",
|
||||
"${workspaceFolder}/build/Release.proj",
|
||||
"-t:Rilascia",
|
||||
"-p:Versione=${input:versione}",
|
||||
"-nologo",
|
||||
"-v:m"
|
||||
],
|
||||
// Le note passano dall'ambiente, non da -p:. MSBuild spezza il valore di
|
||||
// una proprietà sulle virgole e una nota in italiano ne ha quasi sempre
|
||||
// una: si otterrebbe MSB1006 «proprietà non valida». Vedi Release.proj.
|
||||
"options": {
|
||||
"env": {
|
||||
"ENCELADO_NOTE": "${input:note}"
|
||||
}
|
||||
},
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "avvia in locale (script)",
|
||||
"detail": "scripts/run-dev.ps1: compila e avvia il server nella sandbox deploy/local, senza container. Ctrl+C per fermare.",
|
||||
"type": "shell",
|
||||
"command": "powershell -ExecutionPolicy Bypass -File scripts/run-dev.ps1",
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "avvia in docker",
|
||||
"detail": "scripts/run-docker.ps1: costruisce l'immagine e avvia il container dalla docker-compose.yml (deploy/local come /config e /data), poi segue il log.",
|
||||
"type": "shell",
|
||||
"command": "powershell -ExecutionPolicy Bypass -File scripts/run-docker.ps1",
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "ferma docker",
|
||||
"detail": "docker compose down: SIGTERM al bot, stop pulito, container rimosso (cartelle in deploy/local conservate).",
|
||||
"type": "shell",
|
||||
"command": "docker compose down",
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "screenshot",
|
||||
"detail": "scripts/screenshots.ps1: server in modalità campione e Edge headless, rigenera docs/img/*.png.",
|
||||
"type": "shell",
|
||||
"command": "powershell -ExecutionPolicy Bypass -File scripts/screenshots.ps1",
|
||||
"options": { "cwd": "${workspaceFolder}" },
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"problemMatcher": []
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "versione",
|
||||
"type": "promptString",
|
||||
"description": "Versione — lascia vuoto se hai già taggato (git tag v5.0.0), o per la minor successiva",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "note",
|
||||
"type": "promptString",
|
||||
"description": "Note di rilascio (vuoto = solo il numero di versione)",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "dati",
|
||||
"type": "promptString",
|
||||
"description": "Cartella dei dati: data/market (barre M15) per baskets e falsify, la cartella dei tick MT5 per ticks, la cartella data del bot per learn",
|
||||
"default": "C:\\Users\\alber\\Documents\\Encelado\\data\\market"
|
||||
},
|
||||
{
|
||||
"id": "comando",
|
||||
"type": "pickString",
|
||||
"description": "Comando del backtest",
|
||||
"options": ["baskets", "falsify", "ticks", "learn"],
|
||||
"default": "baskets"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# Cronologia
|
||||
|
||||
Formato: una voce per sessione di lavoro, con data. Le voci più recenti in alto.
|
||||
|
||||
## 2026-09-23 — 5.0, Fasi 6-9: solo container, interfaccia web, Docker e Unraid, pulizia
|
||||
|
||||
- **Ritirata la finestra WPF** (D-28, ADR-0007): `src/Encelado.Bot` diventa la libreria `src/Encelado.Engine`; l'eseguibile è `src/Encelado.Server` (Kestrel, nessun NuGet) che ospita il motore e serve l'interfaccia. Chiavi eToro da variabili d'ambiente o dal file cifrato `etoro.keys.enc` (AES-256-GCM, `ENCELADO_KEY_PASSPHRASE`) al posto di DPAPI. Cartelle `/config` e `/data` nel container, `Documenti\Encelado` fuori.
|
||||
- **Interfaccia web Material 3** senza librerie (`Web/wwwroot`): navigation rail, barra con orologi UTC e fuso, valuta di visualizzazione (otto valute, tasso dalle quotazioni, tooltip in USD), AVVIA/FERMA e KILL-SWITCH con dialoghi (esterne, motivazione, `CONFERMO LIVE`); dashboard con sei KPI e la tabella dei basket (cards da telefono); **Storico ordini** (ordini, posizioni classificate `basket`/`orfana-bot`/`esterna`/`movimento di cassa`, profitti per periodo con curva dell'equity, CSV); Log; Impostazioni con configurazione validata, chiavi, ripristino in cinque passi, Ricerca (stato dell'apprendimento e ciclo a richiesta), Diagnostica (percorsi, quote, bonifica), Informazioni (versione, data di build, commit). SSE `/api/stream` a uno snapshot al secondo; token `ENCELADO_WEB_TOKEN` in cookie HttpOnly; senza token solo localhost. Tema scuro e chiaro. Regole in `docs/UI_GUIDELINES.md`, screenshot in `docs/img/`.
|
||||
- **Docker e Unraid** (ADR-0008): `Dockerfile` multi-stage con i test nello stadio di build, `entrypoint.sh` con TZ e PUID/PGID, `docker-compose.yml`, `HEALTHCHECK` via `--health`; template `deploy/unraid/encelado.xml` con l'icona servita da Gitea (D-29); catena di rilascio senza Inno Setup: `Pubblica` framework-dependent, `Docker`, `Pacchetto` (zip + template + tag), `Rilascia` con push dell'immagine sul registro di Gitea. VS Code: F5 sul server (browser aperto da solo), modalità campione `--sample`, attività Docker.
|
||||
- **Strumento**: comando `learn` (ciclo di apprendimento in ombra su un ledger esportato).
|
||||
- **Pulizia**: rimossi WPF, DPAPI, i test di rendering e di binding, ~45 membri mai usati nel Core e nell'Engine (statistiche di ricerca, `Psi`, `EmptyContextProvider`, `EnvironmentKeyStore`, display helper dello snapshot, `INotifyPropertyChanged`), `build/Encelado.iss`. `PROMPT.md` → `docs/PROMPT_5.0.md`.
|
||||
- **Documenti**: ADR-0007, ADR-0008, `docs/DOCKER.md`, `docs/UI_GUIDELINES.md`, `README.md`, `CLAUDE.md`, ARCHITECTURE, RUNBOOK, KNOWN_ISSUES, GLOSSARY, DATA_SOURCES, ML_AND_LEARNING, QUESTIONS (D-28, D-29, D-36, D-37), POSTMORTEM (gli accrediti del demo erano uno per ordine ridotto), skill, `build/README.md`.
|
||||
- **Avvio in locale**: sandbox `deploy/local/` con la stessa disposizione del container (`ENCELADO_DATA_DIR` vale anche fuori dal container), F5 in VS Code sulla sandbox, script `scripts/run-dev`, `run-docker`, `screenshots` (`.ps1` e `.sh`), attività di VS Code per avviare, fermare e fotografare. Arresto pulito su SIGTERM (`PosixSignalRegistration`) verificato con `docker stop`; `/api/health` in `--sample` non finge un motore acceso.
|
||||
- Test nuovi: `EmbeddedUiTests`, `WebHostTests` (token, cookie, stream, storico), `HistoryBuilderTests`, `KeyStoreTests`; 210 verdi, anche su Linux dentro `docker build`. Versione di sviluppo 5.0.0.
|
||||
|
||||
## 2026-09-23 — 5.0, apprendimento in ombra (ADR-0006) e skill di progetto
|
||||
|
||||
- Sezione `learning` in `strategy.json` (`enabled`, `weeklyCycle`, `challenger`, tutti `false` di fabbrica): il ciclo settimanale e il challenger non girano nel bot, il cancello ML non può attivarsi, la logistica resta in ombra con `p_ML` nel ledger, il bandit propone e basta. Criterio di riattivazione in `docs/ML_AND_LEARNING.md`.
|
||||
- Skill di progetto in `.claude/skills/`: `encelado-start`, `encelado-verify`, `encelado-diagnose`, `encelado-docs`, `encelado-release`, `encelado-ui`.
|
||||
- `CLAUDE.md` aggiornato (esecuzione 5.0, `--bonifica`, variabili Telegram, skill); glossario e problemi noti aggiornati.
|
||||
- Catena di verifica (`build/Release.proj -t:Verifica`) verde.
|
||||
|
||||
## 2026-09-23 — 5.0, Fase 5: notifiche e comandi Telegram
|
||||
|
||||
- `TelegramNotifier` nel Core (solo `HttpClient`): coda non bloccante, un messaggio al secondo, tre tentativi con backoff e `retry_after`, spezzatura a 4096 caratteri, long polling `getUpdates` da un solo task, comandi accettati solo dalla chat autorizzata.
|
||||
- Sezione `notifications.telegram` in `encelado.json` e in Impostazioni → Notifiche; token da `TELEGRAM_BOT_TOKEN`, chat da `TELEGRAM_CHAT_ID`.
|
||||
- Stato ogni ora, riepilogo giornaliero, eventi (avvio/arresto, basket aperto/chiuso, gamba in attesa, ordine risolto, unwind, orfana, kill-switch, equity stop, perdita giornaliera, margin guard, recupero, reset, preset, API in errore, scarto orologio).
|
||||
- Comandi `/stato`, `/posizioni`, `/storico Nd`, `/pausa`, `/riprendi`, `/chiudi`, `/kill CONFERMO`, `/reset`; comandi `Pause`, `Resume`, `History` nel motore; ogni comando eseguito (finestra, console, Telegram) scrive una riga `comando` nel ledger.
|
||||
- Test (s), (t), (u) e altri: 219 verdi.
|
||||
|
||||
## 2026-09-23 — 5.0, Fase 4: recupero dopo inattività, heartbeat, lock di istanza
|
||||
|
||||
- `data/state/heartbeat.json` ogni 30 s (all'avvio e all'arresto con nota); all'avvio e a ogni ciclo l'inattività oltre `recovery.thresholdMinutes` (10) fa partire il recupero.
|
||||
- Recupero: entrate bloccate, ordini senza esito risolti, riconciliazione, riscaldamento con le barre perse e `barsHeld` aggiornato, ogni basket aperto rivalutato con le regole di uscita ordinarie (chiudi/tieni), orfane chiuse, esterne riportate, `reports/recupero_<run_id>.csv`, righe `recupero_avviato`/`recupero_concluso`, notifica, entrate riaperte dopo `recovery.warmupMinutes` (15). A mercato chiuso aspetta le quotazioni.
|
||||
- Sezione `recovery` in `strategy.json`; il reset usa lo stesso riscaldamento.
|
||||
- `data/state/instance.lock` tenuto in esclusiva: un secondo bot sulla stessa cartella dati non parte (problema noto dalla 4.0.0, chiuso).
|
||||
- `RecoveryPlanner`, `HeartbeatFile`, `InstanceLock` nel Core; test (r) e altri sei: 211 verdi.
|
||||
|
||||
## 2026-09-23 — 5.0, Fase 3: kill-switch che chiude davvero, ripristino in cinque passi
|
||||
|
||||
- `FlattenProcedure` nel Core: annullamento degli ordini senza esito (con attesa della risoluzione), chiusura delle posizioni, verifica di piattezza sul conto; stato `Halted` o `Halted-Residuo`.
|
||||
- Il kill-switch (pulsante, `kill`, file `STOP`) chiude basket, gambe in attesa e orfane; le posizioni esterne solo su richiesta esplicita o con `risk.closeForeignOnKill`; niente è dichiarato chiuso senza la rilettura del conto; i residui finiscono nel banner, nello stato salvato e nel ledger (`kill_switch_concluso`).
|
||||
- Comando `CloseResidue` (`residuo` da console, «chiudi ora» dalla finestra) per riprovare sui residui.
|
||||
- Reset in cinque passi (stato, file `STOP`, motivazione, riconciliazione + riscaldamento + picco, ripartenza con 15 minuti di entrate bloccate); rifiutato con `reset_rifiutato` finché una posizione del bot resta sul conto.
|
||||
- `INotifier` e `NullNotifier`: il motore notifica kill-switch e reset; il canale Telegram arriva con la Fase 5.
|
||||
- Test (v), (w), (x): 204 verdi.
|
||||
|
||||
## 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.
|
||||
- **Registro persistente degli ordini** (`OrderTracker`, `data/state/pending_orders.json`, `orders.jsonl`): scritto prima di ogni invio, risolto per `orderId`, per riferimento e per posizione comparsa; ricaricato all'avvio e risolto prima di ogni decisione. Nessun esito inventato: `Unknown` resta pendente (ADR-0009).
|
||||
- **Stati `PendingA`/`PendingB`**: una gamba senza esito non è più un rifiuto; alla risoluzione parte la gamba B (ridimensionata sulle unità eseguite di A) oppure la gamba A viene richiusa se il segnale è decaduto.
|
||||
- **Classificazione delle posizioni** (`basket` / `orfana-bot` / `esterna`) a ogni riconciliazione; le orfane del bot vengono adottate e chiuse; contatori «in attesa · orfane · esterne» e P&L aperto **del conto** in dashboard; avviso «posizioni non riconciliate».
|
||||
- **Movimenti di cassa** riconosciuti e scritti nel ledger; picco di equity e drawdown al netto (`EquityTracker`).
|
||||
- **Bonifica** (`--headless --bonifica`, comando `bonifica`): elenco delle orfane con conferma per posizione, rapporto in `reports/bonifica_YYYYMMDD.csv`.
|
||||
- `IBroker.LookupOrderByIdAsync` e `CancelOrderAsync`; `EtoroBroker.OpenAsync` legge l'esito per `orderId` e riconosce l'esecuzione dalla posizione; parser dell'esito v2 e v1.
|
||||
- Il bandit **propone e non applica** più il preset (D-30).
|
||||
- `BasketEngine` spezzato in sei file parziali.
|
||||
- Test nuovi (m)-(q) e altri 13: 190 test verdi.
|
||||
|
||||
## 2026-09-16 (pomeriggio) — 4.0.0: solo Correlation Baskets su eToro, bot autonomo, interfaccia nuova
|
||||
|
||||
- **Rimossi** i motori precedenti: Binance, Alpaca, cTrader/proba, SQLite, GBDT, RL, TA-Lib, indicatori, backtest a coppie, pagine e test relativi (ADR-0004). Nessun pacchetto NuGet nell'applicazione.
|
||||
- **Modalità** ridotte a `Paper`, `Demo` (default), `Live`: nessuna approvazione manuale dei singoli ordini (decisione dell'utente, ADR-0005). I nomi precedenti vengono letti con un avviso.
|
||||
- **Interfaccia** rifatta: barra in alto con tre schede, stato, ambiente, ora e AVVIA; dashboard con equity, P&L di oggi, P&L aperto, drawdown, basket aperti, tabella dei basket, contesto e attività. Tema nuovo. Test di rendering in PNG.
|
||||
- **Fuso orario** della finestra selezionabile (`ui.timeZone`); il log porta l'offset, il ledger resta UTC.
|
||||
- **Corretto** il parser dei costi di eToro (campo `value`): markup e overnight non erano letti.
|
||||
- **Apprendimento** collegato al motore: modello in ombra, challenger, bandit, previsione di volatilità, ciclo settimanale, `knowledge/`. Standardizzatore dell'MLP adattato all'insieme di addestramento.
|
||||
- **Backtest**: test di falsificazione 5 (segnale invertito), scenario di costi `api`, `docs/STRATEGY.md` con il verdetto negativo e i numeri.
|
||||
- Feed: dopo due errori consecutivi una fonte logga solo a debug e ritenta con attese crescenti.
|
||||
- Documenti nuovi: `STRATEGY.md`, `ML_AND_LEARNING.md`, `RUNBOOK.md`, `GLOSSARY.md`, `KNOWN_ISSUES.md`, ADR-0004, ADR-0005. Catena di rilascio aggiornata ai tre comandi dello strumento.
|
||||
- Versione 4.0.0.
|
||||
|
||||
## 2026-09-16 (mattina) — Correlation Baskets su eToro, Fasi 0-7
|
||||
|
||||
- Ricognizione del repository; verifica dell'API eToro (rotte, quote, schemi, limiti), degli strumenti, della valuta del conto, dei feed, del formato dei tick.
|
||||
- Broker eToro (`Encelado.Etoro`), `PaperBroker`, chiavi DPAPI, `--headless`; cross sintetici, indicatori, decisore, cost gate, sizing, esecutore con protocollo leg-risk, backtest event-driven e griglia con PSR/DSR/PBO/walk-forward; calendario, RSS, sentiment, ledger; livelli di apprendimento 0-3 nel Core.
|
||||
- Documenti: `CLAUDE.md`, `docs/ARCHITECTURE.md`, `docs/QUESTIONS.md`, `docs/STATE.md`, `DATA_SOURCES.md`, `LEDGER_SCHEMA.md`, `RISK_RULES.md`, ADR-0001 (eToro), ADR-0002 (storage su file), ADR-0003 (motore cTrader mantenuto selezionabile; superata da ADR-0004).
|
||||
@@ -0,0 +1,90 @@
|
||||
# Encelado — guida per chi lavora sul repository (umano o AI)
|
||||
|
||||
**Leggi prima `docs/STATE.md`.** È la memoria di lavoro fra una sessione e l'altra: dice a che fase siamo, cosa è stato fatto per ultimo e cosa manca.
|
||||
|
||||
## Scopo
|
||||
|
||||
Bot di trading in C# (.NET 10) su **eToro** con la strategia "Correlation Baskets": cinque basket di due coppie forex correlate, ingresso quando il cross sintetico diverge (z-score), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale, ledger completo, feed gratuiti di calendario e notizie, livelli di apprendimento 0-3 costruiti da zero. È l'unica strategia del repository: i motori precedenti (Binance, cTrader/proba, ricerca) sono stati rimossi il 2026-09-16 (ADR-0004) e vivono solo nella storia git. Il bot opera da solo in ogni modalità (ADR-0005): `Paper`, `Demo` (default), `Live`. Dalla 5.0 gira **solo in un container** (ADR-0007, ADR-0008): un processo `Encelado.Server` che ospita il motore e serve l'interfaccia web; la finestra WPF non esiste più.
|
||||
|
||||
## Mappa dei documenti
|
||||
|
||||
| File | Contenuto |
|
||||
|---|---|
|
||||
| `docs/STATE.md` | stato corrente, fase, ultima sessione, prossimi passi, problemi aperti |
|
||||
| `docs/ARCHITECTURE.md` | progetti, flusso dati, macchine a stati, interfacce, API web |
|
||||
| `docs/STRATEGY.md` | logica dei basket, cross sintetici, formule, preset, aspettative oneste, numeri |
|
||||
| `docs/ML_AND_LEARNING.md` | livelli 0-3, feature, label, addestramento, attivazione, esclusioni, comando `learn` |
|
||||
| `docs/DATA_SOURCES.md` | ogni fonte (URL, formato, limiti), schema dei file in `data/` |
|
||||
| `docs/LEDGER_SCHEMA.md` | schema di `decisions.jsonl`, `orders.jsonl`, `baskets.csv`, `trials.csv`, `calibration.csv`, `preregistrazione.csv`, `proposals.csv` |
|
||||
| `docs/RISK_RULES.md` | regole di sicurezza con i default e chi può cambiarle |
|
||||
| `docs/RUNBOOK.md` | avvio, arresto, kill-switch, reset, riconciliazione, chiavi, errori API, checklist |
|
||||
| `docs/DOCKER.md`, `deploy/unraid/README.md` | immagine, volumi, variabili, healthcheck, aggiornamento; template Unraid |
|
||||
| `docs/UI_GUIDELINES.md` | l'interfaccia web: token Material 3, componenti, pagine, screenshot |
|
||||
| `docs/QUESTIONS.md` | domande poste per fase, risposte o default applicati, con data |
|
||||
| `docs/PIANO_5.0.md`, `docs/PROMPT_5.0.md`, `docs/POSTMORTEM_ordini_pendenti.md` | il piano della 5.0 (fasi, stime, decisioni vincolanti), la specifica originale e il post-mortem delle gambe orfane del 16-21/9/2026 |
|
||||
| `docs/GLOSSARY.md`, `docs/KNOWN_ISSUES.md`, `CHANGELOG.md`, `docs/adr/` | glossario, problemi noti, cronologia, decisioni architetturali |
|
||||
| `build/README.md` | catena di verifica, pacchetto (immagine Docker) e rilascio; `.gitea/workflows/` per le Actions |
|
||||
|
||||
## Progetti
|
||||
|
||||
```
|
||||
src/Encelado.Core logica pura, zero I/O, AOT/trim-compatibile
|
||||
src/Encelado.Etoro client HTTP di eToro Public API
|
||||
src/Encelado.Engine motore, ledger, feed, configurazione, impostazioni, storico, Telegram (ex Encelado.Bot senza UI)
|
||||
src/Encelado.Server l'eseguibile: Kestrel, API JSON scritta a mano, SSE, UI incorporata (Web/wwwroot)
|
||||
tools/Encelado.Backtest ticks, baskets, falsify, learn
|
||||
tests/Encelado.Tests xunit
|
||||
deploy/docker, deploy/unraid entrypoint del container, template Unraid
|
||||
```
|
||||
|
||||
## Convenzioni
|
||||
|
||||
- **C#**, `Nullable` e `TreatWarningsAsErrors` attivi. Identificatori e commenti tecnici in inglese; documentazione, report e colonna `motivazione` in italiano.
|
||||
- **Nessun pacchetto NuGet.** Solo BCL e i framework reference dell'SDK (`Microsoft.AspNetCore.App` nel Server) nei progetti dell'applicazione; xunit nei test. Nessuna libreria JavaScript o CSS: l'interfaccia è HTML, CSS e JS vanilla incorporati.
|
||||
- **Tabelle**: CSV con separatore `;`, header, ultima colonna `motivazione`; JSONL append-only per ledger e notizie; JSON per modelli e stato. Scritture atomiche (`.tmp` + `File.Move`), rotazione mensile. **Nessuna riga del ledger viene mai modificata**: le correzioni sono righe nuove con `evento = correzione`.
|
||||
- **Tempo**: UTC ovunque; conversione solo in UI. `CultureInfo.InvariantCulture` per ogni parsing e formattazione su file.
|
||||
- **Concorrenza**: un solo thread di decisione; I/O asincrono; `Channel<T>` fra ingestion, strategia, esecuzione e UI.
|
||||
- **Riproducibilità**: seed fisso 42 per ogni componente stocastica; ogni run scrive `run_id`, hash della configurazione e versione del codice nel ledger.
|
||||
- **Cartelle a runtime**: nel container `/config` (encelado.json, strategy.json, instruments.json, `etoro.keys.enc`, file `STOP`) e `/data` (`data/`, `knowledge/`, `reports/`, `results/`, `logs/`); fuori dal container (solo sviluppo) la sandbox `deploy/local/` (`ENCELADO_CONFIG_DIR` + `ENCELADO_DATA_DIR`, usata da F5 e dagli script) o, senza variabili, `Documenti\Encelado\`. Credenziali solo nelle variabili `ETORO_API_KEY`/`ETORO_USER_KEY` o nel file cifrato (AES-256-GCM, passphrase `ENCELADO_KEY_PASSPHRASE`); token Telegram solo in `TELEGRAM_BOT_TOKEN` (chat in `TELEGRAM_CHAT_ID`); token dell'interfaccia in `ENCELADO_WEB_TOKEN`.
|
||||
- **Esecuzione (5.0)**: ogni ordine entra nel registro `data/state/pending_orders.json` prima dell'HTTP e l'esito si legge per `orderId` (il server non registra il `referenceId`); una gamba senza esito porta il basket in `PendingA`/`PendingB`, mai in un rifiuto; ogni posizione del conto è `basket`, `orfana-bot` (adottata e chiusa) o `esterna` (mai toccata); la size è il minimo fra rischio e margine (`strategy.json` → `risk`); il kill-switch verifica la piattezza sul conto e dichiara `Halted-Residuo` se resta qualcosa; heartbeat e recupero dopo inattività (`recovery`); un solo bot per cartella dati (`instance.lock`).
|
||||
- **Interfaccia**: navigation rail a sinistra (Dashboard, Storico ordini, Log, Impostazioni), barra in alto (titolo, ora UTC e nel fuso, valuta, AVVIA/FERMA, KILL-SWITCH), pagine sotto. Nella dashboard solo le informazioni principali; i dettagli nei tooltip e nel log. La UI non decide niente: legge lo snapshot via SSE e manda comandi via `POST /api/commands/<nome>`. Gli orari a schermo sono nel fuso scelto (`ui.timeZone`, `TZ`); il log porta l'offset, il ledger è UTC. Regole complete in `docs/UI_GUIDELINES.md`.
|
||||
- **Verifica visiva**: `scripts\screenshots.ps1` (server `--sample` + Edge headless, `docs/UI_GUIDELINES.md`); i test `EmbeddedUiTests` e `WebHostTests` coprono HTML, JSON, token e stream.
|
||||
|
||||
## Comandi
|
||||
|
||||
```powershell
|
||||
dotnet build Encelado.slnx # compilazione
|
||||
dotnet test tests/Encelado.Tests --no-restore # test (xunit)
|
||||
dotnet msbuild build/Release.proj -t:Verifica # compilazione + test nella cartella di verifica
|
||||
dotnet run --project src/Encelado.Server -- [--no-autostart] [--sample] [--port 8080] [--minutes 240] [--bonifica] [--confirm-live "CONFERMO LIVE"] # server locale; comandi da tastiera: status, close, kill, residuo, preset, reset, bonifica, stop
|
||||
scripts\run-dev.ps1 [-Sample] [-Autostart] # server locale nella sandbox deploy/local (= F5 in VS Code); .sh per Git Bash
|
||||
scripts\run-docker.ps1 [-Down] # il container dalla compose, stessa sandbox, token "sviluppo"
|
||||
scripts\screenshots.ps1 # docs/img dal server campione
|
||||
dotnet run --project tools/Encelado.Backtest -- ticks --data "A:\Download\Trading" --out "%USERPROFILE%\Documents\Encelado\data\market"
|
||||
dotnet run --project tools/Encelado.Backtest -- baskets --data "%USERPROFILE%\Documents\Encelado\data\market" --out results
|
||||
dotnet run --project tools/Encelado.Backtest -- falsify --data "%USERPROFILE%\Documents\Encelado\data\market" --out reports [--costs api]
|
||||
dotnet run --project tools/Encelado.Backtest -- learn --data "%USERPROFILE%\Documents\Encelado\data" [--knowledge …] [--strategy config/strategy.json]
|
||||
dotnet msbuild build/Release.proj -t:Docker # immagine 192.168.30.23:3000/alby96/encelado:<v> (test dentro la build)
|
||||
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=5.0.0 # zip + immagine + tag + push dell'immagine + release su Gitea (dopo il commit e il push del ramo)
|
||||
docker compose up --build # il container in locale (deploy/local/, segreti in .env)
|
||||
```
|
||||
|
||||
## Regole
|
||||
|
||||
1. **Chiedi se hai un dubbio.** Le domande vanno in `docs/QUESTIONS.md`, numerate, con il default che applicheresti; in assenza di risposta applica il default più prudente e annotalo.
|
||||
2. **Mai un ordine reale senza flag e conferma.** `run.executionMode` predefinito `Demo`; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` (dialogo dell'interfaccia, `--confirm-live`, o `ENCELADO_CONFIRM_LIVE`). Nessuna approvazione per singolo ordine (D-20): il bot opera da solo.
|
||||
3. **Mai una riga del ledger modificata.**
|
||||
4. **Mai un risultato abbellito.** Se la strategia non regge i costi di eToro, il report lo dice con i numeri. "Nessuna configurazione profittevole" è un esito ammesso.
|
||||
5. **Non toccare la catena di rilascio** (`build/`) se non richiesto; è condivisa con Mimante/AutoBidder.
|
||||
6. **Un commit a fine sessione**, dopo che la verifica passa, con un messaggio che dice cosa cambia e perché. Il push lo decide l'utente.
|
||||
7. Aggiorna `docs/STATE.md` e `CHANGELOG.md` a fine sessione; un ADR per ogni scelta non ovvia.
|
||||
8. Le skill di progetto in `.claude/skills/` (`encelado-start`, `encelado-verify`, `encelado-diagnose`, `encelado-docs`, `encelado-release`, `encelado-ui`) dicono come si comincia, si verifica, si diagnostica, si chiude e si rilascia: usale.
|
||||
|
||||
## Cose da non fare
|
||||
|
||||
- Non scrivere chiavi in chat, nel log, nel repo, in `Documenti` o nel template Unraid.
|
||||
- Non usare spread fissi nel cost gate: sempre lo spread reale letto dall'API in quel momento più il markup dell'endpoint dei costi.
|
||||
- Non ricostruire feature a posteriori: il dataset di addestramento è il ledger scritto al momento della decisione.
|
||||
- Non cambiare parametri live in automatico: le proposte passano da `knowledge/proposals.csv` e dal forward test.
|
||||
- Non reintrodurre un'interfaccia desktop né librerie JavaScript: l'interfaccia è servita dal bot ed è vanilla (ADR-0007).
|
||||
- Non usare heredoc lunghi o con backslash nel Bash tool: usare `Write`/`Edit` (vedi memoria `strumenti-heredoc-backslash`).
|
||||
@@ -0,0 +1,45 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<NeutralLanguage>en</NeutralLanguage>
|
||||
<Deterministic>true</Deterministic>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<Product>Encelado</Product>
|
||||
<Company>Encelado</Company>
|
||||
<!-- Numero delle compilazioni di sviluppo: è quello che compare in
|
||||
Impostazioni ▸ Informazioni mentre si lavora. La versione RILASCIATA viene
|
||||
dal tag git — vedi build/Release.proj — e questo serve solo da seme quando
|
||||
non esiste ancora nessun tag. Tenerlo allineato all'ultimo rilascio evita
|
||||
di leggere a schermo un numero che non corrisponde a niente. -->
|
||||
<Version>5.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Hot-path tuning. The bot is a latency-sensitive process: we want the server GC
|
||||
(background, multiple heaps), full PGO and no culture-dependent parsing on the
|
||||
market-data decode path.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<ServerGarbageCollection>true</ServerGarbageCollection>
|
||||
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||
<TieredCompilationQuickJitForLoops>true</TieredCompilationQuickJitForLoops>
|
||||
<TieredPGO>true</TieredPGO>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<UseSystemResourceKeys>true</UseSystemResourceKeys>
|
||||
<EventSourceSupport>false</EventSourceSupport>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Everything on the runtime path must stay reflection-free so PublishAot works. -->
|
||||
<PropertyGroup Condition="'$(MSBuildProjectName)' != 'Encelado.Tests'">
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,90 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Encelado — immagine del bot (motore + interfaccia web su Kestrel).
|
||||
#
|
||||
# Tre stadi: build (restore + compilazione), test (la suite xunit gira dentro la
|
||||
# build: un'immagine con i test rossi non esiste), publish (framework-dependent,
|
||||
# non trimmed: il server ospita ASP.NET Core minimal e non è AOT). Il runtime è
|
||||
# l'immagine ufficiale aspnet, con gosu per scendere ai PUID/PGID di Unraid.
|
||||
#
|
||||
# docker build -t 192.168.30.23:3000/alby96/encelado:5.0.0 --build-arg GIT_COMMIT=$(git rev-parse --short=12 HEAD) .
|
||||
# docker run -p 8080:8080 -v ./config:/config -v ./data:/data -e TZ=Europe/Rome 192.168.30.23:3000/alby96/encelado:5.0.0
|
||||
#
|
||||
# Vedi docs/DOCKER.md per volumi, variabili, healthcheck e aggiornamento.
|
||||
|
||||
ARG DOTNET_VERSION=10.0
|
||||
|
||||
# ---------------------------------------------------------------- build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||
ARG GIT_COMMIT=n/d
|
||||
WORKDIR /src
|
||||
|
||||
# Prima i file di progetto, così il restore resta in cache finché non cambiano.
|
||||
COPY Directory.Build.props Encelado.slnx ./
|
||||
COPY src/Encelado.Core/Encelado.Core.csproj src/Encelado.Core/
|
||||
COPY src/Encelado.Etoro/Encelado.Etoro.csproj src/Encelado.Etoro/
|
||||
COPY src/Encelado.Engine/Encelado.Engine.csproj src/Encelado.Engine/
|
||||
COPY src/Encelado.Server/Encelado.Server.csproj src/Encelado.Server/
|
||||
COPY tools/Encelado.Backtest/Encelado.Backtest.csproj tools/Encelado.Backtest/
|
||||
COPY tests/Encelado.Tests/Encelado.Tests.csproj tests/Encelado.Tests/
|
||||
RUN dotnet restore Encelado.slnx
|
||||
|
||||
COPY . .
|
||||
RUN dotnet build Encelado.slnx -c Release --no-restore -p:GitCommit=${GIT_COMMIT}
|
||||
|
||||
# ---------------------------------------------------------------- test
|
||||
FROM build AS test
|
||||
RUN dotnet test tests/Encelado.Tests -c Release --no-build --nologo -v q
|
||||
|
||||
# ---------------------------------------------------------------- publish
|
||||
FROM build AS publish
|
||||
ARG GIT_COMMIT=n/d
|
||||
# Dipende dallo stadio test solo per ordine: la publish parte se i test sono verdi.
|
||||
COPY --from=test /src/tests/Encelado.Tests/bin/Release/net10.0/Encelado.Tests.dll /tmp/tests-ok
|
||||
RUN dotnet publish src/Encelado.Server/Encelado.Server.csproj -c Release --no-build --no-restore -p:GitCommit=${GIT_COMMIT} -o /app
|
||||
|
||||
# ---------------------------------------------------------------- runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
|
||||
ARG VERSION=dev
|
||||
ARG GIT_COMMIT=n/d
|
||||
LABEL org.opencontainers.image.title="Encelado" \
|
||||
org.opencontainers.image.description="Correlation Baskets su eToro: motore, ledger e interfaccia web" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.revision="${GIT_COMMIT}" \
|
||||
org.opencontainers.image.authors="Alberto Balbo" \
|
||||
org.opencontainers.image.source="http://192.168.30.23:3000/Alby96/Encelado"
|
||||
|
||||
# gosu per cambiare utente dopo aver sistemato i permessi; tzdata per TZ; curl
|
||||
# non serve: il healthcheck è il server stesso con --health.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gosu tzdata ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ENCELADO_IN_CONTAINER=1 \
|
||||
ENCELADO_WEB_PORT=8080 \
|
||||
ENCELADO_AUTOSTART=1 \
|
||||
DOTNET_gcServer=1 \
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
|
||||
ASPNETCORE_URLS= \
|
||||
ASPNETCORE_HTTP_PORTS= \
|
||||
TZ=UTC \
|
||||
PUID=99 \
|
||||
PGID=100
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app ./
|
||||
COPY deploy/docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh && mkdir -p /config /data
|
||||
|
||||
VOLUME ["/config", "/data"]
|
||||
EXPOSE 8080
|
||||
|
||||
# Il server risponde da solo alla sonda: GET /api/health sulla porta configurata.
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD ["dotnet", "/app/Encelado.Server.dll", "--health"]
|
||||
|
||||
# SIGTERM arriva al processo dotnet (exec nell'entrypoint): stop pulito, ledger
|
||||
# chiuso, posizioni lasciate sul conto con gli stop nativi (closeOnShutdown).
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["dotnet", "/app/Encelado.Server.dll"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Encelado.Engine/Encelado.Engine.csproj" />
|
||||
<Project Path="src/Encelado.Server/Encelado.Server.csproj" />
|
||||
<Project Path="src/Encelado.Core/Encelado.Core.csproj" />
|
||||
<Project Path="src/Encelado.Etoro/Encelado.Etoro.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Encelado.Tests/Encelado.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tools/">
|
||||
<Project Path="tools/Encelado.Backtest/Encelado.Backtest.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,2 @@
|
||||
Ottimo! Ora effettua le seguenti modifiche:
|
||||
-
|
||||
@@ -0,0 +1,72 @@
|
||||
# Encelado
|
||||
|
||||
Bot di trading **Correlation Baskets** su eToro (Public API): cinque basket di due coppie forex correlate, ingresso quando il cross sintetico diverge (z-score), uscita quando converge o al take-profit di basket, stop di basket obbligatorio, cost gate sullo spread reale, ledger completo, calendario e notizie gratuiti, apprendimento in ombra. Dalla 5.0 gira **solo in un container** e si guarda dal browser.
|
||||
|
||||
> Il backtest è negativo: la strategia non regge i costi di eToro (`docs/STRATEGY.md`). Il bot è uno strumento di forward test in Demo, non un sistema da mettere sul reale.
|
||||
|
||||

|
||||
|
||||
## Avvio rapido (Docker)
|
||||
|
||||
```powershell
|
||||
docker pull 192.168.30.23:3000/alby96/encelado:latest
|
||||
docker run -d --name encelado --restart unless-stopped -p 8080:8080 `
|
||||
-v /mnt/user/appdata/encelado/config:/config -v /mnt/user/appdata/encelado/data:/data `
|
||||
-e ENCELADO_WEB_TOKEN=<una-stringa-lunga> -e TZ=Europe/Rome `
|
||||
-e ETORO_API_KEY=<x-api-key> -e ETORO_USER_KEY=<x-user-key> `
|
||||
192.168.30.23:3000/alby96/encelado:latest
|
||||
```
|
||||
|
||||
Poi `http://<ip>:8080/`, token una volta, **AVVIA**. Su Unraid: template in `deploy/unraid/` (icona, porte, volumi e variabili già descritti). Tutto il resto in `docs/DOCKER.md`.
|
||||
|
||||
Senza token il server ascolta solo su localhost del container. La modalità `Live` richiede `run.allowLive = true`, `ETORO_ENVIRONMENT=real` e la frase `CONFERMO LIVE` in `ENCELADO_CONFIRM_LIVE`.
|
||||
|
||||
## Le pagine
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Storico ordini**: ordini (unità richieste ed eseguite, slippage, esito), posizioni classificate `basket` / `orfana-bot` / `esterna` / `movimento di cassa`, profitti per periodo con la curva dell'equity, esportazione CSV. | **Log** con filtro, ricerca e «Segui». |
|
||||
|  |  |
|
||||
| **Impostazioni**: configurazione validata, chiavi eToro cifrate, ripristino in cinque passi, Ricerca (modello in ombra, ciclo di apprendimento), Diagnostica (percorsi, quote, bonifica), Informazioni (versione, build, commit). | Sotto i 600 px il rail diventa un menu e i basket diventano cards. |
|
||||
|
||||
## Provarlo in locale
|
||||
|
||||
Tre modi, tutti sulla stessa sandbox `deploy/local/` (ignorata da git: `config/` è il `/config` del container, `data/` il `/data`), così configurazione e chiavi salvate valgono ovunque:
|
||||
|
||||
| Modo | Come | Cosa ottieni |
|
||||
|---|---|---|
|
||||
| **F5 in VS Code** | configurazione `Encelado (server)` | server locale su `http://localhost:8080/`, browser aperto da solo, motore fermo finché non premi **AVVIA**; `Encelado (campione)` mostra le pagine con dati finti senza chiavi |
|
||||
| **Script** | `scripts\run-dev.ps1` (`-Sample`, `-Autostart`, `-Port`) o `sh scripts/run-dev.sh` | lo stesso processo da terminale, con i comandi da tastiera (`status`, `kill`, `stop`…) |
|
||||
| **Container** | `scripts\run-docker.ps1` (o `sh scripts/run-docker.sh`, o `docker compose up -d --build`) | l'immagine vera su `http://localhost:8080/`, token `sviluppo`; `-Down` per fermare con SIGTERM |
|
||||
|
||||
Le chiavi eToro: variabili `ETORO_API_KEY`/`ETORO_USER_KEY` nell'ambiente (per il compose in un file `.env` accanto alla `docker-compose.yml`), oppure **Impostazioni ▸ Chiavi eToro** nella pagina con `ENCELADO_KEY_PASSPHRASE` impostata (vengono verificate e salvate cifrate in `deploy/local/config/etoro.keys.enc`). Senza chiavi il motore non parte e la pagina lo dice; lo storico mostra solo il ledger.
|
||||
|
||||
```powershell
|
||||
dotnet build Encelado.slnx # compilazione
|
||||
dotnet test tests/Encelado.Tests --no-restore # test (xunit)
|
||||
dotnet msbuild build/Release.proj -t:Verifica # compilazione + test nella cartella di verifica
|
||||
dotnet msbuild build/Release.proj -t:Docker # l'immagine, con i test dentro la build
|
||||
scripts\screenshots.ps1 # rigenera docs/img dal server campione
|
||||
```
|
||||
|
||||
Attività di VS Code: `verifica`, `backtest`, `avvia in locale (script)`, `avvia in docker`, `ferma docker`, `screenshot`, `immagine docker`, `pacchetto`, `rilascia su Gitea`. Su Gitea, `.gitea/workflows/ci.yml` verifica ogni push e `release.yml` pubblica l'immagine a ogni tag (serve un runner: `docs/DOCKER.md`).
|
||||
|
||||
```
|
||||
src/Encelado.Core logica pura: basket, cross sintetici, decisore, cost gate, sizing, esecutore, registro ordini, kill-switch, apprendimento, notifiche
|
||||
src/Encelado.Etoro client HTTP di eToro Public API
|
||||
src/Encelado.Engine motore, ledger, feed, configurazione, impostazioni, storico, Telegram
|
||||
src/Encelado.Server l'eseguibile: Kestrel, API JSON, SSE, interfaccia incorporata (Material 3, vanilla)
|
||||
tools/Encelado.Backtest ticks, baskets, falsify, learn
|
||||
tests/Encelado.Tests xunit
|
||||
deploy/ docker (entrypoint), unraid (template), local (ignorato: sandbox di F5 e del compose)
|
||||
scripts/ run-dev, run-docker, screenshots (.ps1 e .sh)
|
||||
build/ catena di verifica, pacchetto e rilascio (MSBuild)
|
||||
docs/ STATE, ARCHITECTURE, STRATEGY, RUNBOOK, DOCKER, UI_GUIDELINES, ADR…
|
||||
```
|
||||
|
||||
Leggi `CLAUDE.md` per le convenzioni e `docs/STATE.md` per lo stato del lavoro.
|
||||
|
||||
## Autore e licenza
|
||||
|
||||
Alberto Balbo. Uso personale; nessuna licenza di ridistribuzione. Nessuna garanzia: un bot che opera su un conto reale può perdere denaro.
|
||||
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,129 @@
|
||||
# Catena di verifica, pacchetto e rilascio
|
||||
|
||||
Tutto quello che serve a controllare, impacchettare e pubblicare Encelado sta in questa
|
||||
cartella. La radice del progetto contiene solo la `Dockerfile` e la `docker-compose.yml`.
|
||||
|
||||
È la stessa catena di [Mimante/AutoBidder](http://192.168.30.23:3000/Alby96/Mimante),
|
||||
adattata a una soluzione con più progetti e a un pacchetto che è un'immagine Docker
|
||||
(dalla 5.0, ADR-0008). Le differenze sono segnate sul posto in `Release.proj`:
|
||||
|
||||
| | AutoBidder | Encelado |
|
||||
|---|---|---|
|
||||
| Dove sta la versione | `AutoBidder.csproj` | `Directory.Build.props`, ereditato da tutti i progetti |
|
||||
| Cosa produce `dotnet publish` | un eseguibile unico | una cartella framework-dependent: la stessa che il `Dockerfile` mette in `/app` |
|
||||
| Il pacchetto | installatore Inno Setup | immagine Docker `192.168.30.23:3000/alby96/encelado:<versione>` (+ `:latest`) |
|
||||
| Allegati della release | `.exe` e installatore | zip portabile e template Unraid |
|
||||
| Cosa rigioca `Backtest` | i dossier delle aste | barre M15 bid/ask dei basket (+ `learn` sul ledger) |
|
||||
|
||||
| File | Cos'è |
|
||||
|---|---|
|
||||
| `Release.proj` | La catena. Un solo file MSBuild, nessuno script. |
|
||||
| `gitea.example.json` | Modello per `gitea.json` (che è escluso dal controllo di versione). |
|
||||
|
||||
## Da VS Code
|
||||
|
||||
**Terminale ▸ Esegui attività…**
|
||||
|
||||
| Attività | Cosa fa |
|
||||
|---|---|
|
||||
| `verifica` | Compila e lancia i test. |
|
||||
| `backtest` | Ricerca sui basket: `ticks`, `baskets`, `falsify`, `learn`. |
|
||||
| `immagine docker` | Costruisce l'immagine (i test girano dentro la build). Non pubblica. |
|
||||
| `pacchetto` | Verifica, cartella portabile, immagine, tag a pacchetto pronto. Non tocca Gitea. |
|
||||
| `rilascia su Gitea` | Tutto quanto sopra, più push dell'immagine sul registro di Gitea e release con gli allegati. |
|
||||
| `docker compose up` | Il container in locale con le cartelle di `deploy/local/`. |
|
||||
|
||||
## Da riga di comando
|
||||
|
||||
```powershell
|
||||
dotnet msbuild build/Release.proj -t:Verifica
|
||||
dotnet msbuild build/Release.proj -t:Docker
|
||||
dotnet msbuild build/Release.proj -t:Pacchetto
|
||||
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=5.0.0 -p:Note="Cosa cambia"
|
||||
|
||||
# La ricerca sui basket: barre M15 in data/market (da `ticks`), tabelle in results/ e reports/
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data\market"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=falsify -p:Extra="--costs api"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="A:\Download\Trading" -p:Comando=ticks
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data" -p:Comando=learn
|
||||
```
|
||||
|
||||
| Proprietà | Predefinito | A cosa serve |
|
||||
|---|---|---|
|
||||
| `Versione` | vuoto | Vuoto = incrementa la minor (5.0.0 → 5.1.0). Altrimenti la scrive, se ha la forma `X.Y.Z`. |
|
||||
| `Note` | vuoto | Note di rilascio (meglio via `ENCELADO_NOTE`, vedi sotto). |
|
||||
| `Immagine` | `192.168.30.23:3000/alby96/encelado` | Nome completo dell'immagine. |
|
||||
| `SaltaVerifica` | `false` | Non rieseguire i test (girano comunque dentro `docker build`). |
|
||||
| `SaltaDocker` | `false` | Solo lo zip portabile, niente immagine né push. |
|
||||
| `Sovrascrivi` | `false` | Sostituisci una release Gitea con lo stesso tag. |
|
||||
| `Bozza` | `false` | Crea la release come bozza. |
|
||||
| `ConsentiModifiche` | `false` | Tagga anche con l'albero sporco. Serve saperlo. |
|
||||
| `Dati` | — | La cartella dei dati. Obbligatoria per `Backtest`. |
|
||||
| `Comando` | `baskets` | `ticks`, `baskets`, `falsify`, `learn`. |
|
||||
| `Extra` | vuoto | Altre opzioni passate allo strumento così come sono, es. `--costs api --quick`. |
|
||||
|
||||
## Cosa succede in `Rilascia`
|
||||
|
||||
1. `ConfigGitea`: legge url, owner, repo e token (ambiente o `gitea.json`); senza, si ferma prima di compilare.
|
||||
2. `Verifica`: `dotnet test` nella cartella di verifica (`%TEMP%\Encelado.Verifica`).
|
||||
3. `Pubblica`: `dotnet publish` framework-dependent di `Encelado.Server` in `bin/publish/portable`, con la versione da riga di comando; controlli su `Encelado.Server.dll`, `config/encelado.json` e sull'assenza di sorgenti.
|
||||
4. `Docker`: `docker build --pull` con `VERSION` e `GIT_COMMIT` come build-arg; l'immagine ha i tag `:<versione>` e `:latest`. La `Dockerfile` esegue i test nel suo stadio `test`.
|
||||
5. `Pacchetto`: zip portabile `bin/installer/Encelado_<v>_portabile.zip`, copia del template `deploy/unraid/encelado.xml` con la versione nel nome, **tag** `v<versione>` (solo ora, a pacchetto pronto, e solo con l'albero pulito).
|
||||
6. `Rilascia`: `git push --tags` e verifica che il tag sia sul remoto; `docker login` sul registro di Gitea con il token (via stdin), `docker push` dei due tag, `docker logout`; release su Gitea con lo zip e il template allegati.
|
||||
|
||||
## Gitea Actions
|
||||
|
||||
`.gitea/workflows/ci.yml` (build + test + `docker build` a ogni push) e `release.yml` (immagine sul registro e release a ogni tag `v*`) fanno sul server quello che `Verifica` e `Rilascia` fanno dal PC. Richiedono un runner registrato e il segreto `REGISTRY_TOKEN`: vedi `docs/DOCKER.md`, «Gitea Actions». La catena resta il percorso di riferimento finché il runner non c'è.
|
||||
|
||||
## Chi chiede la versione
|
||||
|
||||
MSBuild non può chiedere niente a nessuno: è un motore di compilazione. La domanda la fa
|
||||
l'attività di VS Code (`inputs` in `.vscode/tasks.json`) e passa la risposta in
|
||||
`-p:Versione=`. Lasciando il campo vuoto si prende la minor successiva, che è il caso
|
||||
normale di fine sessione.
|
||||
|
||||
**La versione arriva dal tag e non viene scritta da nessuna parte.** `dotnet publish` e
|
||||
`docker build` la ricevono come proprietà da riga di comando, che è globale e vince su
|
||||
quella dichiarata in `Directory.Build.props`. Tag, assembly, immagine e release portano
|
||||
quindi lo stesso numero per costruzione. Il commit e la data di build finiscono in
|
||||
`AssemblyMetadata` (Impostazioni ▸ Informazioni, `/api/info`).
|
||||
|
||||
Il tag si crea **in fondo**, quando lo zip e l'immagine esistono davvero. Il contrario
|
||||
sembra più naturale — decidi il numero, poi costruisci — ma lascia dietro un tag quando la
|
||||
verifica fallisce, e il tentativo dopo riparte da lì.
|
||||
|
||||
## Gitea
|
||||
|
||||
Servono quattro valori. Le variabili d'ambiente hanno la precedenza sul file, così una
|
||||
macchina condivisa può rilasciare senza scrivere un token su disco:
|
||||
|
||||
- `GITEA_URL`, `GITEA_OWNER`, `GITEA_REPO`, `GITEA_TOKEN`
|
||||
- oppure `build/gitea.json`, copiato da `gitea.example.json`
|
||||
|
||||
Il token si crea in Gitea da *Impostazioni ▸ Applicazioni ▸ Genera nuovo token*, con i
|
||||
permessi `repository: read and write` **e** `package: read and write` (serve per il
|
||||
registro dei container). Il registro è lo stesso host di Gitea senza schema
|
||||
(`192.168.30.23:3000`): in HTTP va dichiarato `insecure-registry` a Docker Desktop
|
||||
(*Settings ▸ Docker Engine*).
|
||||
|
||||
Il token non passa mai dalla riga di comando: sta in un file di configurazione di curl e
|
||||
in un file letto da `docker login --password-stdin`, entrambi cancellati subito dopo. Gli
|
||||
`Exec` hanno `EchoOff` perché un registro di compilazione è la classica cosa che si
|
||||
incolla in una chat.
|
||||
|
||||
## Una trappola già pagata
|
||||
|
||||
`dotnet test` e `dotnet publish` lanciati **da dentro** MSBuild ereditano l'ambiente del
|
||||
processo padre: `MSBUILD_EXE_PATH` e `MSBuildLoadMicrosoftTargetsReadOnly` puntate al
|
||||
build in corso confondono il figlio. Gli `Exec` azzerano **solo quelle due**: la ricetta
|
||||
che gira in rete azzera anche `MSBuildExtensionsPath` e `MSBuildSDKsPath`, e così il
|
||||
figlio perde la posizione dell'SDK.
|
||||
|
||||
I test girano in una cartella a parte (`--artifacts-path`) perché un server lasciato
|
||||
acceso da VS Code tiene bloccata `Encelado.Server.dll` nella `bin/` di lavoro.
|
||||
|
||||
## Prerequisiti
|
||||
|
||||
- .NET SDK 10
|
||||
- Docker Desktop (o un demone Docker raggiungibile) per `Docker`, `Pacchetto`, `Rilascia`; `-p:SaltaDocker=true` per farne a meno
|
||||
- `curl` e `git`, entrambi di serie in Windows 11
|
||||
@@ -0,0 +1,731 @@
|
||||
<!--
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
Encelado — catena di verifica, pacchetto e rilascio
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Un solo file, nessuno script. Si richiama con `dotnet msbuild`, dalle attività
|
||||
di VS Code (Terminale ▸ Esegui attività…) oppure a mano:
|
||||
|
||||
dotnet msbuild build/Release.proj -t:Verifica
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati=...
|
||||
dotnet msbuild build/Release.proj -t:Docker
|
||||
dotnet msbuild build/Release.proj -t:Pacchetto
|
||||
dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=5.0.0
|
||||
|
||||
È la stessa catena di Mimante/AutoBidder, adattata a una soluzione con più
|
||||
progetti. Le differenze rispetto a quel file sono quattro, tutte segnate sul
|
||||
posto: la versione vive in Directory.Build.props e non nel .csproj, la
|
||||
pubblicazione produce una cartella e non un singolo eseguibile, il pacchetto
|
||||
è un'immagine Docker e non un installatore Windows (dalla 5.0, ADR-0007 e
|
||||
ADR-0008: il bot gira solo nel container), e il target Backtest rigioca
|
||||
coppie di serie storiche di prezzi invece dei dossier delle aste.
|
||||
|
||||
── Perché MSBuild e non uno script ──────────────────────────────────────
|
||||
La catena vive accanto al codice che rilascia ed è versionata con lui: fra
|
||||
sei mesi, ripescato un tag, questo file ricostruisce quel pacchetto e non
|
||||
quello di oggi. La logica non banale (leggere e riscrivere la versione,
|
||||
parlare con Gitea) sta in attività C# in linea: si legge come codice, non
|
||||
come una successione di comandi.
|
||||
|
||||
── Da dove viene la versione ────────────────────────────────────────────
|
||||
Dal tag git, e da nient'altro. Directory.Build.props non viene mai riscritto:
|
||||
il numero arriva a `dotnet publish` e a `docker build` come proprietà da riga
|
||||
di comando, quindi tag, assembly, immagine e release portano lo stesso numero
|
||||
per costruzione, non per disciplina.
|
||||
|
||||
Il modo previsto è taggare e poi rilasciare:
|
||||
|
||||
git tag v5.0.0
|
||||
dotnet msbuild build/Release.proj -t:Rilascia
|
||||
|
||||
Se HEAD non ha un tag di versione la catena lo crea da sé — con il numero
|
||||
passato in `-p:Versione=`, oppure la minor successiva all'ultimo tag — e lo
|
||||
fa in fondo, quando l'immagine esiste davvero. Un giro andato male non
|
||||
lascia dietro un tag per una versione che non è mai stata costruita.
|
||||
|
||||
Il numero in Directory.Build.props resta quello delle compilazioni di
|
||||
sviluppo (Impostazioni ▸ Informazioni mentre si lavora) e serve solo come
|
||||
seme al primissimo rilascio, quando non esiste ancora nessun tag.
|
||||
-->
|
||||
<Project DefaultTargets="Pacchetto" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
|
||||
<PropertyGroup>
|
||||
<Radice>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..'))</Radice>
|
||||
<Csproj>$(Radice)\src\Encelado.Server\Encelado.Server.csproj</Csproj>
|
||||
<TestProj>$(Radice)\tests\Encelado.Tests\Encelado.Tests.csproj</TestProj>
|
||||
<BacktestProj>$(Radice)\tools\Encelado.Backtest\Encelado.Backtest.csproj</BacktestProj>
|
||||
<Dockerfile>$(Radice)\Dockerfile</Dockerfile>
|
||||
<TemplateUnraid>$(Radice)\deploy\unraid\encelado.xml</TemplateUnraid>
|
||||
|
||||
<!-- La versione non sta nel .csproj come in AutoBidder: sta in
|
||||
Directory.Build.props, da cui la ereditano tutti i progetti.
|
||||
È il file che VersioneDaTag legge quando non esiste ancora nessun tag. -->
|
||||
<Props>$(Radice)\Directory.Build.props</Props>
|
||||
|
||||
<CartellaPubblicazione>$(Radice)\bin\publish\portable</CartellaPubblicazione>
|
||||
<CartellaPacchetti>$(Radice)\bin\installer</CartellaPacchetti>
|
||||
|
||||
<!-- ── L'immagine ─────────────────────────────────────────────────────
|
||||
Il registro è quello di Gitea (D-29): stesso host di build/gitea.json,
|
||||
owner in minuscolo perché i nomi delle immagini OCI lo richiedono. Si
|
||||
può cambiare con -p:Immagine=host/utente/nome. -->
|
||||
<Immagine Condition="'$(Immagine)' == ''">192.168.30.23:3000/alby96/encelado</Immagine>
|
||||
|
||||
<!-- ── Perché una cartella a parte per verifica e backtest ─────────────
|
||||
L'opzione artifacts-path sposta anche gli INTERMEDI, non solo il
|
||||
risultato: la verifica non tocca le obj/ del lavoro in corso e non
|
||||
viene disturbata da un server lasciato acceso da VS Code. -->
|
||||
<CartellaProve>$([System.IO.Path]::GetTempPath())Encelado.Verifica</CartellaProve>
|
||||
|
||||
<!-- Serve solo quando HEAD non è ancora taggato: è il numero del tag da
|
||||
creare. Vuoto = la minor successiva all'ultimo tag. Vedi VersioneDaTag. -->
|
||||
<Versione Condition="'$(Versione)' == ''"></Versione>
|
||||
|
||||
<!--
|
||||
Note di rilascio. Vuote = solo il numero di versione.
|
||||
|
||||
Arrivano da una variabile d'ambiente, non da -p:. MSBuild spezza il valore
|
||||
di una proprietà sulle virgole: `-p:Note=uno, due` diventa la proprietà
|
||||
Note=uno più l'opzione " due", e si finisce su MSB1006 "proprietà non
|
||||
valida". Una nota di rilascio in italiano contiene quasi sempre una
|
||||
virgola, quindi il passaggio per riga di comando è inutilizzabile.
|
||||
L'ambiente non ha questo problema, e MSBuild legge le variabili
|
||||
d'ambiente come proprietà.
|
||||
|
||||
-p:Note= resta accettato per chi lo passa a mano senza virgole.
|
||||
-->
|
||||
<Note Condition="'$(Note)' == ''">$(ENCELADO_NOTE)</Note>
|
||||
|
||||
<!-- `dotnet test` e `dotnet publish` lanciati da dentro MSBuild ereditano
|
||||
l'ambiente del processo padre; queste due variabili puntate al build
|
||||
in corso confondono il figlio. Si azzerano SOLO queste due: togliere
|
||||
anche MSBuildExtensionsPath o MSBuildSDKsPath fa perdere al figlio la
|
||||
posizione dell'SDK. -->
|
||||
<AmbientePulito>MSBUILD_EXE_PATH=;MSBuildLoadMicrosoftTargetsReadOnly=</AmbientePulito>
|
||||
|
||||
<SaltaVerifica Condition="'$(SaltaVerifica)' == ''">false</SaltaVerifica>
|
||||
<SaltaDocker Condition="'$(SaltaDocker)' == ''">false</SaltaDocker>
|
||||
<Sovrascrivi Condition="'$(Sovrascrivi)' == ''">false</Sovrascrivi>
|
||||
<Bozza Condition="'$(Bozza)' == ''">false</Bozza>
|
||||
|
||||
<!-- Un rilascio da albero sporco produce un tag che non corrisponde a
|
||||
nessuno stato ricostruibile: è esattamente la garanzia che il tag come
|
||||
fonte unica dovrebbe dare. Si può forzare, ma va detto. -->
|
||||
<ConsentiModifiche Condition="'$(ConsentiModifiche)' == ''">false</ConsentiModifiche>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- ═════════════════════ Attività in linea ═════════════════════ -->
|
||||
|
||||
<!--
|
||||
Decide quale versione si sta costruendo, senza toccare niente.
|
||||
|
||||
Il tag su HEAD, se c'è, comanda: è la fonte. Se non c'è se ne propone uno —
|
||||
il numero chiesto, o la minor successiva al tag più alto esistente — che
|
||||
verrà creato solo a pacchetto pronto, da CreaTag.
|
||||
-->
|
||||
<UsingTask TaskName="VersioneDaTag" TaskFactory="RoslynCodeTaskFactory"
|
||||
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
|
||||
<ParameterGroup>
|
||||
<TagsHead ParameterType="System.String" />
|
||||
<TagsTutti ParameterType="System.String" />
|
||||
<Richiesta ParameterType="System.String" />
|
||||
<FileVersione ParameterType="System.String" Required="true" />
|
||||
<Versione ParameterType="System.String" Output="true" />
|
||||
<Tag ParameterType="System.String" Output="true" />
|
||||
<DaCreare ParameterType="System.String" Output="true" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Using Namespace="System" />
|
||||
<Using Namespace="System.IO" />
|
||||
<Using Namespace="System.Collections.Generic" />
|
||||
<Using Namespace="System.Text.RegularExpressions" />
|
||||
<Code Type="Fragment" Language="cs">
|
||||
<![CDATA[
|
||||
// ConsoleToMSBuild unisce le righe con il punto e virgola; git le separa
|
||||
// con a-capo. Si accettano entrambi e non si fanno domande.
|
||||
Func<string, string[]> spezza = s =>
|
||||
(s ?? "").Split(new[] { ';', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var forma = new Regex(@"^v(\d+)\.(\d+)\.(\d+)$");
|
||||
|
||||
var suHead = new List<string>();
|
||||
foreach (var t in spezza(TagsHead))
|
||||
if (forma.IsMatch(t.Trim())) suHead.Add(t.Trim());
|
||||
|
||||
if (suHead.Count > 1)
|
||||
{
|
||||
Log.LogError(
|
||||
"HEAD ha piu' di un tag di versione (" + string.Join(", ", suHead) + ").\n" +
|
||||
"Non si puo' sapere quale sia il rilascio: tienine uno solo con git tag -d <tag>");
|
||||
return false;
|
||||
}
|
||||
|
||||
var chiesta = (Richiesta ?? "").Trim();
|
||||
if (chiesta.Length > 0 && !Regex.IsMatch(chiesta, @"^\d+\.\d+\.\d+$"))
|
||||
{
|
||||
// Un refuso qui produrrebbe un tag e un pacchetto sbagliati.
|
||||
Log.LogError("Versione '" + chiesta + "' non valida: serve la forma X.Y.Z");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (suHead.Count == 1)
|
||||
{
|
||||
var dalTag = suHead[0].Substring(1);
|
||||
|
||||
if (chiesta.Length > 0 && chiesta != dalTag)
|
||||
{
|
||||
Log.LogError(
|
||||
"HEAD e' gia' taggato " + suHead[0] + ", ma e' stata chiesta la versione " + chiesta + ".\n" +
|
||||
"Il tag e' la fonte: o rilasci " + dalTag + " lasciando vuota la versione,\n" +
|
||||
"oppure togli il tag con git tag -d " + suHead[0] + " e rilancia.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Versione = dalTag;
|
||||
Tag = suHead[0];
|
||||
DaCreare = "false";
|
||||
Log.LogMessage(MessageImportance.High, " versione " + Versione + " — dal tag " + Tag + " su HEAD");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (chiesta.Length > 0)
|
||||
{
|
||||
Versione = chiesta;
|
||||
}
|
||||
else
|
||||
{
|
||||
// La minor successiva al tag piu' alto: cosi' il numero proposto e'
|
||||
// sempre libero, anche se il tag piu' alto sta su un altro ramo.
|
||||
int maggiore = -1, minore = -1;
|
||||
foreach (var t in spezza(TagsTutti))
|
||||
{
|
||||
var m = forma.Match(t.Trim());
|
||||
if (!m.Success) continue;
|
||||
|
||||
int ma = int.Parse(m.Groups[1].Value), mi = int.Parse(m.Groups[2].Value);
|
||||
if (ma > maggiore || (ma == maggiore && mi > minore)) { maggiore = ma; minore = mi; }
|
||||
}
|
||||
|
||||
if (maggiore < 0)
|
||||
{
|
||||
// Primo rilascio: non c'e' nessun tag da cui ripartire, e l'unico
|
||||
// numero che esiste e' quello delle compilazioni di sviluppo.
|
||||
var testo = File.Exists(FileVersione) ? File.ReadAllText(FileVersione) : "";
|
||||
var m = Regex.Match(testo, @"<Version>(\d+)\.(\d+)\.\d+</Version>");
|
||||
if (!m.Success)
|
||||
{
|
||||
Log.LogError(
|
||||
"Nessun tag di versione nel repository e <Version> illeggibile in " + FileVersione + ".\n" +
|
||||
"Indica la versione con -p:Versione=X.Y.Z");
|
||||
return false;
|
||||
}
|
||||
|
||||
maggiore = int.Parse(m.Groups[1].Value);
|
||||
minore = int.Parse(m.Groups[2].Value);
|
||||
}
|
||||
|
||||
Versione = maggiore + "." + (minore + 1) + ".0";
|
||||
}
|
||||
|
||||
Tag = "v" + Versione;
|
||||
DaCreare = "true";
|
||||
Log.LogMessage(MessageImportance.High, " versione " + Versione + " — il tag " + Tag + " sara' creato a pacchetto pronto");
|
||||
]]>
|
||||
</Code>
|
||||
</Task>
|
||||
</UsingTask>
|
||||
|
||||
<!--
|
||||
Gitea si raggiunge con curl, di serie in Windows 10 e 11.
|
||||
|
||||
L'alternativa naturale sarebbe HttpClient in un'attività C# in linea, ma
|
||||
RoslynCodeTaskFactory referenzia solo gli assembly di base: System.Net.Http e
|
||||
System.Text.Json andrebbero indicati per percorso assoluto, dentro il runtime
|
||||
condiviso, con il numero di versione nel mezzo. Un percorso che oggi funziona e
|
||||
al prossimo aggiornamento di .NET no. curl non ha questo problema.
|
||||
|
||||
Il token NON passa mai dalla riga di comando: sta in un file di configurazione
|
||||
di curl (e in un file letto da `docker login ‐‐password-stdin`), cancellati
|
||||
subito dopo. Gli Exec hanno EchoOff perché un registro di compilazione è la
|
||||
classica cosa che si incolla in una chat.
|
||||
-->
|
||||
|
||||
<UsingTask TaskName="LeggiConfigGitea" TaskFactory="RoslynCodeTaskFactory"
|
||||
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
|
||||
<ParameterGroup>
|
||||
<Percorso ParameterType="System.String" Required="true" />
|
||||
<Url ParameterType="System.String" Output="true" />
|
||||
<Owner ParameterType="System.String" Output="true" />
|
||||
<Repo ParameterType="System.String" Output="true" />
|
||||
<Token ParameterType="System.String" Output="true" />
|
||||
<Registro ParameterType="System.String" Output="true" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Using Namespace="System" />
|
||||
<Using Namespace="System.IO" />
|
||||
<Using Namespace="System.Text.RegularExpressions" />
|
||||
<Code Type="Fragment" Language="cs">
|
||||
<![CDATA[
|
||||
var testo = File.Exists(Percorso) ? File.ReadAllText(Percorso) : "";
|
||||
|
||||
// Le variabili d'ambiente hanno la precedenza sul file: una macchina
|
||||
// condivisa deve poter rilasciare senza scrivere un token su disco, e un
|
||||
// file dimenticato non deve vincere su una scelta esplicita.
|
||||
Func<string,string,string> leggi = (env, campo) =>
|
||||
{
|
||||
var v = Environment.GetEnvironmentVariable(env);
|
||||
if (!string.IsNullOrWhiteSpace(v)) return v.Trim();
|
||||
|
||||
var m = Regex.Match(testo, "\"" + campo + "\"\\s*:\\s*\"([^\"]*)\"");
|
||||
return m.Success ? m.Groups[1].Value.Trim() : "";
|
||||
};
|
||||
|
||||
Url = leggi("GITEA_URL", "url").TrimEnd('/');
|
||||
Owner = leggi("GITEA_OWNER", "owner");
|
||||
Repo = leggi("GITEA_REPO", "repo");
|
||||
Token = leggi("GITEA_TOKEN", "token");
|
||||
|
||||
if (Url.Length == 0 || Owner.Length == 0 || Repo.Length == 0 || Token.Length == 0)
|
||||
{
|
||||
Log.LogError(
|
||||
"Configurazione di Gitea incompleta. Servono url, owner, repo, token:\n" +
|
||||
" copia build/gitea.example.json in build/gitea.json e riempilo,\n" +
|
||||
" oppure imposta GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_TOKEN.\n" +
|
||||
"Non e' stato costruito niente: si controlla prima di compilare, non dopo.\n" +
|
||||
"Per il solo pacchetto, senza Gitea, usa il target Pacchetto.");
|
||||
|
||||
// Senza questo il target prosegue lo stesso: git tag, poi curl con
|
||||
// l'indirizzo vuoto, e infine un "codice 3" che non dice niente a
|
||||
// nessuno. Un errore va fermato dove si capisce ancora cos'era.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Il registro dei container di Gitea e' lo stesso host, senza schema.
|
||||
Registro = Regex.Replace(Url, "^https?://", "");
|
||||
]]>
|
||||
</Code>
|
||||
</Task>
|
||||
</UsingTask>
|
||||
|
||||
<UsingTask TaskName="PreparaCorpoRelease" TaskFactory="RoslynCodeTaskFactory"
|
||||
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
|
||||
<ParameterGroup>
|
||||
<Destinazione ParameterType="System.String" Required="true" />
|
||||
<Tag ParameterType="System.String" Required="true" />
|
||||
<Versione ParameterType="System.String" Required="true" />
|
||||
<Immagine ParameterType="System.String" />
|
||||
<Note ParameterType="System.String" />
|
||||
<Bozza ParameterType="System.Boolean" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Using Namespace="System" />
|
||||
<Using Namespace="System.IO" />
|
||||
<Code Type="Fragment" Language="cs">
|
||||
<![CDATA[
|
||||
// Le note arrivano da un prompt: possono contenere virgolette, barre e
|
||||
// a-capo. Scritte grezze romperebbero il JSON, o peggio lo cambierebbero.
|
||||
Func<string,string> esc = t => (t ?? "")
|
||||
.Replace("\\", "\\\\").Replace("\"", "\\\"")
|
||||
.Replace("\r", "").Replace("\n", "\\n").Replace("\t", " ");
|
||||
|
||||
var note = string.IsNullOrWhiteSpace(Note) ? "Versione " + Versione + "." : Note;
|
||||
if (!string.IsNullOrWhiteSpace(Immagine))
|
||||
note += "\n\nImmagine: `" + Immagine + ":" + Versione + "` (anche `:latest`). Template Unraid allegato.";
|
||||
|
||||
File.WriteAllText(Destinazione,
|
||||
"{\"tag_name\":\"" + esc(Tag) + "\"," +
|
||||
"\"name\":\"Encelado " + esc(Versione) + "\"," +
|
||||
"\"body\":\"" + esc(note) + "\"," +
|
||||
"\"draft\":" + (Bozza ? "true" : "false") + "," +
|
||||
"\"prerelease\":false}");
|
||||
]]>
|
||||
</Code>
|
||||
</Task>
|
||||
</UsingTask>
|
||||
|
||||
<UsingTask TaskName="LeggiIdRelease" TaskFactory="RoslynCodeTaskFactory"
|
||||
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
|
||||
<ParameterGroup>
|
||||
<Risposta ParameterType="System.String" Required="true" />
|
||||
<Id ParameterType="System.String" Output="true" />
|
||||
<Errore ParameterType="System.String" Output="true" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Using Namespace="System" />
|
||||
<Using Namespace="System.IO" />
|
||||
<Using Namespace="System.Text.RegularExpressions" />
|
||||
<Code Type="Fragment" Language="cs">
|
||||
<![CDATA[
|
||||
var testo = File.Exists(Risposta) ? File.ReadAllText(Risposta) : "";
|
||||
|
||||
// L'id della release e' il primo campo "id" della risposta; quelli annidati
|
||||
// (autore, allegati) vengono dopo. Si prende il primo e basta.
|
||||
var m = Regex.Match(testo, "\"id\"\\s*:\\s*(\\d+)");
|
||||
Id = m.Success ? m.Groups[1].Value : "";
|
||||
|
||||
if (Id.Length == 0)
|
||||
{
|
||||
var msg = Regex.Match(testo, "\"message\"\\s*:\\s*\"([^\"]*)\"");
|
||||
Errore = msg.Success ? msg.Groups[1].Value
|
||||
: (testo.Length > 200 ? testo.Substring(0, 200) : testo);
|
||||
}
|
||||
]]>
|
||||
</Code>
|
||||
</Task>
|
||||
</UsingTask>
|
||||
|
||||
<!-- ═════════════════════ Verifica ═════════════════════ -->
|
||||
|
||||
<Target Name="Verifica" Condition="'$(SaltaVerifica)' != 'true'">
|
||||
<Message Importance="High" Text="== Verifica (compilazione + test) ==" />
|
||||
|
||||
<Exec Command="dotnet test "$(TestProj)" --nologo -v q --artifacts-path "$(CartellaProve)""
|
||||
WorkingDirectory="$(Radice)"
|
||||
EnvironmentVariables="$(AmbientePulito)" />
|
||||
|
||||
<Message Importance="High" Text=" tutto a posto" />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Ricerca sui basket ═════════════════════ -->
|
||||
|
||||
<!--
|
||||
Lo strumento in tools/Encelado.Backtest: `ticks` converte i tick MT5 in barre
|
||||
M15 bid/ask, `baskets` rigioca la strategia (baseline, griglia, PSR/DSR, PBO,
|
||||
walk-forward), `falsify` esegue i test di falsificazione, `learn` rigioca il
|
||||
ciclo di apprendimento in ombra su un ledger. Ogni tabella è un CSV con ; e
|
||||
colonna motivazione. Vedi docs/STRATEGY.md per i risultati.
|
||||
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data\market"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="..." -p:Comando=falsify -p:Extra="‐‐costs api"
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="A:\Download\Trading" -p:Comando=ticks
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data" -p:Comando=learn
|
||||
-->
|
||||
<Target Name="Backtest">
|
||||
<PropertyGroup>
|
||||
<Comando Condition="'$(Comando)' == ''">baskets</Comando>
|
||||
<BacktestExe>$(Radice)\tools\Encelado.Backtest\bin\Release\net10.0\backtest.exe</BacktestExe>
|
||||
</PropertyGroup>
|
||||
|
||||
<Error Condition="'$(Dati)' == ''"
|
||||
Text="Serve la cartella dei dati: -p:Dati="%USERPROFILE%\Documents\Encelado\data\market" (barre candles_SYMBOL_M15.csv) per baskets e falsify, la cartella dei tick MT5 per ticks, la cartella data del bot per learn.%0AComandi disponibili in -p:Comando= : ticks, baskets, falsify, learn (altre opzioni in -p:Extra=, es. --costs api --quick)." />
|
||||
|
||||
<Error Condition="!Exists('$(Dati)')" Text="Cartella dati non trovata: $(Dati)" />
|
||||
|
||||
<Message Importance="High" Text="== Ricerca sui basket ==" />
|
||||
<Message Importance="High" Text=" dati : $(Dati)" />
|
||||
<Message Importance="High" Text=" comando : $(Comando) $(Extra)" />
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)" EnvironmentVariables="$(AmbientePulito)"
|
||||
Command="dotnet build "$(BacktestProj)" -c Release --nologo -v q" />
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)"
|
||||
Command=""$(BacktestExe)" $(Comando) --data "$(Dati)" $(Extra)" />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Pubblicazione ═════════════════════ -->
|
||||
|
||||
<!--
|
||||
I quattro numeri di versione arrivano da qui, non da Directory.Build.props:
|
||||
le proprietà da riga di comando sono globali e vincono su quelle scritte nei
|
||||
progetti. Vengono passati tutti e quattro anche se il file ne dichiara uno
|
||||
solo — Informazioni legge Assembly.GetName().Version, e vederne divergere
|
||||
uno significa un numero a schermo che mente.
|
||||
|
||||
── Cosa si pubblica ──────────────────────────────────────────────────────
|
||||
Una cartella framework-dependent, senza sistema operativo: è la stessa
|
||||
cosa che il Dockerfile mette in /app, e chi vuole girare senza container
|
||||
(sconsigliato: ADR-0007) la lancia con `dotnet Encelado.Server.dll` sopra
|
||||
il runtime ASP.NET Core 10. Una cartella non si allega a una release,
|
||||
quindi al suo posto viene allegato uno zip: vedi il target Pacchetto.
|
||||
-->
|
||||
<Target Name="Pubblica" DependsOnTargets="DeterminaVersione">
|
||||
<Message Importance="High" Text="== Pubblicazione della cartella portabile ($(V)) ==" />
|
||||
|
||||
<!-- Cartella pulita a ogni giro. Senza questo i resti di una pubblicazione
|
||||
precedente — una DLL rinominata, un runtime cambiato — finiscono nel
|
||||
pacchetto, ed è il tipo di problema che si manifesta solo sulla
|
||||
macchina di qualcun altro. -->
|
||||
<RemoveDir Directories="$(CartellaPubblicazione)" ContinueOnError="true" />
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)"
|
||||
EnvironmentVariables="$(AmbientePulito)"
|
||||
Command="dotnet publish "$(Csproj)" -c Release --nologo -v q --self-contained false -p:DebugType=none -p:Version=$(V) -p:AssemblyVersion=$(V).0 -p:FileVersion=$(V).0 -p:InformationalVersion=$(V) -o "$(CartellaPubblicazione)"" />
|
||||
|
||||
<Error Condition="!Exists('$(CartellaPubblicazione)\Encelado.Server.dll')"
|
||||
Text="Pubblicazione fallita: Encelado.Server.dll non trovato." />
|
||||
|
||||
<!-- Senza configurazione di fabbrica il primo avvio non può seminare
|
||||
encelado.json e strategy.json nella cartella di configurazione. -->
|
||||
<Error Condition="!Exists('$(CartellaPubblicazione)\config\encelado.json')"
|
||||
Text="Pubblicazione incompleta: config\encelado.json non è finito accanto all'eseguibile." />
|
||||
|
||||
<!--
|
||||
Nella release non devono finire i sorgenti: si pubblica il programma, non
|
||||
il progetto. La cartella pubblicata diventa lo zip portabile, quindi basta
|
||||
controllare qui.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.cs" />
|
||||
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.csproj" />
|
||||
<SorgenteIntruso Include="$(CartellaPubblicazione)\**\*.pdb" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error Condition="'@(SorgenteIntruso)' != ''"
|
||||
Text="Nella cartella pubblicata ci sono file che non sono programma: @(SorgenteIntruso->'%(Filename)%(Extension)', ', ').%0ANon devono finire nella release." />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Immagine Docker ═════════════════════ -->
|
||||
|
||||
<!--
|
||||
L'immagine è il pacchetto vero (ADR-0008). La Dockerfile alla radice compila,
|
||||
esegue i test e pubblica dentro lo stadio di build: un'immagine con i test
|
||||
rossi non esiste. Il commit arriva come build-arg perché .git resta fuori
|
||||
dal contesto (.dockerignore).
|
||||
|
||||
dotnet msbuild build/Release.proj -t:Docker # :<versione> e :latest
|
||||
dotnet msbuild build/Release.proj -t:Docker -p:SaltaDocker=true # (no-op, per la catena senza Docker)
|
||||
-->
|
||||
<Target Name="Docker" DependsOnTargets="DeterminaVersione" Condition="'$(SaltaDocker)' != 'true'">
|
||||
<Message Importance="High" Text="== Immagine Docker $(Immagine):$(V) ==" />
|
||||
|
||||
<Error Condition="!Exists('$(Dockerfile)')" Text="Dockerfile non trovata: $(Dockerfile)" />
|
||||
|
||||
<Exec Command="docker version --format {{.Server.Version}}" ContinueOnError="true"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low" StandardErrorImportance="low">
|
||||
<Output TaskParameter="ExitCode" PropertyName="DockerEsito" />
|
||||
</Exec>
|
||||
<Error Condition="'$(DockerEsito)' != '0'"
|
||||
Text="Docker non risponde: avvia Docker Desktop (o il demone) e rilancia. Per saltare l'immagine: -p:SaltaDocker=true." />
|
||||
|
||||
<Exec Command="git rev-parse --short=12 HEAD" WorkingDirectory="$(Radice)" ContinueOnError="true"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low" StandardErrorImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="Commit" />
|
||||
</Exec>
|
||||
<PropertyGroup>
|
||||
<Commit Condition="'$(Commit)' == ''">n/d</Commit>
|
||||
</PropertyGroup>
|
||||
|
||||
<Exec WorkingDirectory="$(Radice)"
|
||||
Command="docker build --pull -t "$(Immagine):$(V)" -t "$(Immagine):latest" --build-arg VERSION=$(V) --build-arg GIT_COMMIT=$(Commit) -f "$(Dockerfile)" "$(Radice)"" />
|
||||
|
||||
<Message Importance="High" Text=" immagine $(Immagine):$(V) (commit $(Commit))" />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Pacchetto ═════════════════════ -->
|
||||
|
||||
<!--
|
||||
L'ordine conta: si verifica, si costruisce, e il tag si crea per ultimo.
|
||||
|
||||
Il contrario sembra piu' naturale — decidi il numero, poi costruisci — ma
|
||||
lascia dietro un tag quando qualcosa va storto, e il tentativo successivo
|
||||
riparte da li'. Un giro andato male e il numero e' salito lo stesso, senza
|
||||
che sia mai esistito un pacchetto con quella versione. Taggando in fondo,
|
||||
ogni tag corrisponde a un'immagine e a uno zip che esistono davvero.
|
||||
-->
|
||||
<Target Name="Pacchetto" DependsOnTargets="Verifica;DeterminaVersione;Pubblica;Docker">
|
||||
<Message Importance="High" Text="== Creazione del pacchetto ==" />
|
||||
|
||||
<MakeDir Directories="$(CartellaPacchetti)" />
|
||||
|
||||
<PropertyGroup>
|
||||
<Portabile>$(CartellaPacchetti)\Encelado_$(V)_portabile.zip</Portabile>
|
||||
<TemplateAllegato>$(CartellaPacchetti)\encelado-unraid-$(V).xml</TemplateAllegato>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
La copia portabile per chi non vuole il container (framework-dependent:
|
||||
serve il runtime ASP.NET Core 10). Si crea qui e non nel rilascio: deve
|
||||
esistere anche costruendo senza pubblicare su Gitea.
|
||||
-->
|
||||
<Delete Files="$(Portabile)" ContinueOnError="true" />
|
||||
<ZipDirectory SourceDirectory="$(CartellaPubblicazione)" DestinationFile="$(Portabile)" />
|
||||
|
||||
<!-- Il template Unraid con la versione nel nome, così la release lo porta con sé. -->
|
||||
<Copy SourceFiles="$(TemplateUnraid)" DestinationFiles="$(TemplateAllegato)" Condition="Exists('$(TemplateUnraid)')" />
|
||||
|
||||
<!-- Adesso: il pacchetto c'e', il tag puo' esistere. -->
|
||||
<CallTarget Targets="CreaTag" />
|
||||
|
||||
<Message Importance="High" Text=" " />
|
||||
<Message Importance="High" Text="Pacchetto pronto:" />
|
||||
<Message Importance="High" Text=" $(Portabile)" />
|
||||
<Message Importance="High" Text=" $(TemplateAllegato)" />
|
||||
<Message Importance="High" Text=" $(Immagine):$(V)" Condition="'$(SaltaDocker)' != 'true'" />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Versione e tag ═════════════════════ -->
|
||||
|
||||
<Target Name="DeterminaVersione">
|
||||
<!-- Nessuno dei due comandi fallisce mai: senza tag l'uscita e' vuota, e
|
||||
basta. `git describe` invece esce in errore, e qui non serve. -->
|
||||
<Exec Command="git tag --points-at HEAD" WorkingDirectory="$(Radice)"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="TagSuHead" />
|
||||
</Exec>
|
||||
|
||||
<Exec Command="git tag --list v*" WorkingDirectory="$(Radice)"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="TagEsistenti" />
|
||||
</Exec>
|
||||
|
||||
<VersioneDaTag TagsHead="$(TagSuHead)" TagsTutti="$(TagEsistenti)"
|
||||
Richiesta="$(Versione)" FileVersione="$(Props)">
|
||||
<Output TaskParameter="Versione" PropertyName="V" />
|
||||
<Output TaskParameter="Tag" PropertyName="Tag" />
|
||||
<Output TaskParameter="DaCreare" PropertyName="TagDaCreare" />
|
||||
</VersioneDaTag>
|
||||
</Target>
|
||||
|
||||
<Target Name="CreaTag" Condition="'$(TagDaCreare)' == 'true'">
|
||||
<!-- Solo le modifiche ai file gia' tracciati: un appunto non versionato
|
||||
accanto al progetto non cambia cosa viene compilato. -->
|
||||
<Exec Command="git status --porcelain --untracked-files=no" WorkingDirectory="$(Radice)"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="AlberoSporco" />
|
||||
</Exec>
|
||||
|
||||
<Error Condition="'$(AlberoSporco)' != '' AND '$(ConsentiModifiche)' != 'true'"
|
||||
Text="Ci sono modifiche non committate: il tag $(Tag) indicherebbe uno stato che non e' ricostruibile.%0ACommitta prima di rilasciare, oppure rilancia con -p:ConsentiModifiche=true se sai cosa stai facendo.%0A%0A$(AlberoSporco)" />
|
||||
|
||||
<Exec Command="git tag -a $(Tag) -m "Encelado $(V)"" WorkingDirectory="$(Radice)"
|
||||
StandardOutputImportance="low" StandardErrorImportance="low" />
|
||||
|
||||
<Message Importance="High" Text=" tag $(Tag) creato" />
|
||||
</Target>
|
||||
|
||||
<!-- ═════════════════════ Rilascio ═════════════════════ -->
|
||||
|
||||
<!-- Prima di tutto il resto: un token mancante non deve costare i minuti
|
||||
dell'immagine per poi fermarsi all'ultimo passo. -->
|
||||
<Target Name="ConfigGitea">
|
||||
<LeggiConfigGitea Percorso="$(MSBuildThisFileDirectory)gitea.json">
|
||||
<Output TaskParameter="Url" PropertyName="GUrl" />
|
||||
<Output TaskParameter="Owner" PropertyName="GOwner" />
|
||||
<Output TaskParameter="Repo" PropertyName="GRepo" />
|
||||
<Output TaskParameter="Token" PropertyName="GToken" />
|
||||
<Output TaskParameter="Registro" PropertyName="GRegistro" />
|
||||
</LeggiConfigGitea>
|
||||
</Target>
|
||||
|
||||
<Target Name="Rilascia" DependsOnTargets="ConfigGitea;Pacchetto">
|
||||
<Message Importance="High" Text="== Pubblicazione su Gitea ==" />
|
||||
|
||||
<PropertyGroup>
|
||||
<Api>$(GUrl)/api/v1/repos/$(GOwner)/$(GRepo)</Api>
|
||||
<Tmp>$([System.IO.Path]::GetTempPath())Encelado.Rilascio</Tmp>
|
||||
<CurlCfg>$(Tmp)\curl.cfg</CurlCfg>
|
||||
<TokenFile>$(Tmp)\registry.token</TokenFile>
|
||||
<CorpoJson>$(Tmp)\release.json</CorpoJson>
|
||||
<RispostaJson>$(Tmp)\risposta.json</RispostaJson>
|
||||
<Portabile>$(CartellaPacchetti)\Encelado_$(V)_portabile.zip</Portabile>
|
||||
<TemplateAllegato>$(CartellaPacchetti)\encelado-unraid-$(V).xml</TemplateAllegato>
|
||||
</PropertyGroup>
|
||||
|
||||
<MakeDir Directories="$(Tmp)" />
|
||||
|
||||
<!-- Il token vive qui e solo qui, per il tempo del rilascio. -->
|
||||
<WriteLinesToFile File="$(CurlCfg)" Overwrite="true"
|
||||
Lines="header = "Authorization: token $(GToken)"" />
|
||||
<WriteLinesToFile File="$(TokenFile)" Overwrite="true" Lines="$(GToken)" />
|
||||
|
||||
<!--
|
||||
Il tag esiste gia' in locale: l'ha creato Pacchetto, o c'era prima. Qui va
|
||||
spinto, e va verificato che ci sia arrivato.
|
||||
|
||||
Non e' una formalita'. Il push qui sotto spinge i tag, non i commit: se il
|
||||
ramo e' indietro, il tag punta a un oggetto che il remoto non conosce e il
|
||||
push viene rifiutato. A quel punto la creazione della release non
|
||||
fallisce — Gitea, non trovando il tag, lo crea da se' sulla testa del ramo
|
||||
predefinito. Verrebbe pubblicata una release che dichiara di essere il
|
||||
commit X mentre il codice allegato e' il commit Y, e nessuno se ne
|
||||
accorgerebbe. Meglio fermarsi e dire cosa manca.
|
||||
|
||||
I commit non si spingono da qui: quando spingere il ramo lo decide chi
|
||||
lavora, non la catena di rilascio.
|
||||
-->
|
||||
<Exec Command="git push --tags" WorkingDirectory="$(Radice)" ContinueOnError="true"
|
||||
StandardOutputImportance="low" StandardErrorImportance="low" />
|
||||
|
||||
<Exec Command="git ls-remote --tags origin refs/tags/$(Tag)" WorkingDirectory="$(Radice)"
|
||||
ConsoleToMSBuild="true" ContinueOnError="true" StandardOutputImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="TagSulRemoto" />
|
||||
</Exec>
|
||||
|
||||
<Error Condition="'$(TagSulRemoto)' == ''"
|
||||
Text="Il tag $(Tag) non e' arrivato sul remoto, quasi sempre perche' il ramo e' indietro.%0AGitea creerebbe la release sulla testa del ramo predefinito: il codice allegato non corrisponderebbe al commit dichiarato.%0A%0ASpingi il ramo e rilancia: git push" />
|
||||
|
||||
<Message Importance="High" Text=" tag $(Tag) sul remoto" />
|
||||
|
||||
<!--
|
||||
L'immagine sul registro dei container di Gitea (stesso host, stesso
|
||||
token: il registro accetta utente + token come password). Il push viene
|
||||
prima della release: se fallisce, non esiste una release che indica
|
||||
un'immagine che nessuno può scaricare.
|
||||
-->
|
||||
<Exec Condition="'$(SaltaDocker)' != 'true'" EchoOff="true" StandardOutputImportance="low"
|
||||
Command="docker login "$(GRegistro)" -u "$(GOwner)" --password-stdin < "$(TokenFile)"" />
|
||||
<Exec Condition="'$(SaltaDocker)' != 'true'" Command="docker push "$(Immagine):$(V)"" />
|
||||
<Exec Condition="'$(SaltaDocker)' != 'true'" Command="docker push "$(Immagine):latest"" />
|
||||
<Exec Condition="'$(SaltaDocker)' != 'true'" ContinueOnError="true" StandardOutputImportance="low"
|
||||
Command="docker logout "$(GRegistro)"" />
|
||||
<Message Condition="'$(SaltaDocker)' != 'true'" Importance="High" Text=" immagine $(Immagine):$(V) e :latest sul registro" />
|
||||
|
||||
<!-- Release gia' presente? -->
|
||||
<Exec EchoOff="true" ContinueOnError="true" StandardOutputImportance="low"
|
||||
Command="curl -s -K "$(CurlCfg)" -o "$(RispostaJson)" "$(Api)/releases/tags/$(Tag)"" />
|
||||
|
||||
<LeggiIdRelease Risposta="$(RispostaJson)">
|
||||
<Output TaskParameter="Id" PropertyName="IdEsistente" />
|
||||
</LeggiIdRelease>
|
||||
|
||||
<Error Condition="'$(IdEsistente)' != '' AND '$(Sovrascrivi)' != 'true'"
|
||||
Text="La release $(Tag) esiste gia'. Alza la versione, oppure rilancia con -p:Sovrascrivi=true." />
|
||||
|
||||
<Exec Condition="'$(IdEsistente)' != ''" EchoOff="true" StandardOutputImportance="low"
|
||||
Command="curl -s -K "$(CurlCfg)" -X DELETE "$(Api)/releases/$(IdEsistente)"" />
|
||||
<Message Condition="'$(IdEsistente)' != ''" Importance="High"
|
||||
Text=" release $(Tag) esistente: sostituita" />
|
||||
|
||||
<!-- Creazione -->
|
||||
<PreparaCorpoRelease Destinazione="$(CorpoJson)" Tag="$(Tag)" Versione="$(V)"
|
||||
Immagine="$(Immagine)" Note="$(Note)" Bozza="$(Bozza)" />
|
||||
|
||||
<Exec EchoOff="true" StandardOutputImportance="low"
|
||||
Command="curl -s -K "$(CurlCfg)" -X POST -H "Content-Type: application/json" --data-binary "@$(CorpoJson)" -o "$(RispostaJson)" "$(Api)/releases"" />
|
||||
|
||||
<LeggiIdRelease Risposta="$(RispostaJson)">
|
||||
<Output TaskParameter="Id" PropertyName="IdRelease" />
|
||||
<Output TaskParameter="Errore" PropertyName="ErroreRelease" />
|
||||
</LeggiIdRelease>
|
||||
|
||||
<Error Condition="'$(IdRelease)' == ''"
|
||||
Text="Creazione della release non riuscita: $(ErroreRelease)" />
|
||||
|
||||
<Message Importance="High" Text=" release creata" />
|
||||
|
||||
<!--
|
||||
Allegati: la copia portabile e il template Unraid, entrambi già pronti da
|
||||
Pacchetto. Nient'altro — in particolare nessun sorgente: si pubblica il
|
||||
programma, non il progetto. L'immagine non si allega: sta nel registro.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<Allegato Include="$(Portabile)" />
|
||||
<Allegato Include="$(TemplateAllegato)" />
|
||||
</ItemGroup>
|
||||
|
||||
<Exec Condition="Exists('%(Allegato.FullPath)')" EchoOff="true" StandardOutputImportance="low"
|
||||
Command="curl -s -K "$(CurlCfg)" -X POST -F "attachment=@%(Allegato.FullPath)" "$(Api)/releases/$(IdRelease)/assets?name=%(Allegato.Filename)%(Allegato.Extension)"" />
|
||||
|
||||
<Message Importance="High" Text=" caricato %(Allegato.Filename)%(Allegato.Extension)"
|
||||
Condition="Exists('%(Allegato.FullPath)')" />
|
||||
|
||||
<!-- Il token non deve sopravvivere al rilascio. -->
|
||||
<Delete Files="$(CurlCfg);$(TokenFile);$(CorpoJson);$(RispostaJson)" ContinueOnError="true" />
|
||||
|
||||
<Message Importance="High" Text=" " />
|
||||
<Message Importance="High" Text="Rilascio completato:" />
|
||||
<Message Importance="High" Text=" $(GUrl)/$(GOwner)/$(GRepo)/releases/tag/$(Tag)" />
|
||||
<Message Importance="High" Text=" docker pull $(Immagine):$(V)" Condition="'$(SaltaDocker)' != 'true'" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"_commento": "Copia questo file in gitea.json e metti il token. gitea.json e' escluso dal controllo di versione perche' contiene una credenziale. In alternativa usa le variabili d'ambiente GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_TOKEN, che hanno la precedenza su questo file.",
|
||||
|
||||
"url": "http://192.168.30.23:3000",
|
||||
"owner": "Alby96",
|
||||
"repo": "Encelado",
|
||||
|
||||
"_token": "Gitea > Impostazioni > Applicazioni > Genera nuovo token, permesso 'repository: read and write'.",
|
||||
"token": "INSERISCI_QUI_IL_TOKEN"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"_commento": "Configurazione di Encelado — Correlation Baskets su eToro (CFD forex). Le chiavi con il prefisso _ sono documentazione e vengono ignorate. I parametri della strategia (basket, preset, soglie, rischio) stanno in strategy.json accanto a questo file. Le chiavi API non stanno qui: si inseriscono dalla finestra e vivono cifrate in %LOCALAPPDATA%\\Encelado\\etoro.dat, oppure nelle variabili d'ambiente ETORO_API_KEY e ETORO_USER_KEY.",
|
||||
|
||||
"etoro": {
|
||||
"_note": "eToro Public API. environment = demo oppure real: chiavi e rotte sono diverse, e l'ambiente attivo è sempre visibile nella finestra.",
|
||||
"environment": "demo",
|
||||
"baseUrl": "https://public-api.etoro.com",
|
||||
"requestTimeoutSeconds": 20,
|
||||
"_fillTimeoutSeconds": "Quanto attendere l'esito di un ordine (eToro lo lavora in modo asincrono) prima di trattarlo come non confermato e riconciliare. È anche il timeout della seconda gamba (leg-risk).",
|
||||
"fillTimeoutSeconds": 5
|
||||
},
|
||||
|
||||
"run": {
|
||||
"_executionMode": "Paper = simulatore locale sopra le quotazioni reali (nessun ordine sul conto). Demo = conto demo di eToro: ordini veri, denaro virtuale, il bot apre e chiude da solo. Live = conto reale: richiede allowLive = true e la frase CONFERMO LIVE a ogni avvio. Nessuna modalità chiede l'approvazione dei singoli ordini (decisione D-20).",
|
||||
"executionMode": "Demo",
|
||||
"allowLive": false,
|
||||
"_pollSeconds": "Secondi fra due letture delle quotazioni (una richiesta per tutti gli strumenti). 3 s = 20 richieste al minuto su una quota di 120: resta spazio per candele e costi.",
|
||||
"pollSeconds": 3,
|
||||
"_statusSeconds": "Ogni quanti secondi il bot scrive una riga di stato nel log (e sulla console in headless).",
|
||||
"statusSeconds": 60,
|
||||
"_closeOnShutdown": "true = fermare il bot chiude i basket aperti a mercato. false = restano sul conto con gli stop nativi sul server, senza nessuno che applichi il take-profit o lo stop di basket finché il bot non riparte.",
|
||||
"closeOnShutdown": false,
|
||||
"strategyFile": "strategy.json",
|
||||
"_cartelle": "Relative alla cartella di questo file: data (mercato, calendario, notizie, ledger, modelli), knowledge (calibrazione, proposte, registri), reports.",
|
||||
"dataDirectory": "data",
|
||||
"knowledgeDirectory": "knowledge",
|
||||
"reportsDirectory": "reports",
|
||||
"_paper": "Solo per executionMode = Paper: saldo iniziale del simulatore e slippage per gamba oltre lo spread reale del momento.",
|
||||
"paperStartingBalance": 10000,
|
||||
"paperSlippagePips": 0.3
|
||||
},
|
||||
|
||||
"ui": {
|
||||
"_timeZone": "Fuso orario con cui l'interfaccia mostra gli orari. 'computer' = quello del sistema (nel container la variabile TZ); 'UTC'; oppure un id IANA (es. 'Europe/Rome'). Il file di log porta l'offset, il ledger è in UTC: cambiare questo valore non tocca nessun file.",
|
||||
"timeZone": "computer",
|
||||
"_displayCurrency": "Valuta in cui l'interfaccia, Telegram e le esportazioni mostrano gli importi (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD). I tassi vengono dalle quotazioni eToro già in polling; ledger e decisioni restano in USD. Anche ENCELADO_DISPLAY_CURRENCY.",
|
||||
"displayCurrency": "USD",
|
||||
"_theme": "dark oppure light.",
|
||||
"theme": "dark",
|
||||
"_navExpanded": "Se la barra di navigazione a sinistra parte aperta (etichette estese) o chiusa (solo icone).",
|
||||
"navExpanded": true
|
||||
},
|
||||
|
||||
"logging": {
|
||||
"_level": "trace, debug, info, warn, error, none. 'info' basta: ogni rifiuto che impedisce un ordine viene scritto a questo livello o sopra, con il basket e il motivo esatto.",
|
||||
"level": "info",
|
||||
"console": false,
|
||||
"_directory": "Cartella dei log, relativa a questo file se non è assoluta.",
|
||||
"directory": "logs",
|
||||
"file": "encelado.log",
|
||||
"_rotazione": "Superata maxFileSizeMb il file viene ruotato (encelado.1.log, encelado.2.log…) e ne restano maxFiles.",
|
||||
"maxFileSizeMb": 32,
|
||||
"maxFiles": 10,
|
||||
"_righe": "statusLines = righe della striscia di attività nella dashboard; bufferedLines = righe tenute in memoria dalla pagina Log (il file su disco resta completo).",
|
||||
"statusLines": 200,
|
||||
"bufferedLines": 5000
|
||||
},
|
||||
|
||||
"notifications": {
|
||||
"_telegram": "Notifiche e comandi su Telegram (5.0). Il token del bot NON si scrive qui: variabile d'ambiente TELEGRAM_BOT_TOKEN (e TELEGRAM_CHAT_ID, che ha la precedenza su chatId). hourlyStatus = stato allo scoccare di ogni ora UTC; eventAlerts = aperture, chiusure, ordini risolti, kill-switch, recupero, errori; dailySummaryUtcHour = ora UTC del riepilogo giornaliero (negativo = spento); commands = risposte ai comandi /stato, /posizioni, /storico, /pausa, /riprendi, /chiudi, /kill CONFERMO, /reset dalla sola chat autorizzata.",
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"chatId": "",
|
||||
"hourlyStatus": true,
|
||||
"eventAlerts": true,
|
||||
"dailySummaryUtcHour": 21,
|
||||
"commands": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"_comment": "Encelado — strategia Correlation Baskets su eToro. Cinque basket di due coppie forex correlate: si entra quando il cross sintetico diverge (z-score), si esce quando converge o al take-profit di basket in pip; lo stop di basket è obbligatorio. Ogni chiave con '_' davanti è documentazione.",
|
||||
|
||||
"_preset": "Conservative | Moderate | Aggressive. Fissa zIn, riskPerBasketPct, maxBaskets, tpPips, maxAdds, zStop; si cambia a caldo dalla finestra senza toccare i basket aperti. Le chiavi omonime qui sotto, se presenti, sovrascrivono il preset.",
|
||||
"preset": "Moderate",
|
||||
|
||||
"_signalMode": "ZScoreSynthetic (default, |z| >= zIn sul cross sintetico) oppure PipDivergence (fedele all'interfaccia Titany: divergenza in pip dall'ancora, dIn).",
|
||||
"signalMode": "ZScoreSynthetic",
|
||||
"_exitMode": "First = la prima fra TP in pip e rientro dello z; FixedPips = solo TP in pip lordi; ZReturn = solo |z| <= zOut.",
|
||||
"exitMode": "First",
|
||||
"_averagingMode": "Off | AddOnce | Grid. Off in live; AddOnce in paper. Moltiplicatore di lotto sempre 1,0 (niente martingala).",
|
||||
"averagingMode": "Off",
|
||||
"tpMode": "Pips",
|
||||
"_sameCrossPolicy": "I basket 4 e 5 sono entrambi EURCAD: Exclusive = uno solo aperto per volta; Half = entrambi a metà size.",
|
||||
"sameCrossPolicy": "Exclusive",
|
||||
"preferDirectCross": false,
|
||||
|
||||
"_indicatori": "Correlazione di Pearson rolling dei rendimenti M15 su window (ρ_W) e windowShort (ρ_20); z-score del cross sintetico su window; half-life OLS ricalcolata ogni halfLifeRecalcHours.",
|
||||
"window": 100,
|
||||
"windowShort": 20,
|
||||
"rhoMin": 0.60,
|
||||
"rhoShortMin": 0.40,
|
||||
"halfLifeMinBars": 4,
|
||||
"halfLifeMaxBars": 96,
|
||||
"halfLifeRecalcHours": 4,
|
||||
"atrPeriod": 14,
|
||||
"ewmaSpan": 100,
|
||||
"trendPeriod": 14,
|
||||
|
||||
"zOut": 0.25,
|
||||
"dIn": 15,
|
||||
"anchorBars": 32,
|
||||
"gridStepZ": 0.75,
|
||||
"lotMultiplier": 1.0,
|
||||
|
||||
"_uscite": "Stop di basket: |z| >= zStop, oppure perdita netta >= maxLossPerBasketPct dell'equity, oppure |ρ_20| < rhoBreak per rhoBreakBars barre, oppure maxHoldingBars barre (96 = 24 h).",
|
||||
"maxLossPerBasketPct": 1.5,
|
||||
"rhoBreak": 0.20,
|
||||
"rhoBreakBars": 8,
|
||||
"maxHoldingBars": 96,
|
||||
"tpAtrMultiple": 1.0,
|
||||
|
||||
"_costGate": "Costo = spread_A + spread_B (in pip-equivalenti di A) + markup e commissioni dell'API + overnight stimato per maxHoldingBars. Entrata solo se TP >= costMultiple × costo e ogni spread <= spreadMedianMultiple × la sua mediana delle ultime 24 h; spread oltre spreadAnomalyMultiple × mediana = chiusura forzata.",
|
||||
"costMultiple": 3,
|
||||
"spreadMedianMultiple": 2,
|
||||
"spreadAnomalyMultiple": 3,
|
||||
"slippagePipsPerLeg": 0.3,
|
||||
"overnightPipsPerDay": 0.3,
|
||||
|
||||
"_calendario": "Nessuna entrata nei blackoutBeforeMin minuti prima e blackoutAfterMin dopo un evento ad alto impatto sulle valute del basket; niente entrate dal venerdì fridayCutoffUtcHour UTC alla riapertura né nei primi openDelayMinutes dopo l'apertura settimanale; sessions = fasce orarie UTC ammesse (vuoto = sempre).",
|
||||
"blackoutBeforeMin": 45,
|
||||
"blackoutAfterMin": 30,
|
||||
"fridayCutoffUtcHour": 20,
|
||||
"openDelayMinutes": 30,
|
||||
"sessions": [],
|
||||
|
||||
"_sizing": "Lotto B = lotto A × (ATR_A × pipValue_A) / (ATR_B × pipValue_B); lotto A tale che la perdita allo stop valga riskPerBasketPct dell'equity; leva effettiva <= maxEffectiveLeverage sul nozionale complessivo; orderLeverage è la leva dichiarata a eToro per ogni gamba (1, 2, 5, 10, 20, 30).",
|
||||
"maxEffectiveLeverage": 10,
|
||||
"orderLeverage": 10,
|
||||
"_volScale": "zIn effettivo = zIn × clamp(σ_prevista / σ_media_30g, volScaleMin, volScaleMax).",
|
||||
"volScaleMin": 0.8,
|
||||
"volScaleMax": 1.5,
|
||||
"volAverageDays": 30,
|
||||
"mlMinProbability": 0.55,
|
||||
|
||||
"_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
|
||||
},
|
||||
|
||||
"_recovery": "Recupero dopo inattività (5.0, §6). Il bot scrive data/state/heartbeat.json ogni heartbeatSeconds; se all'avvio o fra due cicli passano più di thresholdMinutes (riavvio, sospensione del PC, aggiornamento, container fermo) blocca le entrate, risolve gli ordini senza esito, riconcilia, riscalda le serie con le barre perse e rivaluta ogni basket aperto come a una chiusura di barra ordinaria (chiudi o tieni; le orfane si chiudono, le esterne si riportano); scrive reports/recupero_<run_id>.csv e riapre le entrate dopo warmupMinutes di quotazioni. A mercato chiuso aspetta la riapertura.",
|
||||
"recovery": {
|
||||
"thresholdMinutes": 10,
|
||||
"warmupMinutes": 15,
|
||||
"heartbeatSeconds": 30
|
||||
},
|
||||
|
||||
"_learning": "Apprendimento (ADR-0006). Di fabbrica tutto spento: restano il ledger, le tabelle di calibrazione, la previsione di volatilità e la logistica in ombra (p_ML nel ledger, mai un cancello). enabled = true riaccende il ciclo settimanale (weeklyCycle) e il challenger MLP (challenger) solo dopo il criterio di riattivazione di docs/ML_AND_LEARNING.md: almeno 300 basket chiusi in Demo e P&L netto forward >= 0 sulla pre-registrazione. Il bandit propone soltanto, non applica mai il preset.",
|
||||
"learning": {
|
||||
"enabled": false,
|
||||
"weeklyCycle": false,
|
||||
"challenger": false
|
||||
},
|
||||
|
||||
"_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" },
|
||||
{ "a": "AUDUSD", "b": "USDCAD", "enabled": true, "note": "cross sintetico AUDCAD" },
|
||||
{ "a": "NZDUSD", "b": "EURNZD", "enabled": true, "note": "cross sintetico EURUSD: replica EURUSD pagando due spread" },
|
||||
{ "a": "USDCAD", "b": "EURUSD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 5)" },
|
||||
{ "a": "EURAUD", "b": "AUDCAD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 4)" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Encelado — entrypoint del container.
|
||||
#
|
||||
# 1. Fuso orario da TZ (tzdata è nell'immagine).
|
||||
# 2. Utente e gruppo da PUID/PGID (default 99/100, i «nobody:users» di Unraid):
|
||||
# /config e /data vengono riassegnati a quell'utente, così i file scritti dal
|
||||
# bot sono leggibili dalla share appdata senza giochi di permessi.
|
||||
# 3. exec del processo finale con gosu: PID 1 è dotnet, SIGTERM arriva a lui.
|
||||
#
|
||||
# Con PUID=0 il bot resta root: sconsigliato, ma ammesso per il debug.
|
||||
set -eu
|
||||
|
||||
PUID="${PUID:-99}"
|
||||
PGID="${PGID:-100}"
|
||||
|
||||
if [ -n "${TZ:-}" ] && [ -f "/usr/share/zoneinfo/${TZ}" ]; then
|
||||
ln -sf "/usr/share/zoneinfo/${TZ}" /etc/localtime
|
||||
echo "${TZ}" > /etc/timezone
|
||||
fi
|
||||
|
||||
if [ "${PUID}" != "0" ]; then
|
||||
if ! getent group "${PGID}" >/dev/null 2>&1; then
|
||||
groupadd -g "${PGID}" encelado
|
||||
fi
|
||||
if ! getent passwd "${PUID}" >/dev/null 2>&1; then
|
||||
useradd -r -o -u "${PUID}" -g "${PGID}" -M -d /data -s /usr/sbin/nologin encelado
|
||||
fi
|
||||
mkdir -p /config /data
|
||||
chown -R "${PUID}:${PGID}" /config /data
|
||||
echo "encelado: avvio come uid ${PUID} gid ${PGID}, fuso ${TZ:-UTC}"
|
||||
exec gosu "${PUID}:${PGID}" "$@"
|
||||
fi
|
||||
|
||||
echo "encelado: avvio come root (PUID=0), fuso ${TZ:-UTC}"
|
||||
exec "$@"
|
||||
@@ -0,0 +1,71 @@
|
||||
# Encelado su Unraid
|
||||
|
||||
Il template `encelado.xml` installa il container dall'immagine sul registro di Gitea
|
||||
(`192.168.30.23:3000/alby96/encelado:latest`). Dettagli su volumi, variabili, healthcheck e
|
||||
aggiornamento in `docs/DOCKER.md`; guida operativa in `docs/RUNBOOK.md`.
|
||||
|
||||
## 1. Il registro di Gitea è in HTTP: dillo a Docker di Unraid
|
||||
|
||||
Docker rifiuta un registro senza TLS finché non è dichiarato «insecure». Su Unraid, dal
|
||||
terminale (o da *Tools ▸ Terminal*):
|
||||
|
||||
```sh
|
||||
echo 'DOCKER_OPTS="--insecure-registry 192.168.30.23:3000"' >> /boot/config/docker.cfg
|
||||
/etc/rc.d/rc.docker restart # oppure Impostazioni ▸ Docker: disabilita e riabilita
|
||||
docker info | grep -A2 "Insecure Registries" # deve elencare 192.168.30.23:3000
|
||||
```
|
||||
|
||||
Il repository Encelado è pubblico, quindi anche il pacchetto lo è: `docker pull` non ha
|
||||
bisogno di login. Se un giorno diventa privato: `docker login 192.168.30.23:3000` con
|
||||
utente Gitea e un token con permesso `package:read`.
|
||||
|
||||
## 2. Installa dal template
|
||||
|
||||
*Docker ▸ Add Container*, in fondo alla pagina **Template repositories** non serve: incolla
|
||||
l'URL del template nel campo **Template** (in alto, «Select a template» ▸ voce vuota, poi il
|
||||
campo URL), oppure scarica il file e mettilo in `/boot/config/plugins/dockerMan/templates-user/`:
|
||||
|
||||
```
|
||||
http://192.168.30.23:3000/Alby96/Encelado/raw/branch/main/deploy/unraid/encelado.xml
|
||||
```
|
||||
|
||||
Lo stesso file è allegato a ogni release su Gitea con la versione nel nome.
|
||||
|
||||
## 3. Compila i campi
|
||||
|
||||
| Campo | Cosa mettere |
|
||||
|---|---|
|
||||
| **Porta web** | `8080` (o un'altra libera: la WebUI del container la segue) |
|
||||
| **/config**, **/data** | lascia i default `/mnt/user/appdata/encelado/config` e `/data` |
|
||||
| **ENCELADO_WEB_TOKEN** | **obbligatorio**: una stringa lunga a piacere. Senza, il server ascolta solo dentro il container e dalla rete non si vede niente |
|
||||
| **ETORO_API_KEY**, **ETORO_USER_KEY** | le chiavi del conto **demo** (dal portale sviluppatori eToro; demo e reale hanno chiavi diverse). Oppure lasciale vuote e inseriscile poi dall'interfaccia, mettendo una **ENCELADO_KEY_PASSPHRASE** |
|
||||
| **ETORO_ENVIRONMENT** | `demo` |
|
||||
| **ENCELADO_EXECUTION_MODE** | `Demo` |
|
||||
| **TZ** | `Europe/Rome` |
|
||||
| **TELEGRAM_BOT_TOKEN**, **TELEGRAM_CHAT_ID** | facoltativi: notifiche ogni ora, eventi, riepilogo serale e comandi dalla chat |
|
||||
| PUID / PGID | `99` / `100` (nobody:users, i default di Unraid) |
|
||||
|
||||
`ENCELADO_CONFIRM_LIVE` resta vuoto: serve solo per il conto reale, che oggi non è praticabile.
|
||||
|
||||
## 4. Primo avvio
|
||||
|
||||
1. **Apply**. Il container semina `encelado.json` e `strategy.json` in `/mnt/user/appdata/encelado/config`.
|
||||
2. Apri `http://<ip-unraid>:8080/`, inserisci il token (resta in un cookie per trenta giorni).
|
||||
3. Se non hai messo le chiavi nelle variabili: **Impostazioni ▸ Chiavi eToro ▸ Verifica e salva** (vengono provate contro eToro e salvate cifrate in `/config/etoro.keys.enc`).
|
||||
4. Con `ENCELADO_AUTOSTART` al default (`1`) il motore è già partito; altrimenti **AVVIA**. Il chip in basso a sinistra dice `DEMO`.
|
||||
5. Per un test lungo guarda: contatore «in attesa · orfane · esterne» (orfane deve restare 0), **Storico ▸ Ordini** (unità richieste ed eseguite uguali, esito `Filled`), il log. Il kill-switch è il pulsante in alto a destra, o un file `STOP` in `/mnt/user/appdata/encelado/config`.
|
||||
|
||||
Il log è in `docker logs encelado` e in `/mnt/user/appdata/encelado/data/logs/encelado.log`; il ledger in `.../data/data/ledger/`.
|
||||
|
||||
## 5. Aggiornamento
|
||||
|
||||
*Docker ▸ Check for Updates*: il tag `latest` segue l'ultima release. Il container si ferma
|
||||
con SIGTERM (45 s di grazia): i basket aperti restano sul conto con gli stop nativi e
|
||||
vengono ripresi dallo stato salvato e dalla riconciliazione al riavvio (`docs/RUNBOOK.md`,
|
||||
«Recupero dopo inattività»). Per tornare a una versione precisa cambia il tag nel campo
|
||||
**Repository** (`…/encelado:5.0.0`).
|
||||
|
||||
## Icona
|
||||
|
||||
`assets/encelado.png` nel repository, servita da Gitea come file raw (`Icon` nel
|
||||
template). Non serve pubblicarla altrove (D-29).
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>Encelado</Name>
|
||||
<Repository>192.168.30.23:3000/alby96/encelado:latest</Repository>
|
||||
<Registry>http://192.168.30.23:3000/Alby96/-/packages/container/encelado</Registry>
|
||||
<Network>bridge</Network>
|
||||
<MyIP/>
|
||||
<Shell>sh</Shell>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>http://192.168.30.23:3000/Alby96/Encelado/issues</Support>
|
||||
<Project>http://192.168.30.23:3000/Alby96/Encelado</Project>
|
||||
<Overview>Encelado: bot di trading "Correlation Baskets" su eToro (Public API). Motore, ledger e interfaccia web Material 3 in un solo container. Configurazione in /config (encelado.json, strategy.json, chiavi cifrate), dati in /data (ledger, stato, barre, conoscenza, rapporti, log). Le chiavi eToro si mettono nelle variabili ETORO_API_KEY e ETORO_USER_KEY oppure si salvano dall'interfaccia (cifrate con ENCELADO_KEY_PASSPHRASE). Modalita' Live solo con ENCELADO_EXECUTION_MODE=Live, ETORO_ENVIRONMENT=real e la frase CONFERMO LIVE in ENCELADO_CONFIRM_LIVE.</Overview>
|
||||
<Category>Tools: Productivity:</Category>
|
||||
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
|
||||
<TemplateURL>http://192.168.30.23:3000/Alby96/Encelado/raw/branch/main/deploy/unraid/encelado.xml</TemplateURL>
|
||||
<Icon>http://192.168.30.23:3000/Alby96/Encelado/raw/branch/main/assets/encelado.png</Icon>
|
||||
<ExtraParams>--restart=unless-stopped --stop-timeout 45</ExtraParams>
|
||||
<PostArgs/>
|
||||
<CPUset/>
|
||||
<DateInstalled/>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Requires/>
|
||||
|
||||
<Config Name="Porta web" Target="8080" Default="8080" Mode="tcp" Description="Porta dell'interfaccia web e dell'API (http://IP:porta/)." Type="Port" Display="always" Required="true" Mask="false">8080</Config>
|
||||
<Config Name="Configurazione (/config)" Target="/config" Default="/mnt/user/appdata/encelado/config" Mode="rw" Description="encelado.json, strategy.json, instruments.json e il file cifrato delle chiavi. Seminati al primo avvio dai valori di fabbrica." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/encelado/config</Config>
|
||||
<Config Name="Dati (/data)" Target="/data" Default="/mnt/user/appdata/encelado/data" Mode="rw" Description="Ledger (data/ledger), stato (data/state), barre, cache dei feed, conoscenza, rapporti e log. Non cancellare: il ledger e' la memoria del bot." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/encelado/data</Config>
|
||||
|
||||
<Config Name="ETORO_API_KEY" Target="ETORO_API_KEY" Default="" Mode="" Description="Chiave x-api-key di eToro Public API (portale sviluppatori). In alternativa si salvano dall'interfaccia, cifrate con ENCELADO_KEY_PASSPHRASE." Type="Variable" Display="always" Required="false" Mask="true"></Config>
|
||||
<Config Name="ETORO_USER_KEY" Target="ETORO_USER_KEY" Default="" Mode="" Description="Chiave x-user-key di eToro Public API. Demo e reale hanno chiavi diverse." Type="Variable" Display="always" Required="false" Mask="true"></Config>
|
||||
<Config Name="ETORO_ENVIRONMENT" Target="ETORO_ENVIRONMENT" Default="demo" Mode="" Description="Ambiente eToro: demo (denaro virtuale) oppure real (conto reale; serve anche ENCELADO_EXECUTION_MODE=Live e la frase di conferma)." Type="Variable" Display="always" Required="true" Mask="false">demo</Config>
|
||||
<Config Name="ENCELADO_EXECUTION_MODE" Target="ENCELADO_EXECUTION_MODE" Default="Demo" Mode="" Description="Paper (simulatore locale sopra le quotazioni reali), Demo (conto demo eToro, predefinito) o Live (conto reale: richiede run.allowLive=true in encelado.json e la frase in ENCELADO_CONFIRM_LIVE)." Type="Variable" Display="always" Required="true" Mask="false">Demo</Config>
|
||||
<Config Name="ENCELADO_CONFIRM_LIVE" Target="ENCELADO_CONFIRM_LIVE" Default="" Mode="" Description="Solo per la modalita' Live: la frase esatta CONFERMO LIVE. Con qualunque altro valore il bot non parte sul conto reale." Type="Variable" Display="advanced" Required="false" Mask="false"></Config>
|
||||
<Config Name="ENCELADO_WEB_TOKEN" Target="ENCELADO_WEB_TOKEN" Default="" Mode="" Description="Token di accesso all'interfaccia web (una stringa lunga a piacere). SENZA token il server ascolta solo su localhost del container e l'interfaccia non e' raggiungibile dalla rete: mettilo." Type="Variable" Display="always" Required="true" Mask="true"></Config>
|
||||
<Config Name="ENCELADO_WEB_PORT" Target="ENCELADO_WEB_PORT" Default="8080" Mode="" Description="Porta interna di Kestrel. Cambiala solo se cambi anche la mappatura della porta qui sopra." Type="Variable" Display="advanced" Required="false" Mask="false">8080</Config>
|
||||
<Config Name="ENCELADO_DISPLAY_CURRENCY" Target="ENCELADO_DISPLAY_CURRENCY" Default="USD" Mode="" Description="Valuta con cui l'interfaccia mostra gli importi (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD). Il ledger resta in USD; il tasso viene dalle quotazioni eToro." Type="Variable" Display="always" Required="false" Mask="false">USD</Config>
|
||||
<Config Name="ENCELADO_KEY_PASSPHRASE" Target="ENCELADO_KEY_PASSPHRASE" Default="" Mode="" Description="Passphrase con cui l'interfaccia cifra le chiavi eToro salvate in /config/etoro.keys.enc (AES-256-GCM). Non serve se le chiavi arrivano dalle variabili qui sopra." Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
|
||||
<Config Name="TELEGRAM_BOT_TOKEN" Target="TELEGRAM_BOT_TOKEN" Default="" Mode="" Description="Token del bot Telegram (da @BotFather) per notifiche e comandi. Vuoto = Telegram spento." Type="Variable" Display="always" Required="false" Mask="true"></Config>
|
||||
<Config Name="TELEGRAM_CHAT_ID" Target="TELEGRAM_CHAT_ID" Default="" Mode="" Description="Chat id autorizzata a ricevere le notifiche e a mandare i comandi (/stato, /posizioni, /pausa, /kill CONFERMO...). Ogni altro mittente viene ignorato." Type="Variable" Display="always" Required="false" Mask="false"></Config>
|
||||
<Config Name="TZ" Target="TZ" Default="Europe/Rome" Mode="" Description="Fuso orario degli orari a schermo e nel log (nome IANA). Il ledger resta in UTC." Type="Variable" Display="always" Required="false" Mask="false">Europe/Rome</Config>
|
||||
<Config Name="PUID" Target="PUID" Default="99" Mode="" Description="Utente con cui gira il bot e a cui vengono assegnati /config e /data (99 = nobody su Unraid)." Type="Variable" Display="advanced" Required="false" Mask="false">99</Config>
|
||||
<Config Name="PGID" Target="PGID" Default="100" Mode="" Description="Gruppo del bot (100 = users su Unraid)." Type="Variable" Display="advanced" Required="false" Mask="false">100</Config>
|
||||
</Container>
|
||||
@@ -0,0 +1,50 @@
|
||||
# Encelado in locale con Docker Compose: immagine costruita dalla Dockerfile alla
|
||||
# radice, cartelle di configurazione e dati sotto deploy/local (ignorate da git).
|
||||
#
|
||||
# docker compose up --build # costruisce e avvia, log a video (Ctrl+C = stop pulito)
|
||||
# docker compose up -d --build # in background → http://localhost:8080/ (token "sviluppo")
|
||||
# docker compose logs -f encelado # segue il log
|
||||
# docker compose down # ferma (SIGTERM: stop pulito del motore)
|
||||
# Oppure: scripts/run-docker.ps1 (o .sh), che fa le stesse cose e stampa l'indirizzo.
|
||||
#
|
||||
# Le chiavi eToro e il token Telegram NON stanno qui: mettili in un file .env
|
||||
# accanto a questo (ignorato da git) con ETORO_API_KEY=…, ETORO_USER_KEY=…,
|
||||
# TELEGRAM_BOT_TOKEN=…, TELEGRAM_CHAT_ID=…, ENCELADO_WEB_TOKEN=…
|
||||
# Per Unraid usa invece il template in deploy/unraid/encelado.xml.
|
||||
|
||||
services:
|
||||
encelado:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
GIT_COMMIT: ${GIT_COMMIT:-n/d}
|
||||
VERSION: ${VERSION:-dev}
|
||||
image: 192.168.30.23:3000/alby96/encelado:${VERSION:-dev}
|
||||
container_name: encelado
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ENCELADO_WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ./deploy/local/config:/config
|
||||
- ./deploy/local/data:/data
|
||||
environment:
|
||||
TZ: ${TZ:-Europe/Rome}
|
||||
PUID: ${PUID:-1000}
|
||||
PGID: ${PGID:-1000}
|
||||
# demo | real — l'ambiente eToro; real richiede anche ENCELADO_EXECUTION_MODE=Live
|
||||
ETORO_ENVIRONMENT: ${ETORO_ENVIRONMENT:-demo}
|
||||
# Paper | Demo | Live
|
||||
ENCELADO_EXECUTION_MODE: ${ENCELADO_EXECUTION_MODE:-Demo}
|
||||
# Solo per Live: la frase esatta «CONFERMO LIVE», altrimenti il bot non parte
|
||||
ENCELADO_CONFIRM_LIVE: ${ENCELADO_CONFIRM_LIVE:-}
|
||||
ENCELADO_DISPLAY_CURRENCY: ${ENCELADO_DISPLAY_CURRENCY:-USD}
|
||||
# Senza token l'interfaccia è raggiungibile solo da localhost del container:
|
||||
# per usarla dal browser il token serve. "sviluppo" è il default delle prove
|
||||
# in locale; in .env mettine uno vero.
|
||||
ENCELADO_WEB_TOKEN: ${ENCELADO_WEB_TOKEN:-sviluppo}
|
||||
ENCELADO_KEY_PASSPHRASE: ${ENCELADO_KEY_PASSPHRASE:-}
|
||||
ETORO_API_KEY: ${ETORO_API_KEY:-}
|
||||
ETORO_USER_KEY: ${ETORO_USER_KEY:-}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
stop_grace_period: 45s
|
||||
@@ -0,0 +1,128 @@
|
||||
# Architettura di Encelado
|
||||
|
||||
Aggiornato: 2026-09-23 (5.0: Engine + Server, interfaccia web, container). La storia della ricognizione del 2026-09-16 (l'albero che non compilava, i motori Binance/cTrader, SQLite) è nella versione precedente di questo file nella storia git e in ADR-0004.
|
||||
|
||||
## 1. Progetti
|
||||
|
||||
```
|
||||
Encelado.slnx
|
||||
├── src/Encelado.Core libreria portabile (net10.0), zero NuGet, AOT/trim-compatibile, zero I/O
|
||||
│ ├── Broker/ IBroker, modelli (Instrument, QuoteSnapshot, AccountSnapshot, BrokerPosition, ClosedTrade, OrderRequest, OrderOutcome), PaperBroker, RateLimiter
|
||||
│ ├── Baskets/ SyntheticCross, PipMath, BasketMath, SymbolSeries, BasketDecider, CostGate, VolParitySizing, BasketExecutor, BasketPosition (con PendingA/PendingB),
|
||||
│ │ PendingEntry, BasketStrategyConfig (strategy.json, preset, risk, recovery, learning), OrderTracker (ADR-0009), PositionClassifier, EquityTracker,
|
||||
│ │ CashFlowDetector, FlattenProcedure, Heartbeat/HeartbeatFile, InstanceLock, RecoveryPlanner
|
||||
│ ├── Baskets/History/ OrderRecord (orders.jsonl), BasketOutcomeRow (baskets.csv), PositionRecord, PeriodStats, CashMovementRecord, EquityPoint, HistoryBuilder (la pagina Storico, pura)
|
||||
│ ├── Baskets/Data/ BidAskBar + CSV, TickToBars (tick MT5 → M15)
|
||||
│ ├── Baskets/Learning/ CalibrationTables, OnlineLogistic, SmallMlp, ThompsonBandit, VolForecast, LearningFeatures, ModelEvaluator
|
||||
│ ├── Baskets/Backtest/ BasketBacktest, BacktestBroker, BasketTrials
|
||||
│ ├── News/ CalendarParser, RssParser, SentimentLexicon, SentimentEngine
|
||||
│ ├── Notifications/ INotifier, NullNotifier, TelegramNotifier (solo HttpClient)
|
||||
│ └── Statistics/ Ols, Normal, Performance (Sharpe, PSR, DSR, drawdown), Classification, Pbo
|
||||
├── src/Encelado.Etoro EtoroOptions, EtoroHttp (x-api-key/x-user-key/x-request-id, limitatore per classe di quota, 429, scarto orologio), EtoroBroker : IBroker, Json
|
||||
├── src/Encelado.Engine libreria (net10.0): il motore e tutto ciò che tocca il disco o la rete oltre al broker
|
||||
│ ├── Baskets/ BasketEngine in otto file parziali (.cs ciclo e decisioni, .Pending, .Reconcile, .Kill, .Recovery, .State, .Commands, .Snapshot), Ledger, Feeds,
|
||||
│ │ ContextProvider, LearningState, HistoryService (fonti della pagina Storico, con cache), TelegramReports, TelegramCommands, HeadlessRunner (console: stato, comandi, bonifica)
|
||||
│ ├── Configuration/ BotConfig, ConfigLoader (JsonDocument, variabili d'ambiente), ConfigDefaults (JSON di fabbrica incorporato), ConfigWriter, AppPaths (/config, /data, Documenti), KeyStore (IKeyStore, EncryptedFileKeyStore, KeyStores)
|
||||
│ ├── Engine/ IEngine, BotSupervisor (ciclo di vita, snapshot, eventi di log), BotSnapshot, SnapshotJson (lo snapshot in JSON, a mano), SampleSnapshot (dati finti per --sample e test)
|
||||
│ ├── Logging/ Log statico non bloccante su Channel<T>, file ';' con rotazione, console
|
||||
│ └── Settings/ SettingsCatalogue e SettingField (il form di Impostazioni), SettingsService (descrizione e applicazione via API), UiClock (fuso orario a schermo)
|
||||
├── src/Encelado.Server l'eseguibile (Microsoft.NET.Sdk.Web, framework reference Microsoft.AspNetCore.App, nessun NuGet)
|
||||
│ ├── Program.cs argomenti e ambiente, AppPaths, log, notifier, supervisore, Telegram, LogBuffer, HistoryService, WebHost, avvio automatico, SIGTERM, --health, --sample
|
||||
│ └── Web/ WebHost (Kestrel, autenticazione, rotte statiche e API), Json (Utf8JsonWriter), LogBuffer (finestra del log per la pagina), LogBridge (log di ASP.NET → Log),
|
||||
│ wwwroot/ (index.html, login.html, app.css, app.js, icon.svg, manifest.webmanifest — incorporati)
|
||||
├── tools/Encelado.Backtest ticks, baskets, falsify, learn (referenzia Core ed Engine)
|
||||
├── tests/Encelado.Tests xunit (net10.0, gira su Windows e nello stadio di build dell'immagine)
|
||||
├── .gitea/workflows/ ci.yml (build, test, docker build a ogni push), release.yml (immagine sul registro e release a ogni tag)
|
||||
├── deploy/docker/ entrypoint.sh (TZ, PUID/PGID, gosu, exec)
|
||||
├── deploy/unraid/ encelado.xml (template Container v2), README.md
|
||||
├── Dockerfile, docker-compose.yml, .dockerignore
|
||||
└── build/ Release.proj (Verifica, Backtest, Pubblica, Docker, Pacchetto, Rilascia), README.md
|
||||
```
|
||||
|
||||
- **Target framework**: `net10.0` ovunque, `Nullable` e `TreatWarningsAsErrors` attivi via `Directory.Build.props`; `InvariantGlobalization` nei progetti dell'applicazione (i test lo spengono per verificare i fusi IANA e Windows). SDK 10.0.301.
|
||||
- **Pattern**: nessun contenitore DI; oggetti costruiti a mano in `Program.cs` e nel supervisore; async/await con `ConfigureAwait(false)` nel motore; `Channel<T>` per il log e le notifiche; `Lock` per lo stato condiviso; snapshot immutabili verso il server; ogni tabella è CSV `;` con colonna finale `motivazione`; log strutturato `timestamp;level;source;subject;event;message;exception;stack`.
|
||||
- **Storage**: solo file (ADR-0002). Nel container `/config` (configurazione, chiavi cifrate) e `/data` (`data/`, `knowledge/`, `reports/`, `results/`, `logs/`); fuori dal container `Documenti\Encelado` o `ENCELADO_CONFIG_DIR`.
|
||||
- **Build ed esecuzione**: `dotnet build Encelado.slnx`; `dotnet run --project src/Encelado.Server`; immagine con `dotnet msbuild build/Release.proj -t:Docker`; la versione rilasciata viene dal tag git, commit e data di build da `AssemblyMetadata`.
|
||||
|
||||
## 2. Flusso dati (live)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E[eToro Public API] -->|rates ogni 3 s| Q[Quote poller]
|
||||
Q --> B[Bar builder M15]
|
||||
E -->|candles| B
|
||||
B --> S[Strategy loop<br/>un solo thread]
|
||||
C[Calendario + RSS] --> F[Feature contesto]
|
||||
F --> S
|
||||
M[Meta-modello in ombra<br/>vol forecast] --> S
|
||||
S -->|decisione| X[Executor<br/>leg-risk + OrderTracker]
|
||||
X --> E
|
||||
S --> L[(Ledger jsonl/csv)]
|
||||
X --> L
|
||||
S --> N[Snapshot]
|
||||
N --> W[Server: SSE /api/stream<br/>console, Telegram]
|
||||
W --> U[Browser]
|
||||
U -->|POST /api/commands| W --> S
|
||||
L --> K[Ciclo di apprendimento<br/>backtest learn / Ricerca]
|
||||
K --> M
|
||||
```
|
||||
|
||||
Le decisioni avvengono su un solo thread; l'I/O è asincrono; l'unico gate umano per ordine è sparito (ADR-0005): restano avvio del reale, kill-switch, reset e cambio di preset, tutti dall'interfaccia web, dalla console o da Telegram.
|
||||
|
||||
## 3. Macchina a stati del basket
|
||||
|
||||
```
|
||||
Idle ──(segnale + cancelli)──► Entering ──(A e B eseguite)──► Open ──(add)──► Adding ──► Open
|
||||
▲ ▲ │ (B rifiutata → chiudi A, leg_risk_unwind, basket disattivato 1 h)
|
||||
│ │ │ (A senza esito oltre legTimeoutSec) ──► PendingA ──(A eseguita, segnale valido)──► Entering
|
||||
│ │ │ │ (A rifiutata → Idle; A eseguita e segnale decaduto → chiudi A → Idle)
|
||||
│ │ │ (A eseguita, B senza esito) ──► PendingB ──(B eseguita)──► Open
|
||||
│ └───────────────────────────────────────────────────────────────── (B rifiutata → chiudi A → Idle)
|
||||
│ ▼
|
||||
└────────── Closed ◄──── Exiting ◄──(TP | z_out | stop | time-stop | manuale | forzata)── Open
|
||||
│ (una gamba non chiude dopo 3 tentativi)
|
||||
▼
|
||||
Error (blocco nuove entrate finché non risolto)
|
||||
```
|
||||
|
||||
Stati del motore (non del basket): `Halted` dopo un kill-switch o un equity stop con il conto piatto per le posizioni del bot; **`Halted-Residuo`** quando la verifica di piattezza trova ancora posizioni del bot sul conto (banner rosso con l'elenco, reset rifiutato finché restano). Negli stati `PendingA`/`PendingB` il basket non viene valutato e non manda ordini; il registro degli ordini (`OrderTracker`) chiede l'esito al server e, quando manca, lo ricostruisce dalla posizione comparsa sul conto. Gli ingressi in sospeso sopravvivono a un riavvio (`pendingEntries` in `baskets_state.json`) e vengono risolti all'avvio prima di qualsiasi decisione.
|
||||
|
||||
## 4. Interfacce
|
||||
|
||||
- `IBroker`: `Environment`, `GetInstrumentsAsync`, `GetQuotesAsync(ids)`, `GetCandlesAsync(id, interval, count)`, `GetAccountAsync`, `GetPositionsAsync`, `OpenAsync(OrderRequest)` (esito per `orderId`, poi per posizione comparsa; mai un esito inventato), `LookupOrderAsync(clientRef)`, `LookupOrderByIdAsync(orderId)`, `CancelOrderAsync(orderId)`, `CloseAsync(positionId, instrumentId)`, `UpdateStopsAsync(positionId, sl, tp)`, `GetCostAsync(OrderRequest)`, `GetClosedTradesAsync`, `ClockSkew`.
|
||||
- `IContextProvider` (Engine): calendario, notizie e sentiment per basket (`FeedContextProvider`).
|
||||
- `IModel`: `Predict(features)`, `Update(features, label)`, JSON, implementato da `OnlineLogistic` e `SmallMlp`.
|
||||
- `IEngine` (Engine): `RunAsync`, `CloseAllAsync`, `ExecuteAsync(EngineCommand)` con `Close`, `KillSwitch` (argomento `esterne`), `SetPreset`, `ResetEquityStop(motivazione)` (la procedura in cinque passi), `CloseResidue`, `Bonifica`, `History`, `Pause`, `Resume`, `Snapshot()`. Ogni comando eseguito è una riga `comando` nel ledger.
|
||||
- `INotifier` (Core): `Notify(kind, title, text)` non bloccante e `Status`; il motore lo riceve dal supervisore, che lo riceve da `Program.cs` (`Notifications.Create`).
|
||||
- `IKeyStore` (Engine): `Load(demo)`, `Save`, `Clear`, `CanSave`, implementato da `EncryptedFileKeyStore` (`etoro.keys.enc`, AES-256-GCM con passphrase); le variabili `ETORO_API_KEY`/`ETORO_USER_KEY` le applica il loader e `KeyStores.Resolve` prova prima quelle poi il file.
|
||||
- `WebContext` (Server): ciò che il web host vede del processo — configurazione, supervisore, finestra del log, storico, percorso del file, `Start` e `Learn` come funzioni, `Snapshot()` (finto in `--sample`).
|
||||
|
||||
## 5. API web
|
||||
|
||||
Tutte le rotte tranne `/api/health`, `/login`, `/icon.svg`, `/manifest.webmanifest` chiedono il token (`ENCELADO_WEB_TOKEN`) nel cookie `encelado_token` (HttpOnly, 30 giorni) o in `Authorization: Bearer`. Senza token configurato il server ascolta solo su localhost e non chiede niente. JSON scritto a mano con `Utf8JsonWriter`, mai serializzazione per reflection.
|
||||
|
||||
| Rotta | Cosa |
|
||||
|---|---|
|
||||
| `GET /`, `/index.html`, `/app.css`, `/app.js`, `/icon.svg`, `/manifest.webmanifest`, `/login`; `POST /login` | la pagina e i suoi file, incorporati; il login mette il cookie e rimanda a `/` |
|
||||
| `GET /api/health` | `{ok, running, heartbeatAgeSeconds, disk, state}`; usato dal `HEALTHCHECK` del container |
|
||||
| `GET /api/snapshot` | lo snapshot completo (`SnapshotJson`) |
|
||||
| `GET /api/stream` | Server-Sent Events, `event: snapshot` con lo stesso JSON, uno al secondo |
|
||||
| `GET /api/log?level=&q=&limit=&after=` | la finestra del log (`LogBuffer`), a incrementi per `seq` |
|
||||
| `POST /api/commands/{start\|stop\|close\|kill\|preset\|reset\|residue\|pause\|resume\|bonifica\|learn}` | corpo `{argument, reason, confirm, foreign}`; risposta `{ok, message, steps[], positions[]}` |
|
||||
| `GET /api/history/orders\|positions\|periods[?format=csv&from=&to=&basket=&symbol=&outcome=&origin=]` | le tre viste dello Storico (`HistoryBuilder`); `format=csv` scarica il file `;` |
|
||||
| `GET/POST /api/settings` | il form (`SettingsService.Describe`) e le modifiche per percorso (`Apply`, con validazione e scrittura atomica) |
|
||||
| `POST/DELETE /api/settings/keys` | verifica le chiavi contro eToro (profilo e conto) e le salva nel file cifrato; rimozione |
|
||||
| `POST /api/settings/restore` | configurazione ai valori di fabbrica con backup datato (solo a bot fermo) |
|
||||
| `GET /api/info` | nome, autore, versione, data di build, commit, percorsi, .NET, sistema, uptime, quote, fuso |
|
||||
| `GET /api/research` | stato dell'apprendimento (abilitazioni, modello in ombra, bandit, volatilità, file in `knowledge/`) |
|
||||
|
||||
Il ciclo di vita del processo (`Program.cs`): carica `AppPaths` (semina `encelado.json` e `strategy.json`), inizializza il log (file + console), crea notifier e supervisore, avvia Kestrel, avvia il motore se `ENCELADO_AUTOSTART` ≠ 0 (chiavi da `KeyStores.Resolve`, frase `CONFERMO LIVE` per il Live), legge i comandi da stdin, si ferma su Ctrl+C, SIGTERM, `stop` o `--minutes`. `--health` interroga `/api/health` ed esce 0/1; `--sample` serve dati finti senza motore; `--bonifica` avvia con la bonifica interattiva.
|
||||
|
||||
## 6. Vincoli e limiti dell'API eToro
|
||||
|
||||
- L'endpoint candele accetta solo `count ≤ 1000` senza data di partenza: dà al massimo ~10 giorni di M15. Lo storico per il backtest viene dai tick MT5 forniti dall'utente (`A:\Download\Trading`, 2018-12 → 2026-09, UTC), convertiti in M15 bid/ask dallo strumento `backtest ticks`.
|
||||
- Quote di mercato (`/api/v2/market-data/rates`) in batch fino a 1000 strumenti per chiamata: un polling ogni 3 s costa 20 richieste/min sulla quota condivisa di 120/min. Le sette coppie di conversione della valuta di visualizzazione (EURUSD, GBPUSD, USDCHF, USDJPY, AUDUSD, USDCAD, NZDUSD) viaggiano nella stessa richiesta.
|
||||
- Quota ordini: 20 richieste/min (demo e reale separate). Un basket costa 2 aperture + 2 chiusure.
|
||||
- Le quote di `rates` sono senza markup; il costo effettivo (markup + spread di mercato + overnight) arriva da `POST /trading/info/{demo/}costs` (20/min dedicate). Il cost gate somma i due.
|
||||
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (asincrono: esito con `orders:lookup?orderId=`; il server **non** registra l'`x-request-id` come `referenceId`, verificato il 2026-09-23); `sellShort` e leva > 1 richiedono `stopLossRate`. Il server può **ridurre** un ordine invece di rifiutarlo (2 000 USD di margine a margine esaurito, 2026-09-16): le unità eseguite si leggono dalla risposta, mai date per scontate. Chiusura: `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}`. Cancellazione: `DELETE /api/v2/trading/execution/{demo/}orders/{id}`.
|
||||
- Esposizione minima per posizione: 1000 USD (`minPositionExposure`); leva ammessa 1-30 (majors) e 1-20 (minors). Il conto reale dell'utente vale 224,90 USD: con i limiti di rischio della strategia il reale non è praticabile oggi (vedi QUESTIONS D-05).
|
||||
@@ -0,0 +1,97 @@
|
||||
# Fonti dei dati
|
||||
|
||||
Aggiornato: 2026-09-16. Ogni fonte è stata verificata alla data indicata; se un feed cambia o sparisce, la voce va aggiornata e la decisione annotata in `docs/QUESTIONS.md`.
|
||||
|
||||
## 1. Mercato
|
||||
|
||||
| Fonte | Cosa | Formato | Frequenza | Limiti | Fallback |
|
||||
|---|---|---|---|---|---|
|
||||
| **eToro Public API** — `GET /api/v2/market-data/rates?instrumentIds=…` | bid/ask di tutti gli strumenti in una chiamata | JSON `{results:[{instrumentId,bid,ask,date,quoteType}]}` | polling ogni `run.pollSeconds` (3 s) | quota condivisa 120/min con le altre rotte di market data; il bot ne usa ~20/min | nessuno: senza quote il bot non decide |
|
||||
| **eToro Public API** — `GET /api/v1/market-data/instruments/{id}/history/candles/asc/FifteenMinutes/1000` | ultime 1000 candele M15 (mid, senza spread) | JSON | all'avvio, per il riscaldamento e il delta | **non pagina**: niente data di partenza, al massimo ~10 giorni | le barre locali salvate a ogni chiusura |
|
||||
| **eToro Public API** — `GET /api/v2/market-data/instruments?symbols=…`, `POST /trading/info/{demo/}eligibility` | id, nome, esposizione minima, leve, limiti di stop | JSON | all'avvio, scritti in `instruments.json` | 120/min e 20/min dedicate | valori prudenti incorporati (esposizione minima 1000 USD, leve 1-20) |
|
||||
| **eToro Public API** — `POST /trading/info/{demo/}costs` | markup, spread di mercato, commissione, overnight e weekend per un ordine ipotetico | JSON `{costs:[{costType, currency, value}]}` — il campo è **`value`** (verificato il 2026-09-16: EURUSD 10 000 unità → markup 0, marketSpread 0,1 USD, overnightFee 0,91 USD/giorno) | ogni 15 minuti per strumento | 20/min dedicate | markup 0 e overnight da `strategy.json` |
|
||||
| **Tick MetaTrader 5** — `A:\Download\Trading\<SYMBOL>_<da>_<a>.csv` | tick bid/ask 2018-12-12 → 2026-09-15, **UTC** (verificato sui fine settimana: chiusura venerdì 20:53 estate / 21:57 inverno, riapertura domenica 21:05 / 22:05) | tab-separato `<DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>`; flag 2 = solo bid, 4 = solo ask, 6 = entrambi | una tantum, `backtest ticks` | EURAUD copre solo parte del 2018, del 2021 e del 2026 (39 615 barre contro ~192 000 delle altre): il basket EURAUD/AUDCAD è misurabile solo su quei tratti | — |
|
||||
| Barre M15 derivate — `Documenti\Encelado\data\market\candles_<SYMBOL>_M15.csv` | OHLC bid e ask, spread medio, numero di tick, provenienza | CSV `;` (schema in `docs/LEDGER_SCHEMA.md`) | scritte dallo strumento e aggiornate dal bot a ogni barra chiusa | — | — |
|
||||
|
||||
Qualità (`data/market/data_quality.csv`, generato da `backtest ticks`, e `reports/data_quality.csv` dal bot): buchi > 1 h nei giorni feriali, salti > 2 % fra barre, duplicati. Una barra sospetta sospende le decisioni sul basket coinvolto per quella barra.
|
||||
|
||||
### 1.1 Ordini e posizioni (rotte verificate il 2026-09-23 sull'OpenAPI v1.379.0 e con chiamate reali sul conto demo)
|
||||
|
||||
| Rotta | Uso | Note verificate |
|
||||
|---|---|---|
|
||||
| `POST api/v2/trading/execution/{demo/}orders` | invio dell'ordine (`action open`, `transaction buy/sellShort`, `orderType mkt`, `units`, `leverage`, `stopLossRate`) | risponde 200 con `orderId`; il server lavora l'ordine in modo asincrono. **Non registra l'`x-request-id` come riferimento**: la lettura per `orderId` di un ordine del bot mostra `referenceID = 00000000-0000-0000-0000-000000000000`. Quota 20/min condivisa con chiusure e cancellazioni. |
|
||||
| `GET api/v2/trading/info/{demo/}orders:lookup?orderId=<id>` | esito dell'ordine, con le posizioni prodotte (`positionExecutions[].positionId`, `openingData.avgPrice`, `units`, `executionTime`, `fees`) | è la **chiave** usata dal bot. `requestedUnits`/`requestedAmount` possono differire dalle unità inviate: il 2026-09-16 il server ha ridotto gli ordini a 2 000 USD di margine (`frozenAmount 2000`, unità a sei decimali ricalcolate). Quota 60/min condivisa con `close-orders/{id}` e `orders/{id}`. |
|
||||
| `GET api/v2/trading/info/{demo/}orders:lookup?referenceId=<x-request-id>` | ripiego quando la risposta al `POST` è andata persa | 404 per gli ordini v2 del bot (vedi sopra). |
|
||||
| `GET api/v1/trading/info/{demo/}orders/{orderId}` | ripiego per `orderId` con la risposta v1 (`statusID`, `errorCode`, `positions[] {positionID, rate, units, occurred, isOpen}`) | verificato con l'ordine 381739181. |
|
||||
| `DELETE api/v2/trading/execution/{demo/}orders/{orderId}` | cancellazione di un ordine non ancora eseguito (kill-switch) | 200 = richiesta accettata, non annullamento avvenuto: confermare con il lookup (7 o 9 = annullato, 6 = in corso). Idempotente su ordini già chiusi. |
|
||||
| `GET api/v1/trading/info/{demo/}pnl` | conto e posizioni in una chiamata: `clientPortfolio.credit`, `bonusCredit`, `unrealizedPnL`, `positions[] {positionID, instrumentID, isBuy, units, openRate, openDateTime, amount (margine), leverage, unrealizedPnL.pnL, totalFees}` | `equity = credit + bonus + Σ amount + unrealized`; `available = credit + bonus`; `usedMargin = Σ amount`. Il conto demo **non compare** in `api/v1/balances` (solo i conti reali). |
|
||||
| `GET api/v1/trading/info/trade/{demo/}history?minDate=…&page=…&pageSize=200` | posizioni chiuse: `positionId`, `orderId`, `openRate`, `closeRate`, `openTime`, `closeTime`, `netProfit`, `fees`, `investment` | `netProfit` **non** include `fees`. Fonte del realizzato della scheda Storico e della distinzione fra chiusure e movimenti di cassa. |
|
||||
|
||||
**Stati dell'ordine** (`status.id` / `statusID`): 1 Received, 2 Placed, 3 Filled, 4 Rejected, 5 PartiallyFilled, 6 PendingCancel, 7 Canceled, 8 Expired, 9 CanceledPartiallyFilled, 10 RejectedPartiallyFilled, 11 WaitingForMarket, 12 PendingTriggeredRate. Il bot tratta 3 e 5 come eseguito, 4, 7, 8, 9, 10 come rifiutato/annullato, 1, 2, 6, 11, 12 come in corso; in assenza di risposta lo stato è `Unknown` e l'ordine resta nel registro. Esiste anche `POST api/v3/trading/execution/{demo/}orders` (202, stessa semantica, `settlementType` obbligatorio): non usato, annotato per il futuro.
|
||||
|
||||
## 2. Calendario economico
|
||||
|
||||
| Fonte | URL | Formato | Aggiornamento | Note |
|
||||
|---|---|---|---|---|
|
||||
| Forex Factory via FairEconomy | `https://nfs.faireconomy.media/ff_calendar_thisweek.json` | JSON `[{title,country,date,impact,forecast,previous,actual}]`, `date` con offset (ora di New York) | il feed cambia più volte al giorno; il bot lo rilegge ogni 10 minuti, mai più di una richiesta al minuto | `country` è già il codice valuta (`USD, EUR, GBP, JPY, AUD, NZD, CAD, CHF, CNY`, `All`); `impact` ∈ {High, Medium, Low, Holiday} |
|
||||
| variante XML | `https://nfs.faireconomy.media/ff_calendar_thisweek.xml` | `<weeklyevents><event>` con `date` MM-DD-YYYY e `time` 8:15am **in UTC** (verificato contro il JSON: "10:30pm" del 09-13 = "18:30-04:00") | idem | usata solo come riserva |
|
||||
|
||||
Archivio: `data/calendar/events.jsonl` (append-only, una riga per evento, dedup per `title+date+country`; un `actual` che arriva dopo la pubblicazione è una riga nuova). Feature derivate per ogni valuta: `minutesToNextHigh`, `minutesSinceLastHigh`, `surpriseLast = (actual − forecast)/|forecast|`.
|
||||
|
||||
Limite: il feed copre **la settimana corrente**. Non esiste uno storico gratuito: il backtest non applica il blackout né le feature di calendario, e lo dice (`docs/STRATEGY.md`).
|
||||
|
||||
## 3. Notizie (RSS)
|
||||
|
||||
Tutte lette con `User-Agent: Encelado/4.0 (+correlation baskets; contact: operator)`, al massimo una richiesta al minuto per fonte, con backoff esponenziale sugli errori e rispetto di `robots.txt` (gruppo `User-agent: *`). Verifica del 2026-09-16:
|
||||
|
||||
| Fonte | URL | Formato | Esito |
|
||||
|---|---|---|---|
|
||||
| FXStreet | `https://www.fxstreet.com/rss/news` | RSS 2.0 | 200 |
|
||||
| ForexLive | `https://www.forexlive.com/feed/` | RSS 2.0 | 200 |
|
||||
| Federal Reserve | `https://www.federalreserve.gov/feeds/press_all.xml` | RSS 2.0 | 200 con lo User-Agent del bot; con uno User-Agent minimale risponde con una pagina HTML "not found" |
|
||||
| BCE | `https://www.ecb.europa.eu/rss/press.html` | RSS 2.0 | 200 |
|
||||
| Bank of England | `https://www.bankofengland.co.uk/rss/news` | RSS 2.0 | 200 |
|
||||
| RBA | `https://www.rba.gov.au/rss/rss-cb-media-releases.xml` | RSS 1.0 (RDF) | 200 alla prima verifica, poi "Access Denied" (Akamai) a richieste successive: tenuta con backoff, coperta anche da Google News `"Reserve Bank of Australia"` |
|
||||
| Bank of Canada | `https://www.bankofcanada.ca/content_type/press-releases/feed/` | RSS 1.0 (RDF) | 200 |
|
||||
| SNB | `https://www.snb.ch/en/rss/press-releases` | — | **404**: omessa (D-08); coperta da Google News `"Swiss National Bank"` |
|
||||
| RBNZ | `https://www.rbnz.govt.nz/rss/news` | — | **403** "website unavailable": omessa (D-08); coperta da Google News `RBNZ` |
|
||||
| Google News | `https://news.google.com/rss/search?q=<query>&hl=en-US&gl=US&ceid=US:en` per `EURUSD`, `"Swiss National Bank"`, `RBNZ`, `forex dollar` | RSS 2.0 | 200 |
|
||||
|
||||
Archivio: `data/news/news_YYYYMM.jsonl` (append-only, una riga per item, dedup per `hash(link)`), con `published, source, title, summary, link, currencies, scores{net, hawkish, riskOff}`.
|
||||
|
||||
Sentiment senza librerie (`Encelado.Core/News/SentimentLexicon.cs`, `SentimentEngine.cs`): lessico incorporato in tre dimensioni (tono positivo/negativo ~180 termini ciascuno, hawkish/dovish ~80, risk-on/risk-off ~50), negazione a finestra di tre parole, attribuzione alle valute per entità (`Fed, Powell, FOMC → USD; ECB, Lagarde → EUR; BoJ → JPY; RBA → AUD; RBNZ → NZD; BoC → CAD; SNB → CHF; BoE → GBP`), parole-paese e nomi di coppia. Per ogni valuta e finestra (1 h, 4 h, 24 h): `netSentiment`, `hawkishScore`, `riskOff` (globale), `newsCount`, con decadimento esponenziale a emivita 2 h. Le feature di un basket sono le differenze fra le sue due valute non comuni.
|
||||
|
||||
Copie dei feed usate dai test: `tests/fixtures/` (scaricate il 2026-09-16).
|
||||
|
||||
## 3.1 Telegram Bot API (dalla 5.0)
|
||||
|
||||
| Rotta | Uso | Note |
|
||||
|---|---|---|
|
||||
| `POST https://api.telegram.org/bot<token>/sendMessage` | notifiche e risposte, corpo `{chat_id, text, parse_mode: "HTML", disable_web_page_preview: true}` | limite 4096 caratteri per messaggio (il bot spezza sulle righe); un invio al secondo; su 429 il corpo porta `parameters.retry_after`; un 4xx (token, chat o markup sbagliati) non viene ritentato |
|
||||
| `GET https://api.telegram.org/bot<token>/getUpdates?offset=<n>&timeout=25&allowed_updates=["message"]` | comandi in ingresso, long polling da un solo task | solo i messaggi con `chat.id` uguale a `TELEGRAM_CHAT_ID` vengono eseguiti; gli altri sono ignorati e annotati nel log; `offset` avanza all'ultimo `update_id` + 1 |
|
||||
|
||||
Il token non è mai in un file del repository: `TELEGRAM_BOT_TOKEN` nell'ambiente (o, a scelta dell'operatore, `botToken` in `encelado.local.json`).
|
||||
|
||||
## 4. Schema dei file (nel container `/config` e `/data`; fuori dal container `Documenti\Encelado`)
|
||||
|
||||
```
|
||||
/config: encelado.json, strategy.json, instruments.json, etoro.keys.enc (chiavi cifrate), STOP (kill-switch da file)
|
||||
/data:
|
||||
data/market/candles_<SYMBOL>_M15.csv timeUtc;bidOpen;bidHigh;bidLow;bidClose;askOpen;askHigh;askLow;askClose;spreadMean;ticks;motivazione
|
||||
data/market/data_quality.csv simbolo;tick_letti;tick_scartati;barre;prima_barra;ultima_barra;buchi_feriali_oltre_1h;barre_spike;spread_mediano_pip;motivazione
|
||||
data/calendar/events.jsonl {title,country,date,impact,forecast,previous,actual}
|
||||
data/news/news_YYYYMM.jsonl {hash,published,source,title,summary,link,currencies[],scores{net,hawkish,riskOff}}
|
||||
data/cache/<fonte>.xml|json ultimo corpo buono di ogni feed
|
||||
data/ledger/decisions.jsonl vedi docs/LEDGER_SCHEMA.md (rotazione mensile in decisions_YYYYMM.jsonl)
|
||||
data/ledger/baskets.csv vedi docs/LEDGER_SCHEMA.md
|
||||
data/ledger/orders.jsonl una riga per ordine inviato e per cambio di stato (5.0)
|
||||
data/state/baskets_state.json posizioni aperte, ingressi in attesa, picco di equity al netto dei movimenti di cassa, blocchi
|
||||
data/state/pending_orders.json il registro degli ordini (5.0)
|
||||
data/state/heartbeat.json ultimo battito (30 s), run, nota di arresto (5.0)
|
||||
data/state/instance.lock un solo bot per cartella dati (5.0)
|
||||
data/state/paper_state.json il conto del simulatore (solo Paper)
|
||||
data/models/*.json modelli (livelli 1-3) e stato del bandit
|
||||
knowledge/*.csv, *.md calibrazione, proposte, registri, insight settimanali
|
||||
reports/*.csv qualità dati, falsificazione, bonifica_YYYYMMDD, recupero_<run_id> (5.0)
|
||||
logs/encelado.log log applicativo (;)
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
# Encelado in Docker
|
||||
|
||||
Aggiornato: 2026-09-23 (5.0). Dalla 5.0 il bot gira **solo** nel container (ADR-0007, ADR-0008): un processo `Encelado.Server` che ospita il motore e serve l'interfaccia web su Kestrel. Per Unraid c'è il template in `deploy/unraid/` (vedi il README lì).
|
||||
|
||||
## Immagine
|
||||
|
||||
`192.168.30.23:3000/alby96/encelado:<versione>` e `:latest`, sul registro dei container di Gitea. Si costruisce dalla `Dockerfile` alla radice:
|
||||
|
||||
```powershell
|
||||
dotnet msbuild build/Release.proj -t:Docker # :<versione dal tag> e :latest, con i test dentro la build
|
||||
docker build -t encelado:dev --build-arg GIT_COMMIT=$(git rev-parse --short=12 HEAD) . # a mano
|
||||
```
|
||||
|
||||
Stadi: `build` (restore + compilazione Release), `test` (la suite xunit: se è rossa l'immagine non esiste), `publish` (framework-dependent, non trimmed), `runtime` (`mcr.microsoft.com/dotnet/aspnet:10.0` + `gosu` + `tzdata`). L'immagine porta le etichette OCI `version` e `revision`; commit e data di build sono in Impostazioni ▸ Informazioni.
|
||||
|
||||
## Volumi
|
||||
|
||||
| Dentro | Contenuto | Su Unraid |
|
||||
|---|---|---|
|
||||
| `/config` | `encelado.json`, `strategy.json`, `instruments.json`, `etoro.keys.enc` (chiavi cifrate), file `STOP` per il kill-switch | `/mnt/user/appdata/encelado/config` |
|
||||
| `/data` | `data/` (ledger, stato, barre, cache, modelli), `knowledge/`, `reports/`, `results/`, `logs/` | `/mnt/user/appdata/encelado/data` |
|
||||
|
||||
Al primo avvio il container semina `encelado.json` e `strategy.json` dai valori di fabbrica. Nel container `run.dataPath`, `knowledgePath`, `reportsPath` e `logging.path` vengono rimappati sotto `/data` qualunque cosa dica il file: non c'è niente da cambiare a mano. I file appartengono a `PUID:PGID`.
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
| Variabile | Default | Effetto |
|
||||
|---|---|---|
|
||||
| `ENCELADO_WEB_TOKEN` | vuoto | Token dell'interfaccia. **Senza token il server ascolta solo su localhost del container**: dalla rete non si vede niente. Si inserisce una volta nel browser e resta in un cookie HttpOnly per 30 giorni; l'API accetta anche `Authorization: Bearer <token>`. |
|
||||
| `ENCELADO_WEB_PORT` | `8080` | Porta di Kestrel. |
|
||||
| `ETORO_API_KEY`, `ETORO_USER_KEY` | vuoto | Chiavi eToro Public API; hanno la precedenza sul file cifrato. |
|
||||
| `ENCELADO_KEY_PASSPHRASE` | vuoto | Passphrase del file `etoro.keys.enc` (AES-256-GCM, PBKDF2 200 000 iterazioni) scritto da Impostazioni ▸ Chiavi eToro. Senza passphrase il salvataggio dall'interfaccia è disattivato. |
|
||||
| `ETORO_ENVIRONMENT` | `demo` | `demo` o `real`. |
|
||||
| `ENCELADO_EXECUTION_MODE` | `Demo` | `Paper`, `Demo`, `Live`. |
|
||||
| `ENCELADO_CONFIRM_LIVE` | vuoto | La frase `CONFERMO LIVE`, obbligatoria per `Live` insieme a `run.allowLive = true`. |
|
||||
| `ENCELADO_AUTOSTART` | `1` | `0` = il motore non parte da solo: si preme AVVIA nell'interfaccia. |
|
||||
| `ENCELADO_DISPLAY_CURRENCY` | `USD` | Valuta di visualizzazione (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD). Il ledger resta in USD. |
|
||||
| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | vuoto | Notifiche e comandi Telegram. |
|
||||
| `TZ` | `UTC` | Fuso degli orari a schermo e nel log (nome IANA, es. `Europe/Rome`); il ledger è UTC. Vale anche come `ui.timeZone` se il file dice `computer`. |
|
||||
| `PUID`, `PGID` | `99`, `100` | Utente e gruppo del processo e dei volumi (`nobody:users` di Unraid). |
|
||||
| `ENCELADO_LOG_LEVEL`, `ENCELADO_CONFIG_DIR`, `ENCELADO_DATA_DIR` | — | Livello di log; cartelle alternative (di norma non servono nel container). |
|
||||
|
||||
## Avvio, arresto, aggiornamento
|
||||
|
||||
```powershell
|
||||
docker compose up -d # sviluppo locale: cartelle in deploy/local/, segreti in .env
|
||||
docker compose logs -f encelado
|
||||
docker compose down # SIGTERM → arresto pulito (45 s di grazia)
|
||||
docker pull 192.168.30.23:3000/alby96/encelado:latest && docker compose up -d # aggiornamento
|
||||
```
|
||||
|
||||
Su `SIGTERM` il server ferma il motore come da `run.closeOnShutdown` (default `false`: i basket restano sul conto con gli stop nativi), chiude il ledger, scrive l'heartbeat con la nota di arresto ed esce con 0. Al riavvio riprende lo stato salvato, riconcilia il conto e, se è passato più di `recovery.thresholdMinutes`, esegue il recupero dopo inattività (`docs/RUNBOOK.md`).
|
||||
|
||||
## Healthcheck
|
||||
|
||||
`HEALTHCHECK` ogni 30 s: `dotnet /app/Encelado.Server.dll --health` chiama `GET /api/health` sulla porta configurata ed esce 0 solo se il server risponde, il disco dei dati è scrivibile e, a motore acceso, l'heartbeat è più fresco di 105 s. `docker inspect --format '{{.State.Health.Status}}' encelado` per leggerlo. Il campo JSON di `/api/health` dice quale condizione fallisce.
|
||||
|
||||
## Log
|
||||
|
||||
Sulla console (stdout, `docker logs`) **e** nel file `/data/logs/encelado.log` (CSV `;`, rotazione per dimensione). L'ora nel log è nel fuso `TZ` con l'offset esplicito; il ledger resta UTC. Il livello si cambia con `ENCELADO_LOG_LEVEL` o da Impostazioni.
|
||||
|
||||
## Rete
|
||||
|
||||
Il server ascolta su tutte le interfacce **solo** quando `ENCELADO_WEB_TOKEN` è impostato. Non c'è TLS: l'interfaccia è pensata per la rete locale (Unraid) o dietro un reverse proxy che lo aggiunge. Le rotte `/api/health`, `/login`, `/icon.svg` e `/manifest.webmanifest` sono libere; tutto il resto chiede il token.
|
||||
|
||||
## Registro privato in HTTP
|
||||
|
||||
Il Gitea dell'utente è raggiunto in HTTP: chi fa `docker pull` deve dichiararlo `insecure-registry` (Docker Desktop: *Settings ▸ Docker Engine* → `"insecure-registries": ["192.168.30.23:3000"]`; Unraid: vedi `deploy/unraid/README.md`). Il push dalla catena di rilascio (`-t:Rilascia`) fa `docker login` con il token di `build/gitea.json` e `docker logout` subito dopo.
|
||||
|
||||
## Gitea Actions
|
||||
|
||||
Due workflow in `.gitea/workflows/`, sul modello di Rea:
|
||||
|
||||
| Workflow | Quando | Cosa fa |
|
||||
|---|---|---|
|
||||
| `ci.yml` | push su `main`, pull request | `dotnet build -warnaserror` e `dotnet test`; poi `docker build` dell'immagine (senza pubblicarla) per accorgersi di una Dockerfile rotta |
|
||||
| `release.yml` | tag `v*` | costruisce e pubblica `<host>/alby96/encelado:<versione>` e `:latest` sul registro di Gitea (solo `linux/amd64`: la build esegue i test e sotto QEMU sarebbe lentissima), poi crea la release con il template Unraid allegato; se la release esiste già (creata dalla catena) aggiunge solo l'allegato mancante |
|
||||
|
||||
Servono due cose che al 2026-09-23 **mancano**:
|
||||
|
||||
1. **Un runner.** Sul server non ce n'è nessuno (`/api/v1/repos/Alby96/Encelado/actions/runners` è vuoto): i workflow restano in coda. Il modo più semplice è un container `gitea/act_runner` su Unraid con il socket di Docker montato (`/var/run/docker.sock`), registrato con il token di *Site Administration ▸ Actions ▸ Runners ▸ Create new runner* e con l'etichetta `ubuntu-latest:docker://catthehacker/ubuntu:act-latest`. Con il registro in HTTP il runner (e la sua immagine di lavoro) deve avere `192.168.30.23:3000` fra gli `insecure-registries`.
|
||||
2. **Il segreto `REGISTRY_TOKEN`** nel repository (*Impostazioni ▸ Actions ▸ Segreti*): un token dell'utente con `package:write` e `repository:write` (lo stesso di `build/gitea.json` va bene).
|
||||
|
||||
Finché il runner non c'è, il rilascio si fa dal PC con `dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=X.Y.Z`, che produce lo stesso risultato (immagine sul registro, tag, release con allegati); i due percorsi sono idempotenti fra loro.
|
||||
|
||||
## Prove in locale: la sandbox `deploy/local`
|
||||
|
||||
`deploy/local/config` e `deploy/local/data` (ignorate da git) sono il `/config` e il `/data` delle prove sul PC, in tutti e tre i modi di avvio:
|
||||
|
||||
| Modo | Comando | Note |
|
||||
|---|---|---|
|
||||
| F5 in VS Code | `Encelado (server)` | processo locale, `ENCELADO_CONFIG_DIR`/`ENCELADO_DATA_DIR` puntati alla sandbox, browser aperto da solo, motore fermo finché non premi AVVIA (senza token: solo localhost, nessun login) |
|
||||
| script | `scripts\run-dev.ps1` / `sh scripts/run-dev.sh` (`--sample`, `--autostart`) | come F5 ma da terminale, con i comandi da tastiera |
|
||||
| container | `scripts\run-docker.ps1` / `sh scripts/run-docker.sh` / `docker compose up -d --build` | l'immagine vera; token `sviluppo` salvo `ENCELADO_WEB_TOKEN` in `.env`; `--down` ferma con SIGTERM |
|
||||
|
||||
`ENCELADO_DATA_DIR` vale anche fuori dal container: separa i dati dalla configurazione come fa `/data`. Le chiavi salvate da Impostazioni finiscono in `deploy/local/config/etoro.keys.enc` e valgono per F5 e per il container (stessa `ENCELADO_KEY_PASSPHRASE`). `Encelado (campione)` e `--sample` servono lo snapshot finto: nessuna chiave, per lavorare sulle pagine e fotografarle (`scripts\screenshots.ps1`).
|
||||
|
||||
Fuori dalla sandbox, senza variabili, il processo locale usa `Documenti\Encelado`: sconsigliato, è la cartella dell'installazione 4.0.0.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Glossario
|
||||
|
||||
| Termine | Significato in Encelado |
|
||||
|---|---|
|
||||
| **Basket** | Due posizioni (una per coppia forex) aperte insieme e chiuse insieme, trattate come una sola scommessa sul cross sintetico. |
|
||||
| **Cross sintetico** | La coppia implicita nelle due gambe: `X = ln A + s·ln B`, con `s = +1` se la valuta comune ha ruoli opposti (EURUSD/USDCHF → EURCHF) e `−1` se uguali. Tutti e cinque i basket della specifica hanno `s = +1`. |
|
||||
| **Gamba** | Una delle due posizioni del basket. |
|
||||
| **z-score** | `(X − media_W) / σ_W` del cross sintetico su una finestra di W barre M15. Ingresso a `|z| ≥ z_in`, uscita a `|z| ≤ z_out` o allo stop `|z| ≥ z_stop`. |
|
||||
| **ρ_W, ρ_20** | Correlazione rolling dei rendimenti delle due gambe su W e su 20 barre. Attesa negativa per i basket della specifica (`rho_min` −0,6). |
|
||||
| **Half-life (HL)** | Semiperiodo di mean reversion del cross, in barre, da un OLS di Δx su x(t−1). Ammesso fra `halfLifeMinBars` e `halfLifeMaxBars`. |
|
||||
| **Preset** | Conservative / Moderate / Aggressive: z_in, rischio per basket, numero massimo di basket, TP in pip, aggiunte, z_stop. |
|
||||
| **TP di basket** | Take-profit in pip, somma dei pip delle due gambe (come nell'interfaccia di riferimento). |
|
||||
| **Cost gate** | Il rifiuto di un ingresso se il TP non copre almeno `costMultiple` volte il costo stimato (spread reale + markup + commissioni + overnight atteso), o se lo spread è più del doppio della mediana delle ultime 24 ore. |
|
||||
| **Break-even** | Il costo in pip oltre il quale il P&L medio lordo di un basket diventa negativo: se è vicino a zero, il segnale non ha contenuto. |
|
||||
| **Vol-parity sizing** | Le unità di ogni gamba sono inversamente proporzionali alla sua volatilità (ATR), così le due gambe contribuiscono allo stesso rischio; il rischio totale è `riskPerBasketPct` dell'equity alla distanza dello stop. |
|
||||
| **Leg-risk** | Il rischio di restare con una sola gamba: se la seconda viene rifiutata, la prima viene chiusa subito (`leg_risk_unwind`); se la seconda è senza esito, il basket aspetta (`PendingB`) finché il registro degli ordini non sa. |
|
||||
| **Registro degli ordini** | `OrderTracker` e il file `data/state/pending_orders.json`: ogni ordine inviato, scritto prima della chiamata e seguito finché il server non dice eseguito, rifiutato o annullato, o finché la posizione non compare sul conto. |
|
||||
| **PendingA / PendingB** | Stati del basket con una gamba senza esito: nessun nuovo ordine, valutazione sospesa, ripresa alla risoluzione. |
|
||||
| **Orfana-bot** | Una posizione sul conto aperta dal bot (registro o firma nel ledger) che non appartiene a nessun basket: adottata e chiusa. |
|
||||
| **Esterna** | Una posizione sul conto senza la firma del bot: segnalata, contata, mai toccata. |
|
||||
| **Movimento di cassa** | Deposito, prelievo o accredito virtuale: un salto del saldo che nessuna chiusura spiega. Escluso dal P&L, dal picco e dal drawdown. |
|
||||
| **Bonifica** | La pulizia una tantum delle orfane con conferma per posizione (`--bonifica`). |
|
||||
| **Halted-Residuo** | Lo stato del motore dopo un kill-switch che non è riuscito ad appiattire le posizioni del bot: banner con l'elenco, reset rifiutato finché restano. |
|
||||
| **Verifica di piattezza** | La rilettura del conto dopo il kill-switch finché le posizioni del bot non sono sparite; niente è «chiuso» prima. |
|
||||
| **Margin guard** | Sotto equity / margine usato = 1,5 niente entrate; sotto 1,2 si chiude il basket con il P&L peggiore. |
|
||||
| **sizing_bound** | Quale vincolo ha deciso la size di un basket: `risk`, `margin`, `leverage`, `units`; `min_exposure` quando nessuna size è ammessa. |
|
||||
| **Heartbeat** | `data/state/heartbeat.json`, scritto ogni 30 s: misura l'inattività che fa scattare il recupero. |
|
||||
| **Recupero** | La procedura dopo un'inattività oltre la soglia: ordini pendenti, riconciliazione, barre perse, ogni basket aperto rivalutato (chiudi/tieni), rapporto, riscaldamento. |
|
||||
| **Lock di istanza** | `data/state/instance.lock`, tenuto in esclusiva: un solo bot per cartella dati. |
|
||||
| **Equity stop** | Chiusura di tutto e blocco a un drawdown del 9 % dal picco; riparte solo con un reset motivato. |
|
||||
| **Kill-switch** | Chiusura immediata di tutto e blocco delle nuove entrate: pulsante, comando o file `STOP`. |
|
||||
| **Paper / Demo / Live** | Simulatore locale / conto demo eToro / conto reale. Il bot opera da solo in tutte e tre (D-20). |
|
||||
| **Ledger** | `decisions.jsonl` (ogni decisione con le sue feature) e `baskets.csv` (ogni basket chiuso). Append-only: le correzioni sono righe nuove. |
|
||||
| **Meta-modello** | La regressione logistica (livello 1) che stima la probabilità che un basket finisca in utile. In ombra finché non supera i cancelli di attivazione. |
|
||||
| **Challenger** | L'MLP (livello 2) valutato accanto al campione. |
|
||||
| **Bandit** | Il campionamento di Thompson (livello 3) che propone il preset per terzile di volatilità. |
|
||||
| **Walk-forward** | Valutazione in cui ogni previsione usa solo dati precedenti; per la griglia del backtest: scegli il migliore dei 6 mesi passati, applicalo al mese successivo. |
|
||||
| **PSR / DSR** | Probabilistic e Deflated Sharpe Ratio: la probabilità che lo Sharpe osservato sia sopra zero, tenendo conto di asimmetria, curtosi, lunghezza e (DSR) del numero di configurazioni provate. |
|
||||
| **PBO** | Probabilità di overfitting del backtest (CSCV, 16 blocchi): quante volte la configurazione migliore in-sample finisce sotto la mediana out-of-sample. |
|
||||
| **Falsificazione** | I test di §9.2: ZScore contro PipDivergence, averaging on/off, con e senza stop, cost gate a 2×/3×/4×, segnale invertito. Servono a rompere il risultato, non a confermarlo. |
|
||||
| **Forward test** | Il periodo in Demo con metrica, soglia e durata scritte prima (`knowledge/preregistrazione.csv`). |
|
||||
| **Blackout** | Niente entrate 45 minuti prima e 30 dopo un evento ad alto impatto sulle valute del basket. |
|
||||
| **PSI** | Population Stability Index: misura la deriva della distribuzione di una feature rispetto all'addestramento. |
|
||||
| **HAR-RV** | Modello eterogeneo autoregressivo della varianza realizzata (medie a 1, 5, 22 giorni). |
|
||||
| **Run id** | Identificatore della sessione del bot, scritto in ogni riga del ledger con l'hash di `strategy.json`. |
|
||||
| **Snapshot** | La fotografia immutabile dello stato del motore (conto, basket, contatori, contesto) che il server serializza in JSON e manda alla pagina una volta al secondo. La UI non ha altro. |
|
||||
| **SSE** | Server-Sent Events: la rotta `/api/stream` che spinge uno snapshot al secondo al browser. |
|
||||
| **Token web** | `ENCELADO_WEB_TOKEN`: la stringa che apre l'interfaccia; senza, il server ascolta solo su localhost. |
|
||||
| **Valuta di visualizzazione** | La valuta con cui la pagina mostra gli importi (`ui.displayCurrency`); conversione solo in presentazione con il tasso delle quotazioni, ledger sempre in USD. |
|
||||
| **Modalità campione** | `--sample`: il server serve uno snapshot finto e non avvia il motore; per lavorare sulle pagine e fotografarle. |
|
||||
| **Navigation rail** | La colonna di navigazione a sinistra (Dashboard, Storico ordini, Log, Impostazioni), 80 o 256 px. |
|
||||
@@ -0,0 +1,48 @@
|
||||
# Problemi noti e limiti
|
||||
|
||||
Aggiornato: 2026-09-23. Una voce per limite, con lo stato. Quando un limite viene rimosso, la voce si sposta nel `CHANGELOG.md`.
|
||||
|
||||
## Strategia
|
||||
|
||||
- **Il backtest è negativo.** Su 7,75 anni di barre M15 nessuna configurazione della griglia è profittevole al netto dei costi assunti; il break-even è vicino a zero, cioè il segnale non ha contenuto misurabile (`docs/STRATEGY.md`). Il modulo resta uno strumento di forward test in Demo, non un sistema da mettere sul reale.
|
||||
- **EURAUD** ha tick solo per parti del 2018, 2021 e 2026: il basket EURAUD/AUDCAD è misurabile in backtest solo su quei tratti.
|
||||
- Il backtest non ha calendario né notizie: blackout e sentiment sono attivi solo dal vivo. L'effetto del blackout sui risultati non è misurato.
|
||||
- **Il cancello di correlazione ρ_W ≤ −0,6 si apre di rado sulle barre M15**: nelle 4 ore di Demo del 2026-09-16 31 segnali su 31 sono stati rifiutati per quel solo motivo. Non è un bug: è la soglia della specifica; va misurata sul ledger prima di proporre un valore diverso (`knowledge/proposals.csv`).
|
||||
- I costi del backtest sono assunzioni (spread tipici pubblicati o spread dei tick, overnight 0,3 o 0,9 pip/gamba/giorno). Il costo vero si misura solo nel ledger del Demo.
|
||||
|
||||
## eToro
|
||||
|
||||
- L'endpoint delle candele non pagina: al massimo ~10 giorni di M15. Lo storico dipende dai tick forniti dall'utente.
|
||||
- L'API demo mostra spread di mercato di 0,1-0,7 pip senza markup e un overnight di 0,91 USD/giorno per 10 000 EURUSD. Se l'esecuzione reale applica uno spread diverso, lo si vedrà dallo slippage scritto nel ledger a ogni ingresso.
|
||||
- Il campo dei costi si chiama `value` (non `amount`, come si era scritto in prima battuta): corretto il 2026-09-16 pomeriggio; le righe del ledger della mattina hanno `markupA/B = 0` e `overnight` nullo per questo motivo.
|
||||
- Il conto reale dell'utente vale 224,90 USD (2026-09-23): con l'esposizione minima di 1000 USD per gamba il Live non è praticabile a prescindere dai cancelli.
|
||||
- **Il server non registra l'`x-request-id` come `referenceId`** degli ordini v2: il lookup per riferimento risponde 404 anche per ordini eseguiti. Dalla 5.0 l'esito si legge per `orderId`; il riferimento resta solo come ripiego (ADR-0009).
|
||||
- **A margine esaurito il server riduce l'ordine** a un importo fisso (2 000 USD di margine osservati il 16-21/9) invece di rifiutarlo; la regola non è documentata nell'OpenAPI. Il bot registra `unita_richieste` e `unita_eseguite` in `orders.jsonl` e dimensiona la gamba B sulle unità eseguite di A; i limiti di margine della Fase 2 evitano di arrivarci.
|
||||
|
||||
## Feed
|
||||
|
||||
- **Google News** vieta `/rss/search` nel `robots.txt`: le cinque query (EURUSD, SNB, RBNZ, RBA, forex) non vengono scaricate e restano vuote. SNB e RBNZ non hanno quindi nessuna fonte; RBA solo il feed ufficiale, che risponde 403 a intermittenza (Akamai). Il sentiment su CHF, NZD e in parte AUD è di fatto zero.
|
||||
- Il feed della Fed risponde 404 a tratti (osservato alle 15:16 UTC+2 del 2026-09-16): la cache copre i buchi.
|
||||
- Il calendario FairEconomy è settimanale: la settimana successiva compare solo da domenica.
|
||||
|
||||
## Bot
|
||||
|
||||
- Le posizioni salvate in `baskets_state.json` da una modalità diversa non vengono riprese (si riparte dalla riconciliazione del conto).
|
||||
- Un movimento di cassa viene riconosciuto dal salto del saldo (oltre 10 USD e 0,25 %): un accredito piccolo sotto quella soglia passa per rumore; un prelievo che coincide con una chiusura viene distinto solo se lo storico del conto risponde.
|
||||
- Telegram non avvisa dei feed (calendario, RSS) in errore da più di un'ora: il provider dei feed non espone l'ora dell'ultimo successo; il riquadro «Telegram» della dashboard mostra solo lo stato del canale.
|
||||
- Le sezioni `risk`, `recovery` e `learning` di `strategy.json` non compaiono nel form di Impostazioni (si modificano nel file, come tutto il resto della strategia); Impostazioni ▸ Ricerca mostra lo stato dell'apprendimento in sola lettura.
|
||||
- Il ciclo settimanale gira solo mentre il bot è acceso la domenica dopo le 10 UTC (o al primo avvio dopo sette giorni).
|
||||
- Un movimento di cassa del demo che coincide con un'esecuzione ridotta dal server (il caso del 16-21/9) viene riconosciuto solo se il salto del saldo non è spiegato dalle chiusure: il matching è per differenza, non per una rotta dell'API.
|
||||
|
||||
## Container e interfaccia web
|
||||
|
||||
- Il server non ha TLS: l'interfaccia è pensata per la rete locale (Unraid) o dietro un reverse proxy che lo aggiunge. Senza `ENCELADO_WEB_TOKEN` ascolta solo su localhost del container.
|
||||
- Il registro dei container di Gitea è in HTTP: chi fa `docker pull` deve dichiararlo `insecure-registry` (Docker Desktop e Unraid, vedi `docs/DOCKER.md`).
|
||||
- Il template Unraid non è pubblicato in un repository di template della community: si installa dall'URL raw del file (`deploy/unraid/README.md`).
|
||||
- La valuta di visualizzazione usa il tasso della quotazione più recente ricevuta dal polling; con il mercato chiuso il tasso invecchia e il tooltip lo dice.
|
||||
|
||||
## Codice
|
||||
|
||||
- `BasketEngine` è spezzato in sei file parziali dalla 5.0; il file principale resta di ~900 righe (loop, decisioni, esecuzione).
|
||||
- I test dell'interfaccia coprono l'HTML incorporato, il JSON dello snapshot, il token e lo stream (`EmbeddedUiTests`, `WebHostTests`), non il comportamento della pagina nel browser (JavaScript, dialoghi): quello si controlla a occhio con `--sample` e con gli screenshot in `docs/img/`.
|
||||
- Il test (l) copre i blocchi nel decisore, non la simulazione completa dell'equity stop nel motore live; quella è coperta dal backtest (`EquityStops` in `BacktestResult`) e dal ledger.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Schema del ledger e delle tabelle
|
||||
|
||||
Regole comuni: UTC ovunque, `CultureInfo.InvariantCulture` per numeri e date, CSV con separatore `;` e ultima colonna `motivazione`, JSONL append-only. **Nessuna riga viene mai modificata**: le correzioni sono righe nuove con `evento = correzione`. Ogni riga porta `run_id` e, dove ha senso, `config_hash` (SHA-256 abbreviato di `strategy.json` canonicalizzato).
|
||||
|
||||
## `data/ledger/decisions.jsonl`
|
||||
|
||||
Una riga per **ogni** valutazione di ogni basket alla chiusura di ogni barra M15 (ingresso, skip, aggiunta, posizione, uscita) più le uscite decise su una quotazione intermedia e gli esiti di esecuzione. Le feature sono quelle disponibili **al momento della decisione**: è la regola anti look-ahead, e il dataset di addestramento è questo file, non una ricostruzione.
|
||||
|
||||
| Campo | Tipo | Significato |
|
||||
|---|---|---|
|
||||
| `ts` | ISO 8601 UTC | istante della valutazione |
|
||||
| `run_id` | testo | `yyyyMMdd-HHmmss-xxxxxx` della sessione del bot |
|
||||
| `config_hash` | testo | hash di `strategy.json` in vigore |
|
||||
| `basket` | testo | `A/B`, es. `EURUSD/USDCHF` |
|
||||
| `basket_id` | testo | id del basket aperto (`B<yyyyMMddHHmmss>-<AB>`), vuoto se piatto; collega a `baskets.csv` |
|
||||
| `cross` | testo | cross sintetico (`EURCHF`) |
|
||||
| `mode` (`Paper` | `Demo` | `Live`; i file scritti prima del 2026-09-16 pomeriggio portano i nomi precedenti `DemoApprove`/`DemoAuto`) | testo | `Paper`, `Demo`, `Live` |
|
||||
| `preset` | testo | `CONSERVATIVE`, `MODERATE`, `AGGRESSIVE` |
|
||||
| `evento` | testo | `skip`, `segnale_ingresso`, `ingresso`, `rifiuto`, `leg_risk_unwind`, `posizione`, `segnale_aggiunta`, `aggiunta`, `segnale_uscita`, `uscita`, `correzione`; dalla 5.0 anche le righe di evento (senza feature, solo `ts`, `run_id`, `evento`, `basket_id`, campi propri e `motivazione`): `pending` (gamba senza esito, con `leg`, `client_ref`, `order_id`), `pending_risolto` (con `esito`, `fonte`, `position_id`), `orfana_adottata` e `orfana_chiusa` (con `position_id`, `strumento`, `pnl`, `exit_reason`), `movimento_di_cassa` (con `importo`, `saldo_prima`, `saldo_dopo`, `chiusure_nel_frattempo`, `cassa_cumulata`), `margin_guard` (con `equity`, `margine_usato`), `kill_switch_avviato`, `kill_switch_concluso` (con `stato` = `Halted` o `Halted-Residuo`, `chiuse`, `residuo`, `annullati`, `pnl`), `residuo_chiuso`, `reset_rifiutato`, `reset_concluso`, `recupero_avviato` (con `inattivita_min`, `barre`), `recupero_concluso` (con `inattivita_min`, `barre`, `chiusi`, `tenuti`), `comando` (ogni comando dalla finestra, dalla console o da Telegram: `comando`, `argomento`, `ok`; la motivazione porta l'origine e la prima riga dell'esito) |
|
||||
| `decision` | testo | `Skip`, `Enter`, `Add`, `Exit`, `Hold` |
|
||||
| `buy_cross` | bool | verso deciso (compra il cross = compra entrambe le gambe nei cinque basket) |
|
||||
| `z`, `z_in_eff` | numero | z-score del cross e soglia effettiva (scalata dalla vol prevista) |
|
||||
| `D_pips` | numero | divergenza in pip dall'ancora (solo `PipDivergence`) |
|
||||
| `rho_W`, `rho_20` | numero | correlazione rolling dei rendimenti su `window` e `windowShort` |
|
||||
| `halfLife` | numero | emivita OLS in barre (null se λ ≥ 0) |
|
||||
| `atrA`, `atrB` | numero | ATR(14) in pip |
|
||||
| `sigmaX`, `ewmaVolX` | numero | deviazione standard del livello del cross sulla finestra; vol EWMA dei rendimenti del cross |
|
||||
| `sigmaForecast`, `sigmaAverage30d` | numero | vol prevista 1-4 h e media 30 g (null finché il livello 8.5 non è attivo) |
|
||||
| `costPips`, `breakEvenWinRate` | numero | costo stimato in pip-equivalenti di A; win rate di pareggio |
|
||||
| `spreadA`, `spreadB`, `markupA`, `markupB` | numero | spread correnti in pip; markup dell'API in pip |
|
||||
| `hourSin`, `hourCos`, `dow` | numero | ora sul cerchio; giorno della settimana (0 = domenica) |
|
||||
| `minutesToNextHigh`, `minutesSinceLastHigh`, `surpriseLast` | numero/null | calendario per le valute del basket |
|
||||
| `netSentDiff_1h/4h/24h`, `hawkishDiff`, `riskOff`, `newsCount` | numero | sentiment (valuta lunga − valuta corta del cross) |
|
||||
| `regimeTrend` | numero | forza di trend (ADX-like) del cross |
|
||||
| `lastNOutcomes` | numero | media degli ultimi esiti (null finché non c'è storia) |
|
||||
| `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`, `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`
|
||||
|
||||
Una riga per basket chiuso. `label = 1` se `pnl_net_usd > 0`, altrimenti 0: è l'etichetta dei livelli 1-3.
|
||||
|
||||
```
|
||||
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, 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)
|
||||
|
||||
Una riga per **ogni ordine inviato** e per **ogni cambio del suo stato** (append-only): la prima riga di un `client_ref` dice cosa è stato chiesto, l'ultima come è finita. Scritta dal registro degli ordini (`OrderTracker`) attraverso il ledger.
|
||||
|
||||
| Campo | Significato |
|
||||
|---|---|
|
||||
| `ts`, `run_id`, `mode` | come in `decisions.jsonl` |
|
||||
| `basket`, `basket_id` | slot (`EURUSD/USDCHF`) e istanza (`B2026…-EURUSDUSDCHF`) |
|
||||
| `strumento`, `instrument_id`, `verso` | la gamba; `verso` = `long`/`short` dell'ordine (per una chiusura è il verso opposto alla posizione) |
|
||||
| `leg` | `A`, `B`, `Add`, `Close`, `Unwind` |
|
||||
| `unita_richieste`, `unita_eseguite` | differiscono quando il server riduce l'ordine (osservato il 2026-09-16) |
|
||||
| `prezzo_richiesto`, `prezzo_eseguito`, `slippage_pip` | quotazione vista all'invio, prezzo del server, differenza in pip con il segno del costo |
|
||||
| `stato`, `stato_id` | l'ultima parola del server (`Submitted`, `Received`, `Placed`, `Filled`, `Rejected`, …, `Unknown` quando non ha risposto), con l'id numerico di eToro |
|
||||
| `esito` | `Pending`, `Filled`, `Rejected`, `Cancelled` |
|
||||
| `order_id`, `position_id`, `client_ref` | le tre chiavi |
|
||||
| `fee` | commissioni riportate dal server all'esecuzione |
|
||||
| `evento` | `inviato`, `stato`, `risolto` |
|
||||
| `motivazione` | la motivazione della decisione o l'errore del server |
|
||||
|
||||
## `data/state/pending_orders.json` (dalla 5.0)
|
||||
|
||||
Il registro degli ordini: `savedUtc` e l'array `orders` con gli stessi campi di `orders.jsonl` più `checks`, `lastCheckUtc`, `source` (`venue`, `lookup`, `lookup-v1`, `positions`). Contiene tutti gli ordini senza esito e quelli risolti nelle ultime 48 ore (servono a riconoscere una posizione come propria). Scritto **prima** di ogni chiamata HTTP e a ogni cambio di stato, con `.tmp` + `File.Move`. Un file illeggibile viene messo da parte come `pending_orders.json.illeggibile-<data>`. In modalità Paper il file è `pending_orders_paper.json`.
|
||||
|
||||
## `data/state/baskets_state.json` (campi aggiunti dalla 5.0)
|
||||
|
||||
`peakNetEquity` (picco dell'equity al netto dei movimenti di cassa), `cumulativeCashFlow`, `lastBalance` e `lastBalanceUtc` (per riconoscere un deposito avvenuto a bot spento), `pendingEntries` (un elemento per basket in `PendingA`/`PendingB`: `name`, `state`, `basketId` e il piano `pending` con unità, TP, stop, riferimenti cliente e la gamba A eseguita), `haltResidue` (le posizioni del bot rimaste sul conto dopo un kill-switch: `positionId`, `symbol`, `isBuy`, `units`, `openedUtc`, `origin`, `basket`, `reason`). `peakEquity` resta per compatibilità e vale `peakNetEquity + cumulativeCashFlow`.
|
||||
|
||||
## `data/state/heartbeat.json` e `data/state/instance.lock` (dalla 5.0)
|
||||
|
||||
`heartbeat.json`: `{utc, run_id, mode, openBaskets, pendingBaskets, pid, note}` scritto ogni `recovery.heartbeatSeconds` (30), all'avvio (`note = avvio`) e all'arresto (`arresto`), con `.tmp` + `File.Move`. Il tempo trascorso dall'ultimo `utc` è l'inattività che fa scattare il recupero. `instance.lock`: `{pid, runId, sinceUtc, machine}`, tenuto aperto in esclusiva dal motore per tutta la sessione; non va cancellato a mano.
|
||||
|
||||
## `reports/recupero_<run_id>.csv` (dalla 5.0)
|
||||
|
||||
Una riga per posizione trovata dal recupero: `posizione;basket;decisione;motivo;z;rho;barsHeld;pnl;motivazione`, con `decisione` ∈ {`chiudi`, `tieni`, `rapporto`} e `motivo` = codice di uscita del decisore (`stop_z`, `stop_max_loss`, `time_stop`, `rho_break`, `spread_anomaly`…), `entro le soglie`, `orfana_bot`, `esterna`.
|
||||
|
||||
## `reports/bonifica_YYYYMMDD.csv` (dalla 5.0)
|
||||
|
||||
Una riga per orfana chiusa dalla bonifica: `ts;position_id;strumento;verso;unita;aperta_utc;pnl_realizzato;basket;motivazione`.
|
||||
|
||||
## `results/trials.csv`
|
||||
|
||||
Una riga per configurazione provata nel backtest; N del Sharpe deflazionato = numero di righe.
|
||||
|
||||
```
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
```
|
||||
|
||||
`break_even_cost` = media per basket di (pip eseguiti + costo stimato), cioè i pip "mid-to-mid" catturati: il costo di giro che azzera il risultato. `p1_pnl`, `p5_pnl` = percentili 1 % e 5 % del P&L per basket (la coda che il win rate nasconde).
|
||||
|
||||
## `reports/falsificazione.csv`
|
||||
|
||||
```
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
```
|
||||
|
||||
## `knowledge/calibration.csv`
|
||||
|
||||
Win rate e P&L netto medio per bucket: `dimensione;bucket;n;win_rate;pnl_medio;pnl_totale;motivazione`, con dimensioni `|z|`, `rho_W`, `ora`, `giorno`, `minuti_evento`, `sentiment`, `preset`, `basket`.
|
||||
|
||||
## `knowledge/preregistrazione.csv`
|
||||
|
||||
Una riga per forward test: `data;config_hash;modalita;durata_minima;n_minimo_basket;sharpe_atteso;win_rate_atteso;dd_stop;stop_basket_consecutivi;esito;motivazione`.
|
||||
|
||||
## `knowledge/proposals.csv`
|
||||
|
||||
`data;origine;parametro;valore_attuale;valore_proposto;evidenza;stato;motivazione` — le proposte del ciclo settimanale; `stato` ∈ {proposta, in forward, accettata, respinta}. Nessuna proposta cambia i parametri live da sola.
|
||||
|
||||
## `knowledge/models_registry.csv`, `knowledge/forward_registry.csv`
|
||||
|
||||
`versione;data;tipo;n_train;auc_wf;brier;logloss;stato;motivazione` (stato ∈ shadow, challenger, champion, ritirato) e `data;config_hash;modalita;basket;pnl_net;sharpe;dd;stato;motivazione`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Apprendimento: livelli 0-3
|
||||
|
||||
Aggiornato: 2026-09-23 (ADR-0006, 5.0). Tutto è costruito da zero nel Core (`src/Encelado.Core/Baskets/Learning/`), senza pacchetti: regressione logistica online, un MLP a 16 unità ReLU con Adam, un bandit di Thompson, due previsori di volatilità. Il codice del motore che li usa a runtime è `src/Encelado.Engine/Baskets/LearningState.cs`; lo stesso ciclo gira offline con `backtest learn`.
|
||||
|
||||
**Regola che governa tutto**: nessun livello cambia un parametro live da solo. Il meta-modello può soltanto *rifiutare* un ingresso quando è attivo; il bandit *propone* un preset e **non lo applica** (dalla 5.0; fino alla 4.0.0 lo applicava in Paper e Demo); tutto il resto finisce in `knowledge/proposals.csv` e passa dal forward test pre-registrato.
|
||||
|
||||
## Stato dalla 5.0 (ADR-0006): quasi tutto in ombra
|
||||
|
||||
`strategy.json` → `learning` è `{enabled: false, weeklyCycle: false, challenger: false}` di fabbrica. Con `enabled = false`:
|
||||
|
||||
| Componente | Runtime |
|
||||
|---|---|
|
||||
| Ledger, `orders.jsonl`, `baskets.csv` | scritti sempre: sono il dato |
|
||||
| Livello 0 (calibrazione) | generato dal ciclo, quindi fermo finché il ciclo non gira (`backtest learn`, o «Esegui ciclo di apprendimento» in Impostazioni ▸ Ricerca) |
|
||||
| Previsione di volatilità | attiva, deterministica, usata da `z_in` effettivo |
|
||||
| Logistica (livello 1) | **in ombra**: `p_ML` a ogni chiusura di barra nel ledger, apprendimento a ogni chiusura di basket; mai un cancello (`mlActive = false`) |
|
||||
| MLP challenger (livello 2) | non addestrato |
|
||||
| Bandit (livello 3) | riceve i premi e **propone** nel log; non applica |
|
||||
| Ciclo settimanale | non gira nel bot; passa a `backtest learn` o al pulsante di Impostazioni ▸ Ricerca |
|
||||
|
||||
**Criterio di riattivazione** (`learning.enabled = true`, scritto dall'operatore, mai dal bot): almeno **300 basket chiusi in Demo** *e* **P&L netto forward ≥ 0** sulla metrica pre-registrata in `knowledge/preregistrazione.csv`. Prima di allora qualunque «adattamento automatico» è rumore: il meta-modello può solo ridurre le perdite di una strategia che il backtest dice non reggere i costi, e a poche entrate al giorno i 300 basket sono mesi. Motivazione completa in `docs/adr/ADR-0006-apprendimento-in-ombra.md`.
|
||||
|
||||
## Il dataset
|
||||
|
||||
Una riga per basket **aperto**: le 28 feature scritte nel ledger nel momento della decisione (`decisions.jsonl`, evento `ingresso`), unite per `basket_id` all'esito scritto alla chiusura (`baskets.csv`: `label` = 1 se il P&L netto è positivo, `pnl_net`). Le feature non vengono mai ricostruite a posteriori: il dataset è il ledger (`LearningState.BuildDataset`).
|
||||
|
||||
| # | Feature | Origine |
|
||||
|---|---|---|
|
||||
| 0-3 | `z`, `abs_z`, `z_in_eff`, `d_pips` | z-score del cross sintetico, soglia effettiva dopo la scala di volatilità, divergenza in pip |
|
||||
| 4-6 | `rho_w`, `rho_20`, `half_life` | correlazione rolling (finestra W e 20 barre), semiperiodo OLS |
|
||||
| 7-10 | `atr_a`, `atr_b`, `sigma_x`, `ewma_vol_x` | volatilità delle gambe e del cross |
|
||||
| 11 | `trend_strength` | forza di trend (ADX-like) del cross |
|
||||
| 12-15 | `spread_a`, `spread_b`, `cost_pips`, `break_even_win_rate` | costi del momento |
|
||||
| 16-18 | `hour_sin`, `hour_cos`, `day_of_week` | ora UTC ciclica, giorno |
|
||||
| 19-20 | `minutes_to_high`, `minutes_since_high` | calendario (troncati a 24 h) |
|
||||
| 21-23 | `sent_1h`, `sent_4h`, `hawkish_diff` | sentiment (valuta lunga − valuta corta del cross) |
|
||||
| 24 | `risk_off` | sentiment risk-off |
|
||||
| 25 | `vol_ratio` | σ prevista / σ media 30 giorni |
|
||||
| 26 | `last_outcomes` | media degli ultimi 10 esiti |
|
||||
| 27 | `buy_cross` | direzione |
|
||||
|
||||
I nomi sono in `LearningFeatures.Names`; il test `LeakTests` verifica che nessun nome contenga l'esito e che un'etichetta presa dal futuro non sia apprendibile (AUC ≈ 0,5).
|
||||
|
||||
## Livello 0 — Calibrazione
|
||||
|
||||
`CalibrationTables.Build` raggruppa i basket chiusi per basket, preset, terzile di volatilità, ora del giorno, bucket di |z| e di costo, e scrive win rate e P&L medio per bucket in `knowledge/calibration.csv` (colonna `motivazione` con il conteggio). Serve a leggere dove la strategia paga e dove no, e a niente altro: non cambia soglie.
|
||||
|
||||
## Livello 1 — Logistica online (il campione)
|
||||
|
||||
`OnlineLogistic`: pesi su 28 feature standardizzate con statistiche rolling (`RollingStandardizer`, emivita 200 righe), SGD con L2 = 10⁻³ e tasso 0,01/√(1+n/100). Predice a ogni chiusura di barra (`p_ML` nella dashboard, "in ombra") e impara a ogni chiusura di basket. Stato in `data/models/logreg_current.json`; versioni datate `logreg_vN.json` con `trained_on_until` e hash del dataset.
|
||||
|
||||
**Valutazione walk-forward** (`ModelEvaluator.EvaluateLogistic`): sequenziale, predici-poi-aggiorna, con i primi 30 basket di burn-in esclusi dalle metriche. Metriche: AUC con intervallo bootstrap (1000 ricampionamenti), Brier, log-loss, curva di calibrazione in 10 bin, P&L di tutti i basket contro P&L dei soli basket con p ≥ `mlMinProbability`, Sharpe e DSR del filtrato.
|
||||
|
||||
**Attivazione** (§8.3 della specifica), tutte insieme:
|
||||
|
||||
1. almeno **300** basket chiusi;
|
||||
2. AUC walk-forward ≥ **0,55** con l'intervallo bootstrap che esclude 0,50;
|
||||
3. P&L filtrato migliore del P&L non filtrato **e** DSR del filtrato ≥ **0,95**.
|
||||
|
||||
Quando è attivo, un ingresso con p < `mlMinProbability` (0,55) viene rifiutato (`ml_gate` nel ledger). **Disattivazione**: se l'AUC mobile sugli ultimi 100 basket scende sotto **0,52** il modello torna in ombra e lo scrive in `models_registry.csv`.
|
||||
|
||||
Stato del 2026-09-16: **0 basket chiusi nel ledger** → il modello è in ombra e non è valutabile. Nessuna cifra qui è un risultato.
|
||||
|
||||
## Livello 2 — MLP challenger
|
||||
|
||||
`SmallMlp`: 28 → 16 ReLU → 1 sigmoide, inizializzazione Glorot con seme fisso, Adam (β 0,9/0,999), L2 = 10⁻⁴, mini-batch 8-64. Addestrato dal ciclo settimanale in **5 fold cronologici con purga ed embargo di 24 ore** attorno al fold di test, **5 semi** mediati, early stopping sull'ultimo 20 % (cronologico) dei dati di addestramento con pazienza 20 epoche. Lo standardizzatore viene adattato all'intero insieme di addestramento prima del fit (le statistiche rolling partono da zero e distorcono le prime righe: scoperto e corretto con il test sul cerchio, vedi `ModelTests`).
|
||||
|
||||
Il **gradient check** (`SmallMlp.GradientCheck`, test `TheMlpGradientMatchesTheNumericalOne`) confronta il gradiente analitico di ogni peso vivo del primo strato con la differenza centrale numerica: scarto relativo < 10⁻⁴.
|
||||
|
||||
Promozione a campione: solo se batte la logistica di almeno 0,01 di AUC walk-forward **e** supera gli stessi cancelli di attivazione, e comunque solo dopo il forward test. Fino ad allora è registrato come `challenger` in `models_registry.csv`.
|
||||
|
||||
## Livello 3 — Bandit sui preset
|
||||
|
||||
`ThompsonBandit`: una Beta(α, β) per braccio = preset × terzile di volatilità prevista (3 × 3). A ogni chiusura il braccio usato riceve 1 se il basket è positivo. La proposta campiona dalle posteriori con un **tetto del 10 %** alle scelte esplorative (`ExplorationCap`; test `TheBanditKeepsExplorationUnderTheCap`). Dalla 5.0 la proposta viene scritta nel log e in `knowledge/proposals.csv` e **non viene applicata** in nessuna modalità (D-30); fino alla 4.0.0 in Paper e Demo cambiava il preset a caldo.
|
||||
|
||||
## Previsione della volatilità
|
||||
|
||||
`VolForecaster`: sui rendimenti a 15 minuti del cross calcola la varianza realizzata giornaliera e mantiene due previsori a 1-4 ore, **EWMA** (span 100) e **HAR-RV** (OLS sulle medie a 1, 5 e 22 giorni, rifittato ogni giorno). Ogni giorno confronta l'errore quadratico delle due previsioni sulla finestra mobile e usa quello migliore (`ActiveModel`). Il rapporto σ prevista / σ media 30 giorni scala la soglia `z_in` (`volScaleZIn`) e la size, ed è la feature `vol_ratio`.
|
||||
|
||||
## Deriva
|
||||
|
||||
L'indice di stabilità (PSI) previsto dalla specifica non è collegato a niente: il codice è stato rimosso nella pulizia della 5.0 (era una classe mai chiamata). Quando il meta-modello sarà attivabile, il controllo della deriva va reintrodotto insieme al criterio di ritorno in ombra, non prima.
|
||||
|
||||
## Ciclo settimanale e comando `learn`
|
||||
|
||||
`LearningState.RunCycle` — nel bot solo con `learning.weeklyCycle = true` (la domenica dopo le 10 UTC o al primo avvio dopo sette giorni); altrimenti a mano, dallo strumento o dall'interfaccia:
|
||||
|
||||
```powershell
|
||||
dotnet run --project tools/Encelado.Backtest -- learn --data <cartella data del bot> [--knowledge <cartella>] [--strategy config/strategy.json]
|
||||
dotnet msbuild build/Release.proj -t:Backtest -p:Dati="%USERPROFILE%\Documents\Encelado\data" -p:Comando=learn
|
||||
```
|
||||
|
||||
`--data` è la cartella che contiene `ledger/` (una copia di `/data/data` del container va benissimo); `--knowledge` è dove scrivere (default: `knowledge` accanto ai dati). Lo strumento stampa quante righe ha il dataset e lo stato del modello dopo il ciclo. Il pulsante «Esegui ciclo di apprendimento» in Impostazioni ▸ Ricerca fa la stessa cosa sui dati del container. Il ciclo:
|
||||
|
||||
1. ricostruisce il dataset dal ledger;
|
||||
2. valuta e riaddestra logistica (walk-forward) e MLP (fold purgati);
|
||||
3. scrive `knowledge/calibration.csv`, `knowledge/insights_YYYYWW.md` (cosa ha funzionato, calibrazione, meta-modello, bandit, parametri suggeriti), `knowledge/models_registry.csv`, `knowledge/proposals.csv` (una riga per proposta, con evidenza e stato `proposta`);
|
||||
4. salva i modelli con versione.
|
||||
|
||||
**Le proposte non toccano niente.** Il percorso per cambiare un parametro live è: proposta → `knowledge/preregistrazione.csv` (metrica, soglia, periodo, N minimo, scritti prima) → forward test in Paper/Demo → `forward_registry.csv` → decisione umana.
|
||||
|
||||
## Cosa è stato escluso, e perché
|
||||
|
||||
- **LSTM / Transformer / RL profondo**: con qualche centinaio di basket l'anno per cinque coppie, un modello con migliaia di parametri impara il rumore del campione; la regressione logistica e un MLP minuscolo sono già al limite di ciò che il dataset può sostenere. Il costo (settimane di lavoro e di calcolo) non è giustificato da nessun indizio che un modello più ricco troverebbe struttura dove i test di falsificazione non ne trovano.
|
||||
- **Feature ricostruite a posteriori**: il ledger scrive ciò che il bot sapeva; ricostruire feature dopo è il modo più facile di introdurre look-ahead.
|
||||
- **Ottimizzazione automatica dei parametri**: la griglia del backtest serve a *sapere*, non a *scegliere*; ogni prova conta nel DSR.
|
||||
|
||||
## File
|
||||
|
||||
| File | Contenuto |
|
||||
|---|---|
|
||||
| `data/models/logreg_current.json`, `mlp_current.json`, `bandit.json` | stato corrente (ripreso all'avvio) |
|
||||
| `data/models/logreg_vN.json`, `mlp_vN.json` | versioni del ciclo settimanale con `trained_on_until`, righe, hash del dataset, nomi delle feature |
|
||||
| `data/models/learning_state.json` | attivo/ombra, campione, versione, ultimi 200 (p, esito) per l'AUC mobile, feature dei basket aperti |
|
||||
| `knowledge/calibration.csv`, `insights_YYYYWW.md`, `models_registry.csv`, `proposals.csv`, `forward_registry.csv`, `preregistrazione.csv` | vedi `docs/LEDGER_SCHEMA.md` |
|
||||
|
||||
Test: `tests/Encelado.Tests/LearningTests.cs` (i: gradient check, apprendimento walk-forward, MLP contro logistica su una regola non lineare, bandit, volatilità; j: leak; l: blocchi).
|
||||
@@ -0,0 +1,74 @@
|
||||
# Piano 5.0 — valutazione, ordine e stima
|
||||
|
||||
Scritto: 2026-09-23 (Fase 0 del prompt "Encelado 5.0"). **Stato al 2026-09-23 sera: tutte le fasi 0-9 sono eseguite e committate; resta il rilascio 5.0.0 (decisione dell'utente).** Ogni punto del prompt è valutato come **fattibile**, **da chiarire** (con la domanda in `docs/QUESTIONS.md`) o **da rifiutare** (con il motivo). Le stime sono in sessioni di lavoro; una sessione è mezza giornata con verifica verde e commit.
|
||||
|
||||
## 0. Verifica della diagnosi preliminare
|
||||
|
||||
Verificata il 2026-09-23 sul codice, sul conto demo via API (sola lettura) e sui file di questa macchina.
|
||||
|
||||
| Affermazione del prompt | Esito |
|
||||
|---|---|
|
||||
| `EtoroBroker.OpenAsync` legge `orderId` e non lo usa più; il polling passa solo da `orders:lookup?referenceId=` | **Confermato** (`src/Encelado.Etoro/EtoroBroker.cs`, metodo `OpenAsync` e `LookupOrderAsync`). |
|
||||
| Il lookup per `referenceId` risponde 404 | **Confermato e spiegato**: `GET api/v1/trading/info/demo/orders/381739181` (uno degli ordini del bot) risponde `referenceID: 00000000-0000-0000-0000-000000000000`. Per gli ordini v2 il server **non ha registrato** l'`x-request-id` come riferimento, quindi la ricerca per riferimento non può trovarli. La ricerca per `orderId` funziona (verificata su due ordini, v1 e `orders:lookup?orderId=`). |
|
||||
| `BasketExecutor.SendAsync` ripete il lookup e dichiara la gamba «non eseguita»; il basket è rifiutato e l'ordine resta sul server | **Confermato** nel codice. Dallo storico del conto demo: **21 posizioni forex del bot** fra il 16/9 19:00 e il 21/9 10:15 UTC (EURUSD 1, USDCAD 1, AUDUSD 4, NZDUSD 3, EURAUD 12), tutte gambe singole, tutte chiuse il **21/9 alle 13:26 UTC** in blocco (chiusura manuale, non del bot: il kill-switch non avrebbe trovato slot da chiudere). P&L netto complessivo di quelle 21 gambe: **+289,05 USD**, per fortuna e non per merito. |
|
||||
| `ReconcileAsync` segnala le posizioni sconosciute una volta e non le tocca; il contatore conta gli slot | **Confermato**. |
|
||||
| `KillAsync → CloseAllAsync` chiude solo gli slot con `Position` | **Confermato**. |
|
||||
| Il ledger 16-22/9 ha 65 `segnale_ingresso` e 65 `rifiuto` | **Non verificabile qui**: su questa macchina `Documenti\Encelado\data\ledger\decisions.jsonl` ha 112 righe del solo 16/9 (mattina e pomeriggio, 110 `skip` e 3 `correzione`). Le sessioni dal 16/9 sera in poi hanno girato altrove (installazione 4.0.0 su un'altra macchina o un altro profilo). Chiesto in D-36. |
|
||||
| «Depositi» = movimenti di cassa del conto virtuale, non operazioni del bot | **Confermato per esclusione**: nessuna rotta di deposito o trasferimento è chiamata dal codice; le rotte di trasferimento dell'API (`api/v1/money/transfers`) non compaiono nel client. La cifra tonda (+30 000) è coerente con un accredito demo. |
|
||||
| Margine: un basket Aggressive ha impegnato tutto il margine | **Confermato e precisato**: i primi due ordini eseguiti (16/9 19:00:48 EURUSD short 547 214,68 unità, 62 792 USD di margine; 19:00:58 USDCAD long 464 358,5 unità, 46 436 USD) sommano 109 228 USD di margine = l'intera equity, cioè 1 092 281 USD di nozionale a leva 10. Sono due gambe A di due basket diversi (EURUSD/USDCHF e USDCAD/EURUSD), ciascuna sizata al tetto `maxEffectiveLeverage = 10` sul nozionale del basket. **Fatto nuovo**: dal terzo ordine in poi eToro ha eseguito ogni ordine a **esattamente 2 000 USD di margine** (`requestedAmount 2000`, `frozenAmount 2000`, unità con sei decimali ricalcolate dal server, mentre il bot manda unità a due decimali): il server ha ridotto gli ordini a un importo fisso perché il margine era esaurito. Conseguenze: (1) le unità eseguite possono differire molto da quelle richieste, quindi la riconciliazione per «unità ± 1 %» è solo un indizio, non la chiave: la chiave è l'`orderId`; (2) i limiti di margine di §10 sono indispensabili. |
|
||||
|
||||
## 1. Valutazione punto per punto
|
||||
|
||||
| § | Contenuto | Valutazione | Note |
|
||||
|---|---|---|---|
|
||||
| 1 | Scheda Storico (ordini, posizioni, profitti per periodo, export CSV) | **fattibile** | Modulo puro `Core/Baskets/History` + endpoint web. La vista dipende da §11 (web UI). Fonte del realizzato: `api/v1/trading/info/trade/{demo/}history` (già usato), da riconciliare con `baskets.csv` e `orders.jsonl`. |
|
||||
| 2 | Navigation rail M3 a sinistra | **fattibile** in web (§11); in WPF sarebbe lavoro perso | Da fare dopo D-28. |
|
||||
| 3 | Versione fuori dalla dashboard, Informazioni e Diagnostica | **fattibile** | Banale in web; in WPF vale la pena solo se D-28 è «no». |
|
||||
| 4 | Valuta di visualizzazione | **fattibile** | Tassi dalle quote già in polling: EURUSD (1), USDCHF (6), AUDUSD (7), USDCAD (4), NZDUSD (3) ci sono; **GBP e JPY no**: servono GBPUSD e USDJPY nel polling (2 strumenti in più, stessa richiesta). Conversione solo in presentazione. |
|
||||
| 5 | OrderTracker, lookup per orderId, stati Pending, orfane, contatori, picco netto dei movimenti di cassa, bonifica | **fattibile, prioritario** | Endpoint verificati (D-26, D-27). Il matching per posizioni usa strumento + verso + finestra temporale; le unità solo come conferma (vedi §0). Movimenti di cassa: il conto demo non compare in `api/v1/balances` (solo i conti reali), quindi si riconoscono dal salto del saldo non spiegato dalle chiusure (`CashFlowDetector`), in Demo e in Live allo stesso modo. |
|
||||
| 6 | Recupero dopo inattività, heartbeat | **fattibile** | Riusa riconciliazione, riscaldamento e decisore già esistenti. |
|
||||
| 7 | Telegram | **fattibile** | Solo `HttpClient`; token e chatId da ambiente (D-31). Un solo task di long polling. |
|
||||
| 8 | Apprendimento: disattivare MLP, bandit, ciclo settimanale; spostarli nello strumento | **fattibile**, decisione D-30 | Il bandit oggi **applica** il preset in Demo (`ObserveOutcome`): va spento comunque, è un cambio di parametro automatico che contraddice la regola «nessun parametro cambiato dal bot». |
|
||||
| 9 | Kill-switch reale e ripristino guidato | **fattibile, prioritario** | Endpoint di cancellazione esiste: `DELETE api/v2/trading/execution/{demo/}orders/{orderId}` (200 = richiesta accettata, esito da confermare con lookup: 7 o 9 annullato, 6 in corso). |
|
||||
| 10 | Margine: soglie, buffer, sizing = min(rischio, margine), ordine per \|z\|, margin guard | **fattibile, prioritario** | In Demo `available` viene da `pnl` (`credit + bonusCredit`), in Live idem; `api/v1/balances` aggiunge `totalUsedMargin`/`currentPNL` solo per il reale. |
|
||||
| 11 | UI web M3 servita dal bot, ritiro di WPF | **da chiarire (D-28)** | Tecnicamente fattibile senza NuGet (`Microsoft.AspNetCore.App` è un framework reference). È la scelta più impattante del prompt: cambia progetti, installer, test. Si fa dopo la conferma. |
|
||||
| 12 | Engine/Server, Docker, IKeyStore | **da chiarire (D-28, D-29)** | Dipende da §11. Il `Dockerfile` multi-stage e l'`entrypoint` con PUID/PGID sono standard. |
|
||||
| 13 | Template Unraid | **da chiarire (D-29)** | Serve il registry e l'URL dell'icona. |
|
||||
| 14 | Documentazione, ADR, skill di progetto | **fattibile** | Le skill in `.claude/skills/` vanno bene; `encelado-diagnose` legge i file nuovi. |
|
||||
| 15 | Domande | poste in `docs/QUESTIONS.md` (D-26…D-37) | D-26 e D-27 già risposte via API. |
|
||||
|
||||
### Punti da rifiutare o ridimensionare (con motivo)
|
||||
|
||||
- **§5.2 «polling per orderId e in parallelo per referenceId»**: il riferimento non è registrato dal server per gli ordini v2 (vedi §0). Si interroga **per `orderId`**, e per riferimento solo come ripiego quando la risposta al `POST` è andata persa (timeout di rete) — che è l'unico caso in cui l'`orderId` non c'è. Non «in parallelo»: la quota dei lookup è 60/min condivisa con l'esito delle chiusure, e due lookup ogni 400 ms per gamba la esaurirebbero in un minuto con tre basket.
|
||||
- **§5.4 «unità ± 1 %» come criterio di adozione**: eToro può ridurre l'ordine (vedi §0), quindi le unità non sono affidabili. Criterio applicato: `orderId`/`positionId` nel registro (certo) → strumento + verso + orario ± 90 s coerenti con un ordine del registro o con una riga `segnale_ingresso`/`rifiuto`/`pending` del ledger (probabile; le unità ± 1 % alzano la confidenza ma non sono richieste). Le posizioni «probabili» sono comunque orfane-bot: sul conto demo non c'è altro che operi sul forex a leva 10 con quegli orari di chiusura di barra.
|
||||
- **§8 bandit in Demo**: già oggi cambia il preset da solo (`ObserveOutcome` → `SetPreset`). Non è «da tenere in ombra»: è da spegnere del tutto a runtime, perché è un cambio automatico di parametro.
|
||||
- **§11 test (aa) «UiRenderTests sostituiti»**: i test di rendering WPF restano finché esiste WPF; vengono rimossi con il progetto (Fase 6-7), non prima.
|
||||
|
||||
## 2. Ordine di esecuzione e criteri
|
||||
|
||||
Invariato rispetto a §16 del prompt, con una precisazione: le Fasi 1-3 non dipendono da nessuna domanda aperta (D-26/27 sono verificate, D-32/34/35 hanno default che sono parametri) e vengono eseguite subito; le Fasi 4-5 nemmeno; le Fasi 6-9 dipendono da D-28/D-29 e aspettano la risposta.
|
||||
|
||||
| Fase | Contenuto | Stima | Dipendenze |
|
||||
|---|---|---|---|
|
||||
| 0 | questo piano, post-mortem, domande | 0,5 | — |
|
||||
| 1 | §5: `OrderTracker` + `pending_orders.json` + `orders.jsonl`; lookup per `orderId` con matching sulle posizioni; stati `PendingA/PendingB`; classificazione `basket / orfana-bot / esterna` e chiusura delle orfane; contatori e P&L del conto nello snapshot; picco al netto dei movimenti di cassa; comando `bonifica`; spezzare `BasketEngine.cs` in file parziali (riconciliazione, stato, comandi) | 2 | — |
|
||||
| 2 | §10: sezione `risk` in `strategy.json`, sizing a margine, ricontrollo di `available` prima di B, ordine per \|z\|, margin guard, `sizing_bound` nel ledger | 1 | Fase 1 |
|
||||
| 3 | §9: kill-switch con cancellazione, chiusura di basket + orfane, esterne opzionali, verifica di piattezza, `Halted-Residuo`; procedura di ripristino in cinque passi nel motore (la finestra WPF la usa come oggi, la web UI la mostrerà passo per passo) | 1 | Fasi 1-2 |
|
||||
| 4 | §6: heartbeat, `recovery` in `strategy.json`, procedura di recupero, lock di istanza, rapporto `recupero_<run_id>.csv` | 1 | Fasi 1-3 |
|
||||
| 5 | §7: `INotifier`, `TelegramNotifier`, stato orario, eventi, riepilogo, comandi | 1 | Fase 4 |
|
||||
| 6 | §12: `Encelado.Engine`, `Encelado.Server`, Kestrel, SSE, API JSON, `IKeyStore` | 2 | **D-28** |
|
||||
| 7 | §11 + §1-4 + §8: UI web M3, Storico, valuta, navigazione, Ricerca | 3 | Fase 6 |
|
||||
| 8 | §12-13: Dockerfile, compose, template Unraid, docs | 1 | Fase 6, **D-29** |
|
||||
| 9 | §14: documenti, ADR, skill, CHANGELOG, 5.0.0 | 0,5 | tutto |
|
||||
|
||||
Totale: circa 13 sessioni. Ogni fase termina con `dotnet build`, `dotnet test`, documenti aggiornati e un commit.
|
||||
|
||||
## 3. Decisioni di progetto prese in Fase 0 (vincolanti per le fasi seguenti)
|
||||
|
||||
1. **Un ordine non si abbandona mai.** Ogni invio viene registrato in `data/state/pending_orders.json` prima della chiamata HTTP; all'avvio i pendenti si risolvono prima di qualsiasi decisione; una posizione che porta la firma del bot è del bot.
|
||||
2. **La chiave è l'`orderId`.** Il riferimento cliente resta nell'intestazione (idempotenza) e nel registro, ma non è il mezzo con cui si chiede l'esito.
|
||||
3. **Nessun esito sintetico.** `OrderOutcome.Status` riporta ciò che il server ha detto; quando non ha detto niente lo stato è `Unknown` e l'ordine resta pendente nel registro.
|
||||
4. **Le unità eseguite le dice il server.** Il sizing propone, l'esecuzione registra ciò che l'API riporta (`positionExecutions[].openingData.units`), e la gamba B si dimensiona sulle unità **eseguite** di A, non su quelle richieste.
|
||||
5. **Il margine è un vincolo di primo livello**, non una conseguenza: nessun ordine parte se `available` non copre il margine con il buffer.
|
||||
6. **Il picco di equity è al netto dei movimenti di cassa**: depositi e prelievi spostano il riferimento, non il drawdown.
|
||||
7. **Il codice nuovo di logica pura va nel Core** (`OrderTracker`, `PositionClassifier`, `CashFlowDetector`, `History`, sizing a margine) così la migrazione a `Encelado.Engine` della Fase 6 sposta file, non riscrive niente.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Post-mortem: ordini dall'esito ignoto e gambe orfane (16-21 settembre 2026)
|
||||
|
||||
Scritto: 2026-09-23. Riguarda il forward test in Demo della versione 4.0.0.
|
||||
|
||||
## Che cosa è successo
|
||||
|
||||
Dal 16 settembre (sera) al 21 settembre il bot ha inviato ordini di apertura della gamba A di vari basket, ha dichiarato ogni gamba «non eseguita» perché non riusciva a leggerne l'esito, ha rifiutato il basket e non ha inviato la gamba B. Gli ordini però erano stati eseguiti dal server: sul conto demo si sono accumulate **posizioni singole senza copertura**, con lo stop nativo ma senza take-profit né stop di basket, che il bot vedeva come «posizioni sconosciute» e non toccava. Nessun basket è mai passato in `Open`; il contatore in dashboard diceva `0/5` mentre il conto aveva fino a venti posizioni aperte.
|
||||
|
||||
## Evidenze
|
||||
|
||||
Dallo storico del conto demo (`api/v1/trading/info/trade/demo/history`, letto il 2026-09-23):
|
||||
|
||||
| Apertura (UTC) | Strumento | Verso | Unità | Margine USD | P&L netto |
|
||||
|---|---|---|---|---|---|
|
||||
| 16/9 19:00:48 | EURUSD | short | 547 214,68 | 62 792,33 | −355,69 |
|
||||
| 16/9 19:00:58 | USDCAD | long | 464 358,50 | 46 435,84 | +414,56 |
|
||||
| 16/9 20:00:14 | AUDUSD | long | 28 207,949 | 2 000,00 | +122,14 |
|
||||
| 16/9 22:15:01 | AUDUSD | long | 28 226,660 | 1 999,99 | +135,49 |
|
||||
| 16/9 23:01:22 | AUDUSD | long | 28 221,482 | 2 000,00 | +131,79 |
|
||||
| 17/9 00:45:04 | EURAUD | long | 17 444,888 | 1 999,99 | −84,13 |
|
||||
| 17/9 01:15:03 | EURAUD | long | 17 448,661 | 1 999,99 | −76,67 |
|
||||
| 17/9 02:00:23 | EURAUD | long | 17 452,394 | 1 999,99 | −72,96 |
|
||||
| 17/9 03:15:01 | EURAUD | long | 17 450,257 | 1 999,99 | −51,91 |
|
||||
| 17/9 12:30:02 | AUDUSD | short | 28 073,722 | 2 000,00 | −26,39 |
|
||||
| 18/9 04:00:03 | NZDUSD | short | 34 883,315 | 1 999,99 | +17,79 |
|
||||
| 18/9 04:15:04 | NZDUSD | short | 34 843,813 | 2 000,00 | +40,42 |
|
||||
| 18/9 07:00:32 | EURAUD | short | 17 418,146 | 1 999,99 | +18,89 |
|
||||
| 18/9 08:15:04 | NZDUSD | short | 34 950,981 | 1 999,99 | −20,97 |
|
||||
| 18/9 09:15:03 | EURAUD | short | 17 419,919 | 1 999,99 | +26,97 |
|
||||
| 18/9 10:15:01 | EURAUD | short | 17 415,984 | 2 000,00 | +27,83 |
|
||||
| 21/9 06:45:02 | EURAUD | short | 17 427,466 | 1 999,99 | +12,68 |
|
||||
| 21/9 07:15:03 | EURAUD | short | 17 432,862 | 2 000,00 | +9,57 |
|
||||
| 21/9 08:15:02 | EURAUD | short | 17 425,358 | 1 999,99 | −0,62 |
|
||||
| 21/9 09:00:44 | EURAUD | short | 17 421,555 | 1 999,99 | +7,95 |
|
||||
| 21/9 10:15:03 | EURAUD | short | 17 420,945 | 1 999,99 | +12,31 |
|
||||
|
||||
Tutte chiuse il 21/9 alle 13:26 UTC in blocco (chiusura manuale). Totale: 21 gambe, **+289,05 USD** netti, 0 commissioni. Il segno positivo è casuale: gambe singole senza copertura, nessuna regola di uscita applicata per cinque giorni.
|
||||
|
||||
Osservazioni:
|
||||
|
||||
1. Gli orari sono chiusure di barra M15 (`:00`, `:15`, `:30`, `:45` più pochi secondi): sono ordini del bot. Ogni volta che il segnale restava oltre `zIn` alla barra successiva, il basket — tornato `Idle` dopo il «rifiuto» — inviava **un'altra gamba A**. Le dodici EURAUD sono lo stesso segnale ripetuto per dodici barre.
|
||||
2. `GET api/v1/trading/info/demo/orders/381739181` (l'ordine EURUSD) risponde `referenceID: 00000000-0000-0000-0000-000000000000`: il server **non ha associato** l'`x-request-id` all'ordine. La ricerca `orders:lookup?referenceId=<x-request-id>` non poteva che rispondere 404. La ricerca per `orderId` risponde 200 con `status.id = 3 (Filled)` e la posizione.
|
||||
3. I primi due ordini hanno impegnato **109 228 USD di margine, cioè tutta l'equity** (1 092 281 USD di nozionale a leva 10): erano le gambe A di due basket diversi, ciascuna sizata fino al tetto `maxEffectiveLeverage = 10` calcolato sul nozionale del *basket* senza guardare il margine disponibile.
|
||||
4. Dal terzo ordine in poi ogni esecuzione vale **esattamente 2 000 USD di margine**: `orders:lookup?orderId=382724150` mostra `requestedAmount 2000`, `frozenAmount 2000`, `requestType byUnits`, `requestedUnits 17420.945125`. Il bot manda unità arrotondate a due decimali; sei decimali significano che il server ha **ricalcolato le unità da un importo**: con il margine esaurito eToro ha ridotto l'ordine a un importo fisso invece di rifiutarlo. Le unità eseguite non erano quelle richieste.
|
||||
5. Il saldo è passato da 109 228 a 149 517 USD fra il 16 e il 22/9. Dal ledger ricevuto il 23/9 (D-36) non è un accredito unico: l'equity registrata a ogni riga sale in **circa ventuno scatti di ≈ +2 000 USD**, in coincidenza con le ventuno esecuzioni che il server aveva ridotto a 2 000 USD di margine. Il conto demo ha cioè accreditato fondi virtuali per ogni ordine ridotto: una regola del demo, non un'operazione del bot né un deposito dell'utente. In dashboard gli accrediti hanno alzato il picco di equity e falsato drawdown e P&L del giorno; dalla 5.0 sono `movimento_di_cassa` e non contano.
|
||||
|
||||
## Causa
|
||||
|
||||
Tre difetti concatenati, nessuno dei quali da solo avrebbe prodotto il danno:
|
||||
|
||||
1. **Esito cercato con la chiave sbagliata** (`EtoroBroker.OpenAsync`): l'`orderId` restituito dal `POST` veniva letto e ignorato; l'esito era chiesto solo per `referenceId`, che il server non registra per gli ordini v2. Dopo `fillTimeoutSeconds` il metodo restituiva un esito **costruito a mano** (`"Received"`, «esito non ancora noto») invece di dire «non so».
|
||||
2. **Ordine abbandonato** (`BasketExecutor.SendAsync`, `BasketEngine.ExecuteEntryAsync`): allo scadere di `legTimeoutSec` la gamba era «non eseguita», il basket rifiutato e riportato a `Idle`, e nessuno seguiva più l'ordine. Alla barra dopo si ripartiva da zero.
|
||||
3. **Posizione sconosciuta = posizione intoccabile** (`BasketEngine.ReconcileAsync`): la regola di sicurezza «non toccare ciò che non è tuo» era corretta per le posizioni esterne, ma il bot non aveva alcun modo per riconoscere le proprie. Il contatore contava gli slot, non le posizioni; il kill-switch chiudeva gli slot, non le posizioni.
|
||||
|
||||
Aggravante: il **sizing non guardava il margine**. Con `orderLeverage = maxEffectiveLeverage = 10` un solo basket al tetto impegna il 100 % dell'equity.
|
||||
|
||||
## Correzione (Fase 1-3 del piano 5.0)
|
||||
|
||||
| Difetto | Correzione | Dove |
|
||||
|---|---|---|
|
||||
| esito per `referenceId` | esito per **`orderId`** (`orders:lookup?orderId=`, ripiego `api/v1/trading/info/{demo/}orders/{id}`); il riferimento solo se la risposta al `POST` è andata persa; matching sulle posizioni (strumento + verso + orario ± 90 s) come ultima risorsa | `EtoroBroker.OpenAsync`, `LookupOrderByIdAsync` |
|
||||
| esito sintetico | `OrderOutcome` con stato `Unknown` e `Pending = true`; mai un nome di stato che il server non ha detto | `EtoroBroker`, `OrderOutcome` |
|
||||
| ordine abbandonato | **`OrderTracker`** con registro persistente `data/state/pending_orders.json` scritto **prima** dell'HTTP; stati `PendingA` / `PendingB`; risoluzione a ogni ciclo e all'avvio; `orders.jsonl` append-only | `Core/Baskets/OrderTracker.cs`, `BasketExecutor`, `BasketEngine` |
|
||||
| posizioni proprie non riconosciute | **`PositionClassifier`**: `basket` / `orfana-bot` / `esterna`; le orfane-bot vengono adottate e chiuse; contatori separati in dashboard; banner se equity − saldo non torna con le posizioni | `Core/Baskets/PositionClassifier.cs`, `BasketEngine.Reconcile.cs` |
|
||||
| kill-switch che chiude gli slot | kill-switch che chiude **le posizioni** (basket + orfane), cancella i pendenti, verifica la piattezza, stato `Halted-Residuo` | `BasketEngine.Kill.cs` (Fase 3) |
|
||||
| sizing senza margine | `risk.maxMarginUsePct`, `maxMarginPerBasketPct`, `marginBufferPct`, sizing = min(rischio, margine), ricontrollo di `available` prima di B, margin guard | `VolParitySizing`, `BasketDecider`, `BasketExecutor` (Fase 2) |
|
||||
| accredito che gonfia il picco | `CashFlowDetector`: salto di saldo non spiegato dalle chiusure = movimento di cassa; picco e equity di inizio giornata al netto | `Core/Baskets/CashFlowDetector.cs`, `BasketEngine.State.cs` |
|
||||
|
||||
## Come si verifica che non si ripeta
|
||||
|
||||
- Test (m): lookup 404 persistente + posizione presente → `Filled` per matching (`EtoroBrokerTests`).
|
||||
- Test (n): A eseguita, B rifiutata → A richiusa entro 5 s (`LegRiskTests`).
|
||||
- Test (o): A pendente oltre il timeout poi eseguita → `PendingA → Open` con B, oppure unwind se il segnale è decaduto (`OrderTrackerTests`).
|
||||
- Test (p): posizione con la firma del bot → `orfana-bot` e chiusa (`PositionClassifierTests`).
|
||||
- Test (q): un deposito non muove il picco né il drawdown (`CashFlowTests`).
|
||||
- In Demo: 24 ore con ordini eseguiti riconosciuti e **zero** orfane (contatore «Gambe orfane» a 0 per tutta la sessione; `orders.jsonl` senza stati `Unknown` non risolti).
|
||||
- Skill `encelado-diagnose`: rapporto di riconciliazione fra `orders.jsonl`, `pending_orders.json` e le posizioni del conto.
|
||||
|
||||
## Che cosa resta aperto
|
||||
|
||||
- ~~Il ledger delle sessioni 16-21/9 non è su questa macchina~~ Ricevuto il 23/9 (D-36): 1 813 righe, 65 `segnale_ingresso` e 65 `rifiuto` «esito non ancora noto», preset AGGRESSIVE impostato dalla finestra, quattro run. Conferma la catena: lo stesso segnale ripetuto a ogni barra, ogni gamba A inviata e mai seguita, `unitsA` a due decimali contro le unità a sei decimali eseguite dal server. La chiusura in blocco del 21/9 13:26 UTC l'ha fatta l'utente dalla piattaforma (D-37): +289,05 USD realizzati, per fortuna.
|
||||
- La regola con cui eToro riduce un ordine a margine esaurito non è documentata nell'OpenAPI; il bot non deve più trovarsi in quella condizione (Fase 2), ma la registra se accade (`units_eseguite ≠ units_richieste` in `orders.jsonl`).
|
||||
@@ -0,0 +1,188 @@
|
||||
# PROMPT PER CLAUDE CODE — Encelado 5.0: bonifica esecuzione, storico ordini, navigazione laterale, valuta, recupero dopo inattività, Telegram, apprendimento, kill-switch, margine, Material Design, Docker + Unraid
|
||||
|
||||
> Apri il repository `Encelado` e leggi, in quest'ordine: `CLAUDE.md`, `docs/STATE.md`, `docs/ARCHITECTURE.md`, `docs/RISK_RULES.md`, `docs/RUNBOOK.md`, `docs/KNOWN_ISSUES.md`, poi questo documento **per intero**. Questo prompt aggiorna la specifica precedente ("Correlation Baskets su eToro"): dove i due si contraddicono vale questo. Le regole di `CLAUDE.md` restano tutte (C#, nessun NuGet nell'applicazione, CSV `;` con `motivazione`, UTC, ledger append-only, mai un ordine reale senza flag e frase, un ADR per ogni scelta non ovvia, `docs/STATE.md` e `CHANGELOG.md` aggiornati a fine sessione).
|
||||
>
|
||||
> **Metodo di lavoro richiesto:** (1) rileggi questo prompt e produci `docs/PIANO_5.0.md` con la valutazione di ogni punto (fattibile / da chiarire / da rifiutare con motivo), l'ordine di esecuzione e la stima; (2) poni in blocco le domande di §15 e attendi; (3) implementa per fasi (§16), una fase per commit, con test verdi e documenti aggiornati; (4) **se hai un dubbio, chiedi**: `docs/QUESTIONS.md`, numerazione D-26 in poi, default prudente se non arriva risposta.
|
||||
|
||||
---
|
||||
|
||||
## 0. Diagnosi preliminare (già fatta leggendo codice e ledger: verificala, poi correggi)
|
||||
|
||||
Dal ledger `data/ledger/decisions.jsonl` (16-22 settembre): **65 `segnale_ingresso` e 65 `rifiuto`**, tutti con lo stesso testo: *«gamba A (…) non eseguita: Received → esito non ancora noto»*. Nessun basket è mai passato in `Open`, `data/ledger/baskets.csv` non esiste, `baskets_state.json` ha `baskets: []`, `learning_state.json` dice «ledger senza basket chiusi». Eppure il log di stato mostra `aperto −348,91` poi `−380,24` con `basket aperti 0/5`, e l'equity in dashboard (137 901) è sotto il saldo (139 228) mentre "P&L aperto" mostra 0,00.
|
||||
|
||||
Catena causale nel codice:
|
||||
1. `EtoroBroker.OpenAsync` (`src/Encelado.Etoro/EtoroBroker.cs`): il `POST orders` risponde con un `orderId`, che viene letto e **mai più usato**. Il polling dell'esito passa solo da `LookupOrderAsync(clientRef)` = `GET orders:lookup?referenceId=<x-request-id>`, che restituisce **404 → `null`** per tutti i 10 s di `fillTimeoutSeconds`; il metodo torna `OrderOutcome("Received", "esito non ancora noto")` costruito a mano (riga `return last ?? new OrderOutcome(...)`).
|
||||
2. `BasketExecutor.SendAsync` ripete lo stesso lookup per altri `legTimeoutSec` (5 s), sempre `null`, e dichiara la gamba A «non eseguita». Il basket viene rifiutato; **l'ordine A resta sul server e viene eseguito** (o lo è già stato): nasce una gamba orfana, senza copertura, con lo stop nativo ma senza TP né gestione.
|
||||
3. `BasketEngine.ReconcileAsync` vede la posizione, non la trova in `_knownPositions`, la scrive una volta nel log come «non appartiene a nessun basket: la lascio com'è» e **non la tocca mai più**. Il contatore basket resta 0/5 perché conta gli slot con `Position`, non le posizioni del conto.
|
||||
4. `KillAsync → CloseAllAsync` chiude solo gli slot con `Position` (zero): il kill-switch «riesce» senza chiudere nulla. È il punto 8 dell'utente.
|
||||
|
||||
Conseguenze da correggere: (a) l'esito ordine va cercato **per `orderId`** (endpoint da verificare sulla documentazione: `GET api/v2/trading/info/{demo/}orders/{orderId}` o equivalente) e, in subordine, per `referenceId`, e comunque **riconciliato con l'elenco posizioni** (strumento + verso + unità ± 1 % + orario ± 90 s); (b) un ordine dall'esito ignoto **non si abbandona mai**: entra in un registro persistente di ordini pendenti e viene seguito finché non è eseguito, rifiutato o annullato; (c) una gamba eseguita a basket abortito va **chiusa subito**, non lasciata; (d) le posizioni del conto che portano la firma del bot (clientRef/orderId nel registro, oppure strumento+unità+orario coerenti con un `segnale_ingresso` del ledger) sono **del bot**, non «sconosciute»; (e) il contatore in dashboard deve distinguere «basket del bot», «gambe orfane del bot», «posizioni esterne».
|
||||
|
||||
Sui **«depositi» nella cronologia eToro**: il codice non chiama alcun endpoint di deposito o trasferimento (verificato: rotte usate = instruments, rates, candles, eligibility, costs, pnl, orders, orders:lookup, market-close-orders, positions, history, me, balances). Il saldo è passato da 109 228 (16/9) a 139 228 (18/9): **+30 000 esatti**, cifra tonda tipica di un accredito di fondi virtuali sul portafoglio demo, non di un trade. Le voci «deposito» sono movimenti di cassa del conto virtuale (accrediti di eToro o richiesti dall'utente), non operazioni del bot. Nella nuova scheda Storico (§1) vanno mostrate a parte, come `movimento di cassa`, mai sommate al P&L. Se dopo l'analisi dello storico via API (`api/v1/trading/info/trade/{demo/}history` e `api/v1/balances`) risultasse un'altra origine, scrivila in `docs/KNOWN_ISSUES.md`.
|
||||
|
||||
Sul **margine**: il sizing attuale è solo `rischio % / distanza dello stop`. Un basket Aggressive ha prodotto 1 092 281 USD di nozionale (leva 10) su 109 228 USD di equity: **una sola** operazione impegnava tutto il margine del conto; con `maxBaskets = 5` il secondo basket non poteva esistere. È il punto 10 dell'utente e va risolto in §10.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scheda «Storico ordini» (nuova)
|
||||
|
||||
Nuova sezione di navigazione `Storico` con tre viste:
|
||||
- **Ordini** (ultimi N, filtro per basket/strumento/verso/esito/periodo): una riga per ordine inviato (apertura, aggiunta, chiusura, unwind), con `ts, basket_id, strumento, verso, unità, prezzo richiesto, prezzo eseguito, slippage (pip), stato (eseguito/rifiutato/annullato/pendente/ignoto→risolto), orderId, positionId, fee, motivazione`. Fonte: nuovo file `data/ledger/orders.jsonl` (append-only, §3) scritto dall'esecutore **a ogni invio e a ogni cambio di stato**, più riconciliazione con lo storico eToro.
|
||||
- **Posizioni** (aperte e chiuse): una riga per posizione, con origine `basket | orfana-bot | esterna | movimento di cassa`, apertura/chiusura, P&L lordo/netto in USD e in valuta di visualizzazione, pip, durata, motivo di uscita, fee e overnight; le posizioni aperte in tempo reale con P&L corrente.
|
||||
- **Profitti per periodo**: tabella `periodo; n_basket; n_posizioni; vinti; persi; win_rate; pnl_lordo; fee; pnl_netto; media_per_basket; max_dd; motivazione` per `oggi, ieri, 7 giorni, 30 giorni, mese corrente, mese precedente, anno, tutto, intervallo personalizzato`, con un piccolo grafico dell'equity (SVG generato lato server o canvas lato client, nessuna libreria). Fonte di verità del realizzato: lo **storico eToro** (`GetClosedTradesAsync`), riconciliato con `baskets.csv`; i movimenti di cassa restano fuori dal P&L e compaiono in una riga separata.
|
||||
- Pulsante **Esporta CSV** (`;`, colonna `motivazione`) per ciascuna vista.
|
||||
|
||||
Modulo: `src/Encelado.Core/Baskets/History/` (`OrderRecord`, `PositionRecord`, `PeriodStats`, `HistoryBuilder` puro e testabile) + endpoint `GET /api/history/{orders|positions|periods}`.
|
||||
|
||||
## 2. Navigazione verticale a sinistra, espandibile e comprimibile
|
||||
|
||||
Sostituisci la barra in alto con le tre schede con una **Navigation rail** Material 3 a sinistra (80 px chiusa: icona + etichetta breve; 256-300 px aperta: icona + etichetta estesa), apribile/chiudibile dal pulsante menu in alto e con stato persistito (`ui.navExpanded`). Voci: `Dashboard`, `Storico`, `Log`, `Impostazioni`; in basso, nel rail, il chip ambiente (`PAPER/DEMO/LIVE`) e lo stato (`in esecuzione / fermo / bloccato`). Su schermi stretti (< 600 px) il rail diventa un drawer modale. La barra superiore resta sottile: titolo della pagina, orologio UTC + fuso scelto, `AVVIA/FERMA`, `KILL-SWITCH`.
|
||||
|
||||
## 3. Versione fuori dalla schermata principale
|
||||
|
||||
Rimuovi dalla dashboard `v4.0.0 · strategia <hash> · run <id>`. In `Impostazioni → Informazioni`: nome, **autore (Alberto Balbo)**, versione, data di build, commit, licenza, link alla documentazione. In `Impostazioni → Diagnostica` (sezione comprimibile): hash della strategia, `run_id`, percorsi dei file, versione .NET, uptime, quote API usate.
|
||||
|
||||
## 4. Valuta di visualizzazione selezionabile
|
||||
|
||||
`ui.displayCurrency` (default = valuta del conto, `USD`) selezionabile fra `USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD` in Impostazioni e con un selettore rapido nella barra. Tassi: dalle quote eToro già in polling (EURUSD id 1, ecc.; per le coppie mancanti derivali via USD) aggiornati con le quote; in assenza di quota, l'ultimo tasso noto con etichetta «tasso di N minuti fa». Conversione **solo nel livello di presentazione** (snapshot → UI/Telegram/export); ledger, `baskets.csv`, `trials.csv` e tutte le decisioni restano in USD. Ogni importo convertito porta nel tooltip il valore in USD e il tasso usato. Formattazione con `CultureInfo` invariante e simbolo/decimali per valuta (JPY senza decimali).
|
||||
|
||||
## 5. Conteggio basket, posizioni aperte e «depositi» (correzione del bug di §0)
|
||||
|
||||
Implementa nell'ordine:
|
||||
1. **`OrderTracker`** (`src/Encelado.Core/Baskets/OrderTracker.cs`) con registro persistente `data/state/pending_orders.json`: `clientRef, orderId, symbol, instrumentId, isBuy, units, basket_id, leg (A|B|add|close|unwind), sentUtc, lastStatus, resolvedUtc, positionId`. Ogni invio si registra **prima** della chiamata HTTP; ogni esito lo aggiorna; all'avvio il registro viene ricaricato e i pendenti risolti prima di qualunque decisione.
|
||||
2. **`EtoroBroker.OpenAsync`**: dopo il `POST`, polling **per `orderId`** (endpoint da verificare, D-26) e, in parallelo, per `referenceId`; se entrambi rispondono 404, dopo 2 s interroga le posizioni e applica la regola di matching di §0(a); se il matching riesce l'ordine è `Filled` con quel `positionId`. Restituisce `Pending` solo se davvero non c'è traccia; **mai** un `"Received"` sintetico. Verifica la mappa degli stati (`filled = 3 or 5`, `rejected = 4,7,8,9,10`) sulla documentazione e scrivila in `docs/DATA_SOURCES.md`.
|
||||
3. **`BasketExecutor`**: se la gamba A resta `Pending` allo scadere di `legTimeoutSec`, il basket passa in stato **`PendingA`** (nuovo) invece di essere rifiutato: nessun nuovo ingresso su quel basket, `OrderTracker` continua a seguire l'ordine; alla risoluzione: eseguito → invia B (se il segnale è ancora valido e il cost gate passa) oppure **chiudi A subito** (`leg_risk_unwind`) se non lo è; rifiutato → `Idle`. Stesso schema per B (`PendingB`). Registra tutto in `orders.jsonl` e in `decisions.jsonl` (`evento = pending`, `pending_risolto`).
|
||||
4. **`ReconcileAsync`**: classifica ogni posizione del conto in `basket` (nota), `orfana-bot` (nel registro ordini o firma coerente con un `segnale_ingresso`/`rifiuto` del ledger entro ±90 s, stesso strumento, stesso verso, unità ± 1 %), `esterna`. Le orfane-bot vengono **adottate e chiuse** (§7 recupero decide se prima provare a ricomporre il basket con la gamba mancante: default **no**, chiudi). Le esterne restano intoccate salvo `risk.closeForeignOnKill`.
|
||||
5. **Bonifica una tantum** all'avvio della 5.0 (`--bonifica` in headless / pulsante in Diagnostica): elenca le posizioni orfane-bot attualmente sul conto demo, le chiude una per una con conferma, scrive per ognuna una riga `baskets.csv` con `exit_reason = bonifica_orfana` e il P&L realizzato letto dallo storico, e produce `reports/bonifica_YYYYMMDD.csv`.
|
||||
6. **Dashboard**: `Basket aperti a/b` resta, affiancato da `Gambe orfane: n` (rosso se > 0) e `Posizioni esterne: n`; `P&L aperto` mostra il valore **del conto** (`_account.UnrealizedPnl`) con sotto «di cui basket: x»; `Equity − Saldo` e `P&L aperto` devono coincidere a meno delle fee: se non coincidono per più di 60 s, banner giallo «posizioni non riconciliate».
|
||||
7. **Movimenti di cassa**: `HistoryBuilder` legge saldi e storico, riconosce depositi/prelievi/accrediti virtuali e li espone in Storico come `movimento di cassa`, esclusi dal P&L e dal calcolo del drawdown (il picco di equity va **ricalcolato al netto dei movimenti di cassa**: oggi un accredito di 30 000 gonfia il picco e falsa il DD).
|
||||
|
||||
Test obbligatori: (m) lookup 404 persistente + posizione presente → `Filled` per matching; (n) A eseguita e B rifiutata → unwind entro 5 s; (o) A pendente oltre il timeout poi eseguita → `PendingA → Open` con B, oppure unwind se il segnale è decaduto; (p) posizione orfana con firma del bot → classificata `orfana-bot` e chiusa; (q) picco di equity insensibile a un deposito.
|
||||
|
||||
## 6. Recupero dopo inattività
|
||||
|
||||
Heartbeat `data/state/heartbeat.json` scritto ogni 30 s (`utc, run_id, mode, openBaskets`). All'avvio e a ogni iterazione del ciclo: `inattività = now − ultimo heartbeat`; se `> recovery.thresholdMinutes` (default 10; copre riavvii, sospensione del PC, aggiornamenti Windows, riavvio del container) entra la **procedura di recupero**, nell'ordine:
|
||||
1. Blocca le nuove entrate; scrivi `evento = recupero_avviato` con la durata dell'inattività.
|
||||
2. Risolvi il registro ordini pendenti (§5.1); riconcilia e classifica le posizioni (§5.4).
|
||||
3. Riscalda le serie (ultime 1000 candele M15 via API) e ricalcola per ogni basket noto `z, ρ_W, ρ_20, HL, spread, costo, barsHeld` **includendo le barre trascorse durante l'inattività**.
|
||||
4. Per ogni basket aperto applica le regole di uscita di §5.4 della specifica precedente come se fosse una valutazione ordinaria (`stop_z`, `stop_max_loss`, `time_stop` con le barre di inattività, `rho_break`, spread anomalo, fine settimana/blackout) e decide **`chiudi` o `tieni`**; per ogni gamba orfana-bot: `chiudi`; per le posizioni esterne: solo rapporto.
|
||||
5. Esegui le chiusure decise; per i `tieni` ripristina TP/stop di basket e riprendi il monitoraggio tick-by-tick.
|
||||
6. Se il mercato è chiuso (fine settimana) programma la valutazione alla riapertura e non fare nulla di irreversibile.
|
||||
7. Scrivi `reports/recupero_<run_id>.csv` (una riga per posizione: `decisione; motivo; z; ρ; barsHeld; pnl; motivazione`), riga `recupero_concluso` nel ledger, notifica Telegram con il riepilogo; sblocca le entrate solo dopo `recovery.warmupMinutes` (default 15) di quote coerenti.
|
||||
Parametri in `strategy.json` → `recovery`. Test: (r) simulazione di 6 h di inattività con un basket che nel frattempo ha superato `z_stop` → chiuso; con un basket ancora dentro le soglie → tenuto e riarmato; con una gamba orfana → chiusa.
|
||||
|
||||
## 7. Notifiche Telegram
|
||||
|
||||
Modulo `src/Encelado.Core/Notifications/TelegramNotifier.cs` (Bot API via `HttpClient`, `sendMessage` con `parse_mode=HTML`, coda in `Channel<T>`, 1 messaggio/s, retry con backoff, nessuna libreria) + `INotifier` con `NullNotifier`. Configurazione in `encelado.json` → `notifications.telegram`: `enabled, chatId, hourlyStatus, eventAlerts, dailySummaryUtcHour` (token e chatId anche da `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID`; mai nel repo).
|
||||
- **Stato ogni ora** (allo scoccare dell'ora UTC): attivo/fermo/bloccato, modalità e ambiente, uptime, equity/saldo/disponibile/margine usato (valuta di visualizzazione), P&L oggi e aperto, basket aperti (per ognuno: coppie, verso, z, pip, P&L, durata), gambe orfane, posizioni esterne, DD dal picco, prossimo evento ad alto impatto, stato API (latenza, quote usate), ultimo errore.
|
||||
- **Eventi**: apertura basket, aggiunta, chiusura (con motivo e P&L), unwind, ordine pendente risolto, kill-switch, equity stop, perdita giornaliera raggiunta, recupero avviato/concluso, cambio preset, avvio/arresto del bot, errori API persistenti, scarto orologio, feed in errore per > 1 h.
|
||||
- **Riepilogo giornaliero** alle `dailySummaryUtcHour` (default 21): P&L del giorno, n. basket, win rate, costi, DD.
|
||||
- **Comandi in ingresso** (long polling `getUpdates`, solo dal `chatId` autorizzato, ogni comando loggato nel ledger): `/stato`, `/posizioni`, `/storico 7d`, `/pausa` (blocca entrate), `/riprendi`, `/chiudi <basket>`, `/kill CONFERMO` (kill-switch), `/reset <motivazione>`; qualunque altro testo → «comando non riconosciuto». Il polling usa un solo task e non compete con le quote eToro.
|
||||
Test: (s) formattazione dei messaggi su snapshot fissi; (t) throttling e retry con `HttpMessageHandler` finto; (u) rifiuto dei comandi da chatId non autorizzato.
|
||||
|
||||
## 8. Valutazione del sistema di autoapprendimento (decisione richiesta)
|
||||
|
||||
Stato di fatto: `docs/STRATEGY.md` dà **verdetto negativo** (7,75 anni, nessuna configurazione con P&L netto positivo, segnale ≈ 2 pip contro 3 pip di costo, walk-forward Sharpe −0,91); il forward test in Demo ha **0 basket chiusi** in sei giorni per il bug di §0; il ciclo settimanale ha girato a vuoto («niente da addestrare»). Un meta-modello può solo filtrare i basket, non creare un edge che non c'è: con un segnale sotto i costi il massimo che può fare è ridurre le perdite. Serve inoltre un minimo di ~300 basket chiusi per attivarsi: con la frequenza osservata (poche entrate al giorno, quando entrano) sono mesi.
|
||||
**Decisione da applicare (ADR-0006), salvo diversa risposta a D-30:**
|
||||
- **Tenere**: il ledger (è il dato), il Livello 0 (calibrazione: tabelle per bucket di z/ρ/ora/evento, quasi gratis e utili a leggere il forward test), la previsione di volatilità (già usata dal decisore in modo deterministico), la logistica **in ombra** (costo nullo, produce `p_ML` nel ledger).
|
||||
- **Disattivare a runtime** (codice conservato nel Core, `learning.enabled = false` di fabbrica, nessun task avviato): MLP challenger, bandit sui preset, ciclo settimanale. Spostare la loro esecuzione nello strumento `tools/Encelado.Backtest` (`learn` su un ledger esportato), così restano disponibili quando ci saranno dati.
|
||||
- **UI**: rimuovere il riquadro «Apprendimento» dalla dashboard; in `Impostazioni → Ricerca` una sezione comprimibile con lo stato del modello in ombra (n. basket visti, AUC mobile, PSI) e il pulsante «esegui ciclo di apprendimento» manuale.
|
||||
- **Criterio di riattivazione**, scritto in `docs/ML_AND_LEARNING.md`: ≥ 300 basket chiusi in Demo **e** P&L netto forward ≥ 0 sulla pre-registrazione; prima di allora qualunque «adattamento automatico» è rumore.
|
||||
|
||||
## 9. Kill-switch che chiude davvero e procedura di ripristino
|
||||
|
||||
**Kill-switch** (`KillAsync`), nuovo comportamento, idempotente e ripetibile:
|
||||
1. `halt = true`, blocco immediato di ogni nuova entrata e aggiunta; riga `kill_switch_avviato`.
|
||||
2. Annulla gli ordini pendenti nel registro (endpoint di cancellazione se esiste, D-27; altrimenti attende la risoluzione entro 30 s e chiude ciò che si è eseguito).
|
||||
3. Chiude **tutte le posizioni del bot**: basket noti + gambe orfane-bot (§5.4), tre tentativi per gamba con backoff, verifica tra un tentativo e l'altro sull'elenco posizioni.
|
||||
4. Posizioni **esterne**: chiuse solo se `risk.closeForeignOnKill = true` **oppure** se la finestra di conferma del kill-switch ha la spunta «chiudi anche le posizioni esterne» (default deselezionata, con l'elenco delle posizioni e il loro P&L).
|
||||
5. **Verifica di piattezza**: rilegge le posizioni finché non restano solo esterne non incluse (timeout 120 s); se restano residui, banner rosso con l'elenco e stato `Halted-Residuo`; nessuna dichiarazione di «tutto chiuso» senza questa verifica.
|
||||
6. Persiste `killSwitched`, scrive una riga `baskets.csv` per basket e una riga `orders.jsonl` per gamba, notifica Telegram (esito, residui).
|
||||
Il file `STOP` e il comando `/kill CONFERMO` seguono lo stesso percorso.
|
||||
|
||||
**Ripristino** (`Impostazioni → Ripristino` e comando `reset <motivazione>`), procedura guidata in cinque passi mostrati uno per volta: (1) stato attuale: posizioni residue (con pulsante «chiudi ora»), ordini pendenti, ultimo errore; (2) rimozione del file `STOP` (automatica, con avviso se ricompare); (3) motivazione obbligatoria ≥ 10 caratteri → riga `correzione`; (4) riconciliazione completa + riscaldamento serie + ricalcolo del picco di equity (al netto dei movimenti di cassa); (5) ripartenza con entrate bloccate per `recovery.warmupMinutes`; notifica Telegram. Documenta in `docs/RUNBOOK.md` con una tabella «sintomo → passo». Test: (v) kill con 2 basket + 1 orfana + 1 esterna → chiude 5 gambe, lascia l'esterna, verifica la piattezza; (w) reset senza motivazione rifiutato; (x) reset con residui → stato `Halted-Residuo`.
|
||||
|
||||
## 10. Saldo disponibile e coerenza delle aperture
|
||||
|
||||
Leggi a ogni ciclo `AccountSnapshot` completo (`balance, equity, available, marginUsed, credit`) da `api/v1/balances` + `pnl` e mostra `Margine usato / disponibile` in dashboard. Nuove regole in `strategy.json → risk` (documentate in `docs/RISK_RULES.md`):
|
||||
- `maxMarginUsePct` (default **40 %** dell'equity): margine totale impegnato dopo l'apertura ≤ soglia.
|
||||
- `maxMarginPerBasketPct` (default **12 %**): margine delle due gambe di un basket ≤ soglia.
|
||||
- `marginBufferPct` (default 25 %): `available ≥ (marginA + marginB) × 1,25` **prima** di inviare A, e ricontrollo di `available` (con la posizione A già aperta) prima di inviare B; se B non è più coperta, unwind di A.
|
||||
- Sizing = **min**(sizing a rischio, sizing a margine); il ledger scrive quale vincolo ha deciso (`sizing_bound = risk | margin | min_exposure`) e il margine impegnato previsto; l'esposizione minima eToro (1000 USD per posizione) resta il floor: se il min non la raggiunge, niente ingresso (`reasonCodes: min_exposure`).
|
||||
- Ordine dei basket quando più segnali arrivano insieme: per `|z|` decrescente, uno alla volta, ricalcolando `available` dopo ogni apertura.
|
||||
- Margin call preventiva: se `equity / marginUsed < 1,5` blocca le entrate; `< 1,2` chiude il basket con il peggior P&L (riga `margin_guard`).
|
||||
Test: (y) con equity 100 000 e Aggressive il nozionale per basket è limitato dal margine, non dal rischio; (z) il secondo basket viene rifiutato quando `available` non copre `1,25 ×` margine.
|
||||
|
||||
## 11. Interfaccia secondo Material Design 3 (web, senza librerie) — e conseguenza sul progetto WPF
|
||||
|
||||
Docker su Unraid è Linux: **WPF non può girare nel container**. Decisione proposta (**ADR-0007**, da confermare con D-28): l'interfaccia diventa una **web app servita dal bot stesso** (Kestrel, `Microsoft.AspNetCore.App` è un framework reference dell'SDK, non un pacchetto NuGet), usata sia su Windows (`http://localhost:8080`) sia nel container; il progetto WPF viene ritirato (resta nella storia git) e il vecchio eseguibile Windows diventa `Encelado.Server` avviabile come console/servizio. Nessun framework front-end, nessun font remoto: HTML + CSS + JavaScript vanilla incorporati come risorse (`EmbeddedResource`), aggiornamenti in tempo reale via **Server-Sent Events** (`/api/stream` con lo snapshot ogni secondo), comandi via `POST /api/commands/...`, autenticazione con **token locale** (`ENCELADO_WEB_TOKEN`, cookie HttpOnly; senza token il server ascolta solo su localhost).
|
||||
|
||||
Linee guida M3 da applicare (scrivile in `docs/UI_GUIDELINES.md` con i token scelti):
|
||||
- **Colore**: ruoli M3 (`primary/on-primary/primary-container`, `surface`, `surface-container-lowest…highest`, `outline`, `error`), palette scura di default e chiara selezionabile, contrasto ≥ 4,5:1; una sola tinta d'accento; verde/rosso solo per P&L e stati.
|
||||
- **Tipografia**: scala M3 (`display, headline, title, body, label`), `Roboto, "Segoe UI", system-ui` con fallback, cifre tabulari (`font-variant-numeric: tabular-nums`) per tutti i numeri.
|
||||
- **Forma ed elevazione**: angoli 12-16 px, elevazione tramite superfici tonali (non ombre pesante), state layer su hover/focus/pressed.
|
||||
- **Componenti** (in CSS/JS proprio): navigation rail + drawer (§2), top app bar, cards (elevated/filled/outlined), data table con densità compatta e intestazioni fisse, chips di stato (assist/filter), buttons (filled/tonal/outlined/text), switch, select/menu, dialog per conferme (kill-switch, chiusura basket, reset), snackbar per esiti, linear progress durante le operazioni, tooltip con formula/fonte su ogni numero, badge sui contatori.
|
||||
- **Layout adattivo** per classi di finestra M3 (compact < 600, medium < 840, expanded ≥ 840): rail/drawer, griglia a 4/8/12 colonne, tabella dei basket che passa a cards sotto 600 px.
|
||||
- **Accessibilità**: navigazione da tastiera completa, `aria-label`, focus visibile, preferenze `prefers-reduced-motion` e `prefers-color-scheme`.
|
||||
|
||||
Contenuti (stesse informazioni dello screenshot attuale, riorganizzate):
|
||||
- **Dashboard**: KPI cards `Equity`, `P&L oggi`, `P&L aperto (conto) / di cui basket`, `Drawdown`, `Basket aperti a/b · orfane · esterne`, `Margine usato / disponibile`; tabella basket con `Basket (cross)`, `Stato`, `z`, `ρ`, `HL`, `Pips`, `TP`, `P&L`, `Costo`, `Prossimo evento`, pulsante `Chiudi` (la colonna `p ML` sparisce dalla tabella e va nel tooltip); cards `Prossimo evento`, `Collegamento eToro`, `Telegram` (ultimo invio, coda); striscia `Attività`.
|
||||
- **Storico**: §1. **Log**: come oggi, con filtro e ricerca. **Impostazioni**: come oggi + `Notifiche`, `Valuta`, `Ripristino`, `Ricerca`, `Diagnostica`, `Informazioni`.
|
||||
Test: (aa) `UiRenderTests` sostituiti da test sullo snapshot JSON dell'API e da un test che valida l'HTML incorporato (well-formed, nessun riferimento esterno, nessun `<script src=http…>`); (bb) SSE che emette uno snapshot al secondo con un client finto.
|
||||
|
||||
## 12. Docker (esecuzione fuori da Windows)
|
||||
|
||||
- Ristruttura i progetti: `Encelado.Core` (invariato), `Encelado.Etoro` (invariato), nuovo **`Encelado.Engine`** (net10.0, portabile: `Baskets/`, `Configuration/`, `Engine/`, `Logging/`, `Notifications/`, `History/` oggi dentro `Encelado.Bot`), nuovo **`Encelado.Server`** (`Microsoft.NET.Sdk.Web`, host Kestrel + UI incorporata + CLI headless con gli stessi comandi di oggi). `EtoroKeyStore` (DPAPI) diventa `IKeyStore` con tre implementazioni: DPAPI (solo Windows), file cifrato con AES da `ENCELADO_KEY_PASSPHRASE`, variabili d'ambiente (default nel container). `TreatWarningsAsErrors` e `Nullable` invariati; nessun NuGet.
|
||||
- `Dockerfile` multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0` per build/test/publish (`-c Release`, framework-dependent), `mcr.microsoft.com/dotnet/aspnet:10.0` per l'esecuzione; utente non root con `PUID/PGID` (default 99/100 per Unraid) applicati da un entrypoint che sistema i permessi di `/config` e `/data`; `TZ` rispettato dai log (il ledger resta UTC); `HEALTHCHECK` con `dotnet Encelado.Server.dll --health` (verifica API eToro raggiungibile, heartbeat recente, disco scrivibile); arresto pulito su `SIGTERM` (`closeOnShutdown` invariato, flush di ledger e stato, heartbeat finale); log su stdout **e** su file.
|
||||
- Volumi: `/config` (`encelado.json`, `strategy.json`, `instruments.json`, chiavi cifrate), `/data` (`data/`, `knowledge/`, `reports/`, `results/`, `logs/`). Percorsi in `encelado.json` relativi a `/config`; su Windows restano `Documenti\Encelado`.
|
||||
- Variabili: `ETORO_API_KEY`, `ETORO_USER_KEY`, `ETORO_ENVIRONMENT` (`demo|real`), `ENCELADO_EXECUTION_MODE` (`Paper|Demo|Live`), `ENCELADO_CONFIRM_LIVE` (deve valere `CONFERMO LIVE` per il Live), `ENCELADO_WEB_PORT` (8080), `ENCELADO_WEB_TOKEN`, `ENCELADO_DISPLAY_CURRENCY`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `TZ`, `PUID`, `PGID`.
|
||||
- **Una sola istanza** per cartella dati: lock file `data/state/instance.lock` con PID e orario (risolve il problema noto).
|
||||
- `docker-compose.yml`, `.dockerignore`, `docs/DOCKER.md` (build, avvio, aggiornamento, backup di `/config` e `/data`, rotazione chiavi, cosa succede ai basket aperti al riavvio → §6). La catena `build/Release.proj` acquisisce il target `Docker` (build + tag `encelado:<versione>`), senza toccare il resto (regola 5 di `CLAUDE.md`); l'installatore Windows resta per `Encelado.Server`.
|
||||
|
||||
## 13. Template XML per Unraid
|
||||
|
||||
Crea `deploy/unraid/encelado.xml` (formato Community Applications, `Container version="2"`), con: `Name` Encelado, `Repository` (registry e immagine da D-29, es. `gitea.<dominio>/alberto/encelado:latest`), `Registry`, `Network bridge`, `Privileged false`, `Support`, `Project`, `Overview` (descrizione in italiano), `Category` `Tools:`, `WebUI` `http://[IP]:[PORT:8080]/`, `Icon` (URL raw di `assets/encelado.png` 256×256, da aggiungere al repo insieme all'`.ico`), `ExtraParams --restart=unless-stopped`, `Config` per: porta 8080 (`Type=Port`), `/config` → `/mnt/user/appdata/encelado/config` e `/data` → `/mnt/user/appdata/encelado/data` (`Type=Path`, `Mode=rw`), tutte le variabili di §12 (`Type=Variable`, `Mask=true` su chiavi e token, `Display=always` per modalità/ambiente/valuta/TZ, `Display=advanced` per PUID/PGID/porta), con `Description` in italiano per ogni voce. Aggiungi `deploy/unraid/README.md` (come importare il template: «Add Container → Template repositories» o copia in `/boot/config/plugins/dockerMan/templates-user/`).
|
||||
|
||||
## 14. Documentazione-memoria per l'AI e base di conoscenza
|
||||
|
||||
Aggiorna: `CLAUDE.md` (nuova struttura dei progetti, comandi Docker/web, regole invariate), `docs/STATE.md`, `docs/ARCHITECTURE.md` (OrderTracker, stati `PendingA/PendingB/Halted-Residuo`, web/SSE, Engine/Server, notifiche, recupero), `docs/RISK_RULES.md` (margine, kill-switch, esterne), `docs/RUNBOOK.md` (ripristino guidato, Docker, Telegram, bonifica), `docs/LEDGER_SCHEMA.md` (`orders.jsonl`, `pending_orders.json`, `heartbeat.json`, nuovi `evento`), `docs/DATA_SOURCES.md` (rotte eToro verificate con stati ordine, Telegram), `docs/ML_AND_LEARNING.md` (§8), `docs/KNOWN_ISSUES.md` (rimuovi ciò che si chiude, aggiungi ciò che resta), `docs/UI_GUIDELINES.md`, `docs/DOCKER.md`, `docs/GLOSSARY.md`, `CHANGELOG.md`; ADR-0006 (apprendimento), ADR-0007 (UI web al posto di WPF), ADR-0008 (Docker/Engine/Server), ADR-0009 (OrderTracker e adozione delle orfane). Genera anche **`docs/POSTMORTEM_ordini_pendenti.md`**: cosa è successo, evidenze dal ledger, causa, correzione, come si verifica che non si ripeta.
|
||||
|
||||
Crea inoltre le **skill di progetto per Claude Code** in `.claude/skills/<nome>/SKILL.md` (frontmatter `name`, `description`), così che il lavoro futuro parta sempre dallo stesso posto:
|
||||
- `encelado-start`: leggi `docs/STATE.md` e `CLAUDE.md`, esegui `dotnet build`, riassumi fase e problemi aperti, chiedi cosa fare.
|
||||
- `encelado-verify`: `dotnet build`, `dotnet test`, verifica dell'HTML incorporato, `dotnet msbuild build/Release.proj -t:Verifica`, riepilogo esiti.
|
||||
- `encelado-diagnose`: legge `decisions.jsonl`, `orders.jsonl`, `pending_orders.json`, `heartbeat.json` e l'elenco posizioni (se chiavi presenti) e produce un rapporto di riconciliazione in CSV `;`.
|
||||
- `encelado-docs`: aggiorna `docs/STATE.md`, `CHANGELOG.md`, ADR e `docs/QUESTIONS.md` a fine sessione, poi propone il messaggio di commit.
|
||||
- `encelado-release`: catena di rilascio + build Docker + aggiornamento del template Unraid.
|
||||
- `encelado-ui`: applica i token e le regole di `docs/UI_GUIDELINES.md` quando si tocca la UI.
|
||||
|
||||
## 15. Domande da porre prima di iniziare (in blocco, con default)
|
||||
|
||||
| # | Domanda | Default |
|
||||
|---|---|---|
|
||||
| D-26 | Qual è l'endpoint corretto per leggere un ordine per `orderId` e quali sono gli stati (id/nome)? Fornisci la pagina della documentazione o l'esito di una chiamata reale. | verifica sul portale; se non esiste, riconciliazione per posizioni entro 2 s |
|
||||
| D-27 | Esiste un endpoint di cancellazione ordini? | no: attesa risoluzione ≤ 30 s |
|
||||
| D-28 | Confermi il ritiro del progetto WPF a favore della web UI servita dal bot (Windows e Docker)? | sì (ADR-0007) |
|
||||
| D-29 | Registry per l'immagine (Gitea del tuo server, GHCR, Docker Hub) e URL raw per l'icona del template Unraid? | Gitea (`build/gitea.example.json`), icona nel repo |
|
||||
| D-30 | Confermi la disattivazione a runtime di MLP, bandit e ciclo settimanale (§8)? | sì (ADR-0006) |
|
||||
| D-31 | Chat ID e token Telegram verranno forniti via variabili d'ambiente? Vuoi i comandi in ingresso o solo le notifiche? | env; comandi attivi |
|
||||
| D-32 | Le posizioni esterne vanno chiuse dal kill-switch di default? | no |
|
||||
| D-33 | Valute da offrire nel selettore oltre a USD/EUR? | USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD |
|
||||
| D-34 | Soglie di margine (40 % totale, 12 % per basket, buffer 25 %) e soglia di inattività (10 min) vanno bene? | sì |
|
||||
| D-35 | Le posizioni orfane oggi sul demo: le chiudo io con la bonifica (§5.5) o le chiudi tu a mano prima? | bonifica con conferma per posizione |
|
||||
|
||||
## 16. Fasi e criteri di accettazione
|
||||
|
||||
| Fase | Contenuto | Accettazione |
|
||||
|---|---|---|
|
||||
| 0 | `docs/PIANO_5.0.md`, domande D-26…D-35, `docs/POSTMORTEM_ordini_pendenti.md` | piano approvato, domande risposte o default registrati |
|
||||
| 1 | §5 OrderTracker, lookup per orderId, stati Pending, adozione orfane, contatori, picco al netto dei movimenti di cassa, bonifica | test (m)-(q) verdi; 24 h di Demo con ordini eseguiti riconosciuti e **zero** orfane |
|
||||
| 2 | §10 margine e coerenza aperture | test (y)(z); ledger con `sizing_bound` |
|
||||
| 3 | §9 kill-switch reale + ripristino guidato | test (v)-(x); prova manuale in Demo con verifica di piattezza |
|
||||
| 4 | §6 recupero dopo inattività, heartbeat, lock di istanza | test (r); prova: fermare 1 h con basket aperto |
|
||||
| 5 | §7 Telegram (stato orario, eventi, riepilogo, comandi) | test (s)-(u); messaggi reali ricevuti |
|
||||
| 6 | §12 Engine/Server, web host, SSE, API, key store portabile | avvio su Windows e in container con la stessa `/config` |
|
||||
| 7 | §11 UI Material 3 + §2 navigazione + §3 versione + §4 valuta + §1 Storico + §8 pannello Ricerca | test (aa)(bb); revisione visiva con te; stesse informazioni dello screenshot presenti |
|
||||
| 8 | §12 Dockerfile/compose/docs + §13 template Unraid | immagine avviata su Unraid con template importato, healthcheck verde |
|
||||
| 9 | §14 documentazione, ADR, skill di progetto, `CHANGELOG`, versione **5.0.0**, rilascio | `encelado-verify` verde; commit a fine sessione |
|
||||
|
||||
**Regola finale: prima la correttezza dell'esecuzione (Fasi 1-3), poi tutto il resto. Finché un ordine dall'esito ignoto può restare sul conto senza padrone, nessuna nuova funzione va in Demo.**
|
||||
@@ -0,0 +1,62 @@
|
||||
# Domande e risposte
|
||||
|
||||
Ogni domanda è numerata per fase. Quando l'utente non ha risposto, è stato applicato il default più prudente e la scelta è segnata come **default applicato**: resta aperta finché non arriva una risposta.
|
||||
|
||||
## Fase 0 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-01 | Il bot è già in C#? Quale target framework? | quello del repo | **Risposto dal repo**: C#, `net10.0` (Bot e test `net10.0-windows`), SDK 10.0.301. Nessuna proposta di cambio. |
|
||||
| D-02 | UI attuale: console, WinForms o WPF? Posso aggiungere un progetto WPF? | nuovo progetto WPF + headless | **Risposto dal repo**: è già WPF (`Encelado.Bot`, tema scuro proprio). Non si aggiunge un progetto: si aggiungono pagine alla shell esistente e la modalità `--headless` nello stesso eseguibile. |
|
||||
| D-03 | Valuta del conto eToro e disponibilità di chiavi demo? | USD, demo | **Verificato via API** (collegamento MCP dell'utente, sola lettura): conto in **USD**; `demoCid` e `realCid` esistono. Le chiavi long-lived (`x-api-key` + `x-user-key`) non sono ancora state fornite al bot: la finestra di accesso le chiede e le salva cifrate (DPAPI). **Default applicato: USD, demo.** |
|
||||
| D-04 | Regola di approvazione: automatismo consentito già in demo? | `DemoApprove` | **Default applicato: `DemoApprove`**. `DemoAuto` richiede `etoro.allowDemoAuto = true` in `encelado.json` e la conferma all'avvio (finestra, o `--confirm-demo-auto` in headless). Le modalità Live richiedono `etoro.allowLive = true` e la frase `CONFERMO LIVE`. L'utente ha chiesto una lunga sessione di test "sperando di piazzare trade": senza risposta il test lungo gira in `Paper` (simulatore locale sopra le quote reali) o in `DemoApprove` con approvazione manuale. |
|
||||
| D-05 | Gli 8 strumenti sono disponibili sul conto? Spread tipici? | verifica via API | **Verificato via API il 2026-09-16 07:23 UTC**: tutti e 8 disponibili (id: EURUSD 1, USDCHF 6, AUDUSD 7, USDCAD 4, NZDUSD 3, EURNZD 49, EURAUD 12, AUDCAD 47; anche EURCHF 9 ed EURCAD 13 per `PreferDirectCross`). Spread di mercato osservati senza markup: 0,1 pip sulle majors, 0,3-0,7 pip sui cross. Il markup di eToro si legge dall'endpoint dei costi e viene sommato nel cost gate. Esposizione minima 1000 USD per posizione, leva fino a 30 (majors) / 20 (minors). **Attenzione**: il conto reale vale 193,18 USD; con `RiskPerBasket` 0,5 % e esposizione minima 1000 USD il reale non è operabile senza leva alta: il passaggio a `LiveApprove` resta comunque subordinato ai cancelli di §9.4. |
|
||||
| D-06 | Dove gira il bot (PC locale Windows, VPS Windows)? | PC locale + headless pronto per VPS | **Default applicato**: Windows 11 locale (questa macchina); `--headless` disponibile per un VPS Windows. |
|
||||
| D-07 | Esiste già uno storage/log da riusare? | nuovi file in `data/` | **Default applicato con una precisazione**: il log applicativo (`Log`, file `;`) e `CsvTable` vengono riusati; il ledger, i dati di mercato, i modelli e la base di conoscenza vanno in file (`data/`, `knowledge/`, `reports/`, `results/`) sotto `Documenti\Encelado\`, come richiesto. Il database SQLite esistente resta per il motore `proba` e non viene usato dal modulo basket. |
|
||||
| D-08 | Se una fonte news/calendario risulta irraggiungibile: sostituire o omettere? | omettere e annotare | **Verificato il 2026-09-16**: calendario FairEconomy (JSON e XML), FXStreet, ForexLive, Fed, ECB (`https://www.ecb.europa.eu/rss/press.html`), BoE (`https://www.bankofengland.co.uk/rss/news`), RBA (`https://www.rba.gov.au/rss/rss-cb-media-releases.xml`), BoC (`https://www.bankofcanada.ca/content_type/press-releases/feed/`) e Google News rispondono 200. **SNB** (`/en/rss/press-releases` → 404) e **RBNZ** (403 "website unavailable") no: **default applicato: omesse**, coperte da Google News con query mirate (`SNB`, `RBNZ`). Annotato in `docs/DATA_SOURCES.md`. |
|
||||
| D-09 | Che fare del motore cTrader/ProbaBot trovato a metà e non committato? | mantenerlo selezionabile | **Default applicato**: resta nel repo, rimesso in compilazione (riferimento di progetto e piccoli fix) e selezionabile con `engine.strategy = "proba"`; il predefinito diventa `"baskets"`. Nessun comportamento esistente viene cambiato. Se l'utente preferisce eliminarlo, basta rimuovere `src/Encelado.CTrader` e `Engine/ProbaEngine.cs`. |
|
||||
| D-10 | Dove stanno i parametri della strategia: in `encelado.json` o in un file separato? | `config/strategy.json` come da specifica | **Default applicato**: `strategy.json` separato (copia di fabbrica in `config/`, copia dell'utente in `Documenti\Encelado\`), letto con `JsonDocument`; `instruments.json` scritto dal bot all'avvio nella stessa cartella. `encelado.json` riceve solo le sezioni `etoro` e `engine.strategy`. |
|
||||
| D-11 | Fuso orario dei tick MT5 in `A:\Download\Trading`? | verificare sul fine settimana | **Verificato**: la chiusura del venerdì cade alle 20:53-20:57 in estate e alle 21:53-21:57 in inverno, la riapertura alle 21:05 (estate) / 22:05 (inverno) della domenica: è **UTC**. Nessuna conversione. Formato: tab-separato `<DATE> <TIME> <BID> <ASK> <LAST> <VOLUME> <FLAGS>`; le righe con solo bid o solo ask (flag 2/4) aggiornano un solo lato. |
|
||||
| D-12 | Lo storico M15 via API eToro si può scaricare paginando? | sì, 1000 barre per richiesta | **Verificato: no.** L'endpoint delle candele accetta solo `count ≤ 1000` e la direzione, senza data di partenza: fornisce al massimo ~10 giorni di M15. Il backtest usa i tick forniti dall'utente; l'API serve per riscaldamento (ultime 1000 barre) e riconciliazione. |
|
||||
| D-13 | Il TP di basket "in pip" con lotti diversi fra le gambe: pip lordi sommati come Titany, o P&L netto? | come da specifica | **Default applicato**: `Pips` di basket = somma dei pip delle due gambe (UI e `ExitMode = FixedPips`); ogni decisione di stop usa il P&L netto in USD; entrambi finiscono nel ledger. |
|
||||
| D-14 | Le credenziali eToro per il bot: quando? | attendere | L'utente ha scritto: «Aspetta l'input per le credenziali per la prima volta e poi potrai aprirlo in autonomia quando memorizzerò la password». La finestra di accesso chiede `x-api-key` e `x-user-key` e li salva in `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI). Finché non ci sono, il bot in headless resta in sola lettura e lo dice. |
|
||||
|
||||
## Fase 1 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-15 | Leva da usare su ogni gamba (l'API la richiede per ordine)? | 10 | **Default applicato**: `orderLeverage = 10` (ammessa su tutte le 8 coppie); l'esposizione complessiva resta comunque ≤ 10:1 sul nozionale (`MaxEffectiveLeverage`) e lo stop nativo di eToro viene messo alla distanza coerente con `MaxLossPerBasket%`, dentro i limiti di eligibility. |
|
||||
| D-16 | Overnight: usare il valore dell'endpoint dei costi o una tabella? | endpoint | **Default applicato**: l'endpoint dei costi (`overnightFee`, `overWeekendFee`) quando disponibile; in backtest una tabella configurabile per coppia (`overnightPipsPerDay`, default 0,3 pip/gamba/giorno, ×3 nel fine settimana). |
|
||||
|
||||
## Fasi 2-7 — 2026-09-16
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-17 | Lo spread anomalo (> 3 × mediana) deve chiudere il basket alla prima barra o dopo una persistenza? | persistenza | **Default applicato**: chiusura forzata solo dopo **3 barre chiuse consecutive** sopra la soglia. Nel backtest la chiusura immediata scattava sui picchi di spread e perdeva sistematicamente (`BasketPosition.BarsWithSpreadAnomaly`). |
|
||||
| D-18 | Nel backtest l'equity stop blocca tutto per sempre o riparte? | riparte | **Default applicato**: dopo lo stop il picco riparte dall'equity corrente e il numero di stop viene contato (`EquityStops` nel riepilogo); altrimenti il primo stop del 2019 avrebbe fermato sette anni di prova. Dal vivo lo stop richiede il reset manuale. |
|
||||
| D-19 | Fed risponde 404 e RBA "Access Denied" con lo User-Agent minimale: cambiare UA? | UA esplicito del bot | **Applicato**: `Encelado/4.0 (+correlation baskets; contact: operator)`. La Fed risponde; RBA (Akamai) a intermittenza. Dopo due errori consecutivi il feed logga solo a debug e ritenta con attese crescenti. |
|
||||
| D-20 | Per il test lungo in demo: `DemoAuto` (bot autonomo) o `DemoApprove`? | DemoAuto | **Risposta dell'utente (2026-09-16 15:00)**: «tutti gli Approve devono sparire, almeno per il momento. Il bot deve girare in completa autonomia aprendo e chiudendo le posizioni senza il mio consenso». Modalità ridotte a `Paper`, `Demo`, `Live`; coda delle approvazioni rimossa (ADR-0005). Il Live conserva flag e frase `CONFERMO LIVE`. |
|
||||
| D-21 | La pulizia delle «vecchie gestioni» deve includere anche cTrader/proba e la pipeline di ricerca? | sì, tutto | **Risposta dell'utente**: «Tutto: resta solo eToro + basket». Rimossi `Encelado.CTrader`, `Encelado.Storage`, ricerca, indicatori, RL, TA-Lib e i test relativi (ADR-0004). |
|
||||
| D-22 | Versione del rilascio su Gitea? | 4.0.0 | **Risposta dell'utente**: 4.0.0 (nuovo broker, nuova strategia, configurazione incompatibile). |
|
||||
| D-23 | Fuso orario della finestra: quello del computer o selezionabile? | computer, selezionabile | **Applicato**: `ui.timeZone` = `computer` di fabbrica; elenco dei fusi di Windows in Impostazioni; `ENCELADO_TIME_ZONE` da ambiente. Solo la finestra cambia: il log porta l'offset, il ledger è UTC. |
|
||||
| D-24 | L'endpoint dei costi restituiva markup e overnight a zero: era davvero zero? | verificare | **Verificato via API il 2026-09-16 12:45 UTC**: il campo si chiama `value`, non `amount`. EURUSD 10 000 unità leva 10: markup 0,0, spread di mercato 0,1 USD (0,1 pip), overnight 0,91 USD/giorno (≈ 0,9 pip/gamba/giorno). Parser corretto; aggiunto lo scenario di costi `api` al backtest. |
|
||||
| D-25 | Google News vieta `/rss/search` nel robots.txt: forzare, sostituire o omettere? | omettere | **Default applicato: omettere** (il bot rispetta il robots.txt). SNB e RBNZ restano senza fonte; documentato in `KNOWN_ISSUES.md`. |
|
||||
|
||||
## Fase 0 della 5.0 — 2026-09-23
|
||||
|
||||
Domande del prompt «Encelado 5.0» (§15) più quelle emerse dalla verifica. D-26 e D-27 sono state verificate via API (sola lettura, collegamento MCP dell'utente) e non aspettano risposta. Le Fasi 1-5 hanno usato i default; D-28, D-29, D-36 e D-37 hanno avuto risposta il 2026-09-23 e le Fasi 6-9 sono state eseguite.
|
||||
|
||||
| # | Domanda | Default proposto | Stato / risposta |
|
||||
|---|---|---|---|
|
||||
| D-26 | Endpoint per leggere un ordine per `orderId` e mappa degli stati? | verifica sul portale | **Verificato via API il 2026-09-23**: `GET api/v2/trading/info/{demo/}orders:lookup?orderId=<id>` (stessa risposta del lookup per riferimento) e `GET api/v1/trading/info/{demo/}orders/{orderId}` (risposta v1: `statusID`, `positions[]`). Stati: 1 Received, 2 Placed, 3 Filled, 4 Rejected, 5 PartiallyFilled, 6 PendingCancel, 7 Canceled, 8 Expired, 9 CanceledPartiallyFilled, 10 RejectedPartiallyFilled, 11 WaitingForMarket, 12 PendingTriggeredRate. Eseguito = 3 o 5; rifiutato = 4, 7, 8, 9, 10; in corso = 1, 2, 6, 11, 12. **Il lookup per `referenceId` non funziona per gli ordini v2**: il server ha registrato `referenceID = 00000000-…` per gli ordini del bot (verificato su 381739181). Scritto in `docs/DATA_SOURCES.md`. |
|
||||
| D-27 | Esiste un endpoint di cancellazione ordini? | no | **Sì, verificato**: `DELETE api/v2/trading/execution/{demo/}orders/{orderId}` (quota 20/min condivisa con gli ordini). Il 200 conferma solo la richiesta; l'esito si legge con il lookup: 7 o 9 = annullato, 6 = in corso. Il kill-switch lo usa e poi attende la risoluzione (≤ 30 s). |
|
||||
| D-28 | Confermi il ritiro del progetto WPF a favore della web UI servita dal bot? | sì (ADR-0007) | **Risposta dell'utente (2026-09-23)**: «Sì, confermo: il bot deve essere eseguito SOLO tramite container. Elimina tutta la parte della grafica Windows». Applicata: progetto WPF rimosso, `Encelado.Engine` + `Encelado.Server`, interfaccia web Material 3, chiavi in file cifrato al posto di DPAPI, VS Code con F5 sul server (ADR-0007, ADR-0008). |
|
||||
| D-29 | Registry per l'immagine e URL dell'icona del template Unraid? | Gitea (`build/gitea.example.json`), icona nel repo | **Risposta dell'utente (2026-09-23)**: «Per il registro useremo Gitea per il momento. L'icona non si può pubblicare da qualche parte su Gitea?». Applicata: immagine `192.168.30.23:3000/alby96/encelado:<versione>` sul registro dei container di Gitea (stesso token della release); icona `assets/encelado.png` servita da Gitea come file raw del repository (`Icon` del template). |
|
||||
| D-30 | Confermi la disattivazione a runtime di MLP, bandit e ciclo settimanale? | sì (ADR-0006) | **Default applicato** (ADR-0006, 2026-09-23): il bandit propone e non applica (Fase 1); `learning.enabled = false` di fabbrica spegne ciclo settimanale e challenger e impedisce al cancello ML di attivarsi; la logistica resta in ombra. Il comando `learn` dello strumento arriva con la Fase 6. Resta aperta solo se vuoi il contrario. |
|
||||
| D-31 | Token e chat ID Telegram via variabili d'ambiente? Comandi in ingresso o solo notifiche? | env; comandi attivi | **Default applicato** (Fase 5). |
|
||||
| D-32 | Le posizioni esterne vanno chiuse dal kill-switch di default? | no | **Default applicato**: `risk.closeForeignOnKill = false`; la conferma del kill-switch ha la spunta «chiudi anche le esterne», deselezionata. |
|
||||
| D-33 | Valute del selettore oltre USD/EUR? | USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD | **Default applicato** (Fase 7). Per GBP e JPY il polling aggiunge GBPUSD e USDJPY. |
|
||||
| D-34 | Soglie di margine (40 % totale, 12 % per basket, buffer 25 %) e soglia di inattività (10 min)? | sì | **Default applicato** (Fasi 2 e 4), tutte in `strategy.json` e cambiabili dall'operatore. |
|
||||
| D-35 | Le orfane sul demo: bonifica con conferma o chiusura manuale? | bonifica con conferma | **Superata dai fatti**: il 2026-09-23 il conto demo ha 0 posizioni e 0 ordini; le 21 gambe del bot sono state chiuse in blocco il 21/9 alle 13:26 UTC (chiusura manuale, non del bot). La bonifica resta come comando (`--bonifica`, e pulsante in Diagnostica) per il futuro e non ha niente da chiudere oggi. |
|
||||
| D-36 | Il ledger delle sessioni 16-21/9 (65 `segnale_ingresso`/`rifiuto`) non è su questa macchina: `Documenti\Encelado\data\ledger\decisions.jsonl` ha solo il 16/9. Su quale macchina o profilo ha girato il bot? Puoi copiare qui `decisions.jsonl`, `baskets_state.json` e `learning_state.json` di quella sessione? | procedere con le evidenze dell'API | **Risposta dell'utente (2026-09-23)**: allegato il bundle della cartella dati dell'installazione 4.0.0. Letto: 1 813 righe dal 16/9 15:55 al 22/9 08:01, 65 `segnale_ingresso` e 65 `rifiuto` con motivazione «esito non ancora noto», preset AGGRESSIVE impostato dalla finestra, quattro run; `baskets_state.json` con `killSwitched = true` («kill-switch dalla finestra») e picco 149 552. L'equity registrata sale da 109 228 a 149 517 USD in circa ventuno scatti di ≈ +2 000 USD, uno per ogni esecuzione ridotta dal server: gli «accrediti» erano crediti virtuali del demo per ordine, non depositi. Post-mortem aggiornato. |
|
||||
| D-37 | Confermi che la chiusura in blocco del 21/9 alle 13:26 UTC l'hai fatta tu dalla piattaforma? | sì | **Risposta dell'utente (2026-09-23)**: «Sì, l'ho fatta io». Chiuso. |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Regole di sicurezza e approvazione
|
||||
|
||||
Tutte le regole di §10 della specifica, con il valore di fabbrica, dove sta e chi può cambiarlo. "Operatore" è chi modifica i file in `Documenti\Encelado` o usa la finestra; "codice" vuol dire che non esiste una chiave di configurazione.
|
||||
|
||||
| Regola | Default | Dove | Chi la cambia |
|
||||
|---|---|---|---|
|
||||
| Modalità di esecuzione | `Demo` | `encelado.json` → `run.executionMode` (`Paper`, `Demo`, `Live`) | operatore; `Live` richiede `run.allowLive = true` **e** la frase `CONFERMO LIVE` scritta all'avvio (o `--confirm-live "CONFERMO LIVE"` in headless) |
|
||||
| Approvazione dei singoli ordini | nessuna, in nessuna modalità (D-20, ADR-0005) | codice | nessuno. Il bot apre, aggiunge e chiude da solo; i gate umani sono l'avvio del reale, il kill-switch, il reset dopo un equity stop e il cambio di preset |
|
||||
| Equity stop | 9 % dal picco di equity | `strategy.json` → `equityStopPct` | operatore; scatta → chiude tutto, blocca, richiede reset con motivazione scritta (finestra o `reset <motivo>` in headless), che finisce nel ledger; il picco riparte dall'equity del reset |
|
||||
| Perdita giornaliera massima | 3 % dell'equity di inizio giornata (UTC) | `strategy.json` → `dailyLossPct` | operatore; blocca le nuove entrate fino al giorno dopo, non chiude |
|
||||
| Rischio per basket | 0,25 / 0,50 / 1,00 % (preset) | `strategy.json` → preset o `riskPerBasketPct` | operatore; il cambio di preset a caldo non tocca i basket aperti |
|
||||
| Perdita massima per basket | 1,5 % dell'equity all'ingresso | `strategy.json` → `maxLossPerBasketPct` | operatore; mai disattivabile |
|
||||
| Stop di basket su z | 3,0 / 3,5 / 4,0 (preset) | `strategy.json` → preset o `zStop` | operatore; mai disattivabile (solo il backtest lo spegne, nel test di falsificazione 3) |
|
||||
| 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 |
|
||||
| Spread anomalo | > 3 × mediana per 3 barre chiuse consecutive → chiusura forzata | `strategy.json` → `spreadAnomalyMultiple` (persistenza: codice) | operatore (moltiplicatore) |
|
||||
| Blackout eventi | 45 min prima, 30 dopo, eventi High sulle valute del basket | `strategy.json` → `blackoutBeforeMin`, `blackoutAfterMin` | operatore |
|
||||
| Fine settimana | niente entrate dal venerdì 20:00 UTC alla riapertura, né nei primi 30 min | `strategy.json` → `fridayCutoffUtcHour`, `openDelayMinutes` | operatore |
|
||||
| Scarto orologio | > 5 s → banner e niente nuove entrate | `strategy.json` → `clockSkewMaxSeconds` | operatore; misurato sull'header `Date` di ogni risposta |
|
||||
| API in errore | 5 letture consecutive fallite → niente nuove entrate finché non risponde | codice | nessuno |
|
||||
| Quotazione vecchia | > 15 s → niente nuove entrate | codice (`BasketEngine.MaxQuoteAgeSeconds`) | nessuno |
|
||||
| Qualità dati | buco > 2 h feriale o salto > 8 σ → decisioni sospese su quella barra | codice | nessuno |
|
||||
| Leg-risk | seconda gamba **rifiutata** → chiudi subito la prima (`leg_risk_unwind`), basket in pausa 1 h. Seconda gamba **senza esito** entro `legTimeoutSec` (5 s) → basket in `PendingB`: il registro degli ordini continua a chiedere; eseguita → basket aperto; rifiutata → prima gamba richiusa | `strategy.json` → `legTimeoutSec` (pausa: codice) | operatore (timeout) |
|
||||
| Ordine dall'esito ignoto | mai abbandonato: registrato in `data/state/pending_orders.json` **prima** dell'invio; esito chiesto per `orderId` (ogni 2 s nel primo minuto, poi ogni 10 s, poi ogni minuto) e riconosciuto anche dalla posizione comparsa sul conto (stesso strumento e verso, entro 90 s); una gamba A senza esito porta il basket in `PendingA` (nessun nuovo ordine su quel basket); all'avvio i pendenti si risolvono prima di qualsiasi decisione | codice (ADR-0009) | nessuno |
|
||||
| Gamba A eseguita in ritardo | segnale ancora valido e nessun blocco → gamba B (ridimensionata sulle unità eseguite di A); altrimenti chiusura immediata di A (`leg_risk_unwind`) | codice | nessuno |
|
||||
| Gamba orfana del bot | una gamba di un basket sparisce dal conto → l'altra viene chiusa alla riconciliazione successiva. Una posizione che porta la firma del bot (id nel registro, oppure strumento + verso + orario entro 90 s da una riga `segnale_ingresso`/`rifiuto`/`ingresso`/`pending` del ledger) ma non appartiene a nessun basket è `orfana-bot`: **adottata e chiusa** (tre tentativi, poi entrate bloccate con avviso). Contatore «orfane» in dashboard, rosso se > 0 | codice; `--bonifica` all'avvio la elenca e chiede conferma per ognuna | operatore (bonifica) |
|
||||
| Movimenti di cassa | un salto del saldo non spiegato dalle chiusure (oltre 10 USD e 0,25 %) è un deposito o un prelievo: scritto nel ledger come `movimento_di_cassa`, escluso dal P&L, dal picco di equity e dal drawdown | codice (`EquityTracker`) | nessuno |
|
||||
| Posizioni non riconciliate | se il P&L aperto del conto e la somma delle posizioni non tornano (oltre 5 USD e 1 %) per più di 60 s, o un basket ha una gamba che il conto non mostra: avviso «posizioni non riconciliate» (banner giallo, riga di stato) | codice | nessuno |
|
||||
| Chiusura incompleta | una gamba non chiude dopo 3 tentativi → stato `Error`, entrate bloccate, allarme | codice | nessuno; si risolve a mano sul conto e con la riconciliazione |
|
||||
| Kill-switch | pulsante con conferma, `kill` da console, file `STOP` in `Documenti\Encelado` (controllato ogni 5 s): blocco delle entrate, annullamento degli ordini senza esito (attesa ≤ 30 s), chiusura di **tutte le posizioni del bot** (basket, gambe in attesa, orfane; tre tentativi per gamba), esterne solo se chiesto o `risk.closeForeignOnKill`, **verifica di piattezza** sul conto (≤ 120 s); ciò che resta è `Halted-Residuo` con l'elenco, e nessuna dichiarazione di «tutto chiuso» senza la verifica | codice (ADR-0009, §9 del piano 5.0) | operatore |
|
||||
| Recupero dopo inattività | oltre `recovery.thresholdMinutes` (10) senza heartbeat o senza cicli: entrate bloccate, ordini pendenti risolti, riconciliazione, riscaldamento con le barre perse, ogni basket aperto rivalutato con le regole di uscita ordinarie (con le barre di inattività nel time-stop) → chiudi o tieni; orfane chiuse, esterne riportate; rapporto `reports/recupero_<run_id>.csv`; entrate riaperte dopo `recovery.warmupMinutes` (15); a mercato chiuso niente di irreversibile | `strategy.json` → `recovery` | operatore |
|
||||
| Una sola istanza | `data/state/instance.lock` tenuto in esclusiva: un secondo bot sulla stessa cartella dati non parte | codice | nessuno |
|
||||
| Ripristino | procedura in cinque passi: stato, rimozione del file `STOP`, motivazione ≥ 10 caratteri (`correzione`), riconciliazione + riscaldamento + picco di equity al netto dei movimenti di cassa, ripartenza con entrate bloccate per `recovery.warmupMinutes`; rifiutato (`reset_rifiutato`) finché una posizione del bot resta sul conto | codice | operatore |
|
||||
| Posizioni esterne | posizioni senza la firma del bot: segnalate una volta nel log, contate in dashboard, **mai toccate** (dalla Fase 3: chiuse dal kill-switch solo con `risk.closeForeignOnKill` o con la spunta esplicita) | codice | operatore |
|
||||
| Chiavi API | solo `%LOCALAPPDATA%\Encelado\etoro.dat` (DPAPI) o `ETORO_API_KEY`/`ETORO_USER_KEY`; mai nel repo (`.gitignore`: `*.local.json`, `.env`) | codice | operatore |
|
||||
| Ambiente visibile | badge `PAPER/DEMO/LIVE` nella barra, nel log e nel ledger (`mode`) | codice | nessuno |
|
||||
| Controlli all'avvio | chiavi (profilo), orologio, strumenti e limiti, conto, riconciliazione, calendario | codice | nessuno; se falliscono il bot resta in sola lettura o non parte |
|
||||
| Averaging | `Off` in live; `AddOnce` ammesso in paper; moltiplicatore di lotto 1,0 | `strategy.json` → `averagingMode`, `lotMultiplier` (max 1,5, solo backtest) | operatore |
|
||||
| Parametri cambiati dal bot | mai. Le proposte vanno in `knowledge/proposals.csv` e passano dal forward test. Dalla 5.0 anche il bandit **propone soltanto**: fino alla 4.0.0 applicava il preset da solo in Paper e Demo (D-30) | codice | operatore |
|
||||
@@ -0,0 +1,153 @@
|
||||
# Runbook
|
||||
|
||||
Aggiornato: 2026-09-23 (5.0). Come si avvia, si ferma, si sblocca e si ripara il bot. Dalla 5.0 il bot gira nel container (`docs/DOCKER.md`, `deploy/unraid/`): i file dell'operatore stanno in `/config` (configurazione, chiavi cifrate, file `STOP`) e `/data` (ledger, stato, log). Fuori dal container, solo per lo sviluppo, valgono `Documenti\Encelado` e le stesse regole.
|
||||
|
||||
## Prima volta
|
||||
|
||||
1. Avvia il container (template Unraid o `docker run`, vedi `docs/DOCKER.md`) con `ENCELADO_WEB_TOKEN` impostato. Al primo avvio vengono creati `encelado.json` e `strategy.json` in `/config` dalle copie di fabbrica.
|
||||
2. Apri `http://<ip>:8080/`, inserisci il token (resta in un cookie per trenta giorni).
|
||||
3. Chiavi eToro Public API (`x-api-key` e `x-user-key`, dal portale sviluppatori; demo e reale hanno chiavi diverse): o nelle variabili `ETORO_API_KEY`/`ETORO_USER_KEY` del container (hanno la precedenza), oppure da **Impostazioni ▸ Chiavi eToro** con `ENCELADO_KEY_PASSPHRASE` impostata: il server le verifica con due letture (profilo e conto) e le salva cifrate in `/config/etoro.keys.enc`.
|
||||
4. Controlla in **Impostazioni**: ambiente `demo`, modalità `Demo`, fuso orario (o `TZ` del container), valuta di visualizzazione.
|
||||
5. Con `ENCELADO_AUTOSTART=1` (default) il motore è già partito; altrimenti premi **AVVIA**.
|
||||
|
||||
Per Telegram: `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` (vedi la sezione Telegram).
|
||||
|
||||
## Sviluppo sul PC
|
||||
|
||||
Per vedere e provare il bot senza Unraid: **F5** in VS Code (`Encelado (server)`), `scripts\run-dev.ps1` o `scripts\run-docker.ps1` (il container dalla compose). Tutti usano la sandbox `deploy/local/` come `/config` e `/data`; dettagli in `docs/DOCKER.md`. Con `--sample` (o `Encelado (campione)`) la pagina mostra dati finti senza chiavi.
|
||||
|
||||
## Modalità
|
||||
|
||||
| Modalità | Ordini | Conferma all'avvio |
|
||||
|---|---|---|
|
||||
| `Paper` | simulatore locale sopra le quotazioni reali (`data/state/paper_state.json`) | nessuna |
|
||||
| `Demo` (default) | conto demo eToro, denaro virtuale | nessuna |
|
||||
| `Live` | conto reale | `run.allowLive = true` **e** la frase `CONFERMO LIVE` (dialogo di AVVIA, oppure `ENCELADO_CONFIRM_LIVE` per l'avvio automatico, oppure `--confirm-live`) |
|
||||
|
||||
In ogni modalità il bot apre e chiude da solo (decisione D-20). Il chip in basso a sinistra del rail dice sempre in che ambiente sei (`PAPER` blu, `DEMO` giallo, `LIVE` rosso).
|
||||
|
||||
## Console e comandi
|
||||
|
||||
Il processo stampa il log su stdout (`docker logs -f encelado`) e una riga di stato ogni `run.statusSeconds`. Con un terminale collegato (`docker attach`, o il processo lanciato a mano) accetta comandi da tastiera: `status`, `close <basket>`, `kill`, `residuo [id]`, `preset <nome>`, `bonifica`, `reset <motivazione>`, `stop`. Gli stessi comandi esistono nell'interfaccia (pulsanti e dialoghi) e in Telegram. Argomenti: `--no-autostart`, `--minutes N` (arresto automatico), `--bonifica`, `--confirm-live "CONFERMO LIVE"`, `--port N`, `--sample`, `--health`.
|
||||
|
||||
**Una sola istanza per cartella dati**, imposta dal lock `data/state/instance.lock`: un secondo processo sulla stessa cartella viene rifiutato con il pid del primo.
|
||||
|
||||
## Fermare
|
||||
|
||||
- **FERMA** nell'interfaccia, `stop` da console, `docker stop` (SIGTERM, 45 s di grazia), Ctrl+C. I basket aperti **restano sul conto** con gli stop nativi (`run.closeOnShutdown = false`): nessuno applica TP e stop di basket finché il bot non riparte, che li riprende dallo stato salvato e dalla riconciliazione (e, se è passato più di dieci minuti, con il recupero dopo inattività).
|
||||
- Con `run.closeOnShutdown = true` la fermata chiude tutto a mercato.
|
||||
|
||||
## Kill-switch
|
||||
|
||||
Tre modi: il pulsante **KILL-SWITCH** nella barra in alto (chiede conferma e, se ci sono posizioni esterne, se chiudere anche quelle: default no), `kill` da console o `/kill CONFERMO` da Telegram, oppure un file chiamato `STOP` nella cartella `/config` (controllato ogni 5 s; utile da remoto: `touch /mnt/user/appdata/encelado/config/STOP`). La procedura è la stessa per i tre e **non dichiara niente chiuso senza averlo riletto dal conto**:
|
||||
|
||||
1. blocco immediato di ogni nuova entrata, riga `kill_switch_avviato` nel ledger;
|
||||
2. annullamento degli ordini nel registro senza esito (`DELETE …/orders/{id}`), poi attesa della loro risoluzione fino a 30 s: un ordine eseguito nel frattempo diventa una posizione da chiudere;
|
||||
3. chiusura di **tutte le posizioni del bot**: gambe dei basket (tre tentativi per gamba con verifica sul conto), gambe in attesa, orfane-bot; le posizioni esterne solo se lo hai chiesto o se `risk.closeForeignOnKill = true`;
|
||||
4. verifica di piattezza: il conto viene riletto finché le posizioni del bot non sono sparite (fino a 120 s);
|
||||
5. riga `kill_switch_concluso` con l'esito, stato salvato, notifica.
|
||||
|
||||
Se qualcosa resta sul conto lo stato è **`Halted-Residuo`**: banner rosso con l'elenco, chip «bloccato · residuo», il reset è rifiutato finché non è piatto. Per riprovare: **Sblocca…** nel banner (il ripristino offre «Chiudi ora» sui residui), `residuo` da console (`residuo <id>` per una sola posizione), oppure chiusura a mano su eToro; la riconciliazione se ne accorge. Il blocco resta finché non fai un **reset**.
|
||||
|
||||
## Equity stop
|
||||
|
||||
Quando l'equity (al netto dei movimenti di cassa) scende del 9 % dal picco (`equityStopPct`) il bot chiude tutto e si blocca: banner rosso nella dashboard, riga `equity_stop` nel ledger. Per ripartire serve il reset. La perdita giornaliera del 3 % (`dailyLossPct`) blocca solo le nuove entrate fino alla mezzanotte UTC e non richiede reset.
|
||||
|
||||
## Ripristino (reset) in cinque passi
|
||||
|
||||
**Sblocca…** nel banner o **Avvia il ripristino…** in Impostazioni, `reset <motivazione>` da console, `/reset <motivazione>` da Telegram. Il reset è una procedura, non un interruttore; il dialogo mostra i cinque passi e l'esito di ciascuno, che finisce anche nel ledger:
|
||||
|
||||
| Passo | Che cosa fa | Se fallisce |
|
||||
|---|---|---|
|
||||
| 1. stato | riporta il motivo del blocco, i residui, gli ordini senza esito, le entrate bloccate | — |
|
||||
| 2. file STOP | lo rimuove da solo; se ricompare il kill-switch riparte | «file STOP non rimovibile»: permessi della cartella `/config` (PUID/PGID) |
|
||||
| 3. motivazione | almeno dieci caratteri, scritta nel ledger come `correzione` | «motivazione mancante»: riscrivila |
|
||||
| 4. riconciliazione | rilegge il conto, verifica che nessuna posizione del bot sia rimasta, riscalda le serie dall'API, riporta il picco di equity all'equity corrente (al netto dei movimenti di cassa) | «residui ancora sul conto» → resta `Halted-Residuo` (riga `reset_rifiutato`): chiudili con «Chiudi ora» / `residuo` / a mano e ripeti |
|
||||
| 5. ripartenza | toglie il blocco; **entrate bloccate per 15 minuti** di riscaldamento (`riscaldamento dopo il reset` nel banner giallo), poi il bot riprende da solo | — |
|
||||
|
||||
Sintomo → passo:
|
||||
|
||||
| Sintomo | Passo da guardare |
|
||||
|---|---|
|
||||
| banner «KILL-SWITCH CON RESIDUO» | 4: chiudi i residui, poi ripeti il reset |
|
||||
| «rimuovi prima il file STOP» / «file STOP non rimovibile» | 2: `/config` non è scrivibile dall'utente del container o il file viene ricreato da un altro processo |
|
||||
| «motivazione mancante» | 3 |
|
||||
| dopo il reset il bot non apre per un quarto d'ora | 5: è il riscaldamento voluto |
|
||||
| dopo il reset compare «posizioni non riconciliate» | 4 non è andato a buon fine sul conto: confronta `pending_orders.json` e le posizioni su eToro (pagina Storico ▸ Posizioni) |
|
||||
|
||||
## Ordini senza esito
|
||||
|
||||
eToro lavora gli ordini in modo asincrono e a volte non risponde al lookup. Il bot non dimentica mai un ordine: ogni invio è scritto in `data/state/pending_orders.json` **prima** della chiamata, l'esito viene chiesto per `orderId` e, se il server non lo trova, ricostruito dalla posizione comparsa sul conto. Un basket con una gamba senza esito compare in dashboard come «attesa gamba A/B» e nel contatore «in attesa»: non manda altri ordini finché il registro non lo risolve. Alla risoluzione: gamba A eseguita e segnale ancora valido → parte la gamba B; segnale decaduto → la gamba A viene richiusa subito (`leg_risk_unwind`); rifiutata → il basket torna libero. Se un ordine resta senza esito per più di dieci minuti il log lo ripete ogni dieci minuti: guarda Storico ▸ Ordini (esito `Pending`) e, se serve, la posizione su eToro; non c'è niente da fare a mano finché la gamba non compare sul conto, e quando compare il bot la gestisce. All'avvio i pendenti del run precedente vengono risolti prima di qualsiasi decisione.
|
||||
|
||||
## Riconciliazione
|
||||
|
||||
Ogni 20 secondi il bot rilegge conto e posizioni e **classifica ogni posizione**: `basket` (gamba nota), `orfana-bot` (aperta dal bot ma senza basket: id nel registro degli ordini, oppure strumento, verso e orario coerenti con una decisione del ledger entro 90 s), `esterna` (tutto il resto). Una gamba di basket sparita dal conto (chiusa a mano, stop nativo) fa chiudere l'altra; un'orfana-bot viene **adottata e chiusa** (riga `orfana_adottata` e `orfana_chiusa` nel ledger, riga in `baskets.csv` con `exit_reason = orphan_closed`; dopo tre tentativi falliti le entrate si bloccano e il banner lo dice: chiudila a mano su eToro); un'esterna viene segnalata una volta, contata e mai toccata. Una chiusura incompleta dopo tre tentativi mette il basket in stato `Error` e blocca le nuove entrate finché non è risolta sul conto. Se il P&L aperto del conto e quello delle posizioni non tornano per più di un minuto compare «posizioni non riconciliate»: di solito è un'esecuzione in corso; se persiste, confronta `pending_orders.json` con le posizioni su eToro.
|
||||
|
||||
Un deposito o un prelievo sul conto (anche l'accredito di fondi virtuali del demo) viene riconosciuto dal salto del saldo non spiegato dalle chiusure e scritto nel ledger come `movimento_di_cassa`: non è P&L, non muove il picco di equity né il drawdown; nello Storico compare come riga «movimento di cassa» e nei periodi è una colonna a parte.
|
||||
|
||||
## Recupero dopo inattività (riavvio, aggiornamento dell'immagine, container fermo)
|
||||
|
||||
Il bot scrive `data/state/heartbeat.json` ogni 30 s (`recovery.heartbeatSeconds`). All'avvio legge quello precedente e a ogni ciclo misura il tempo dall'ultimo giro: oltre `recovery.thresholdMinutes` (10) parte il **recupero**, nell'ordine: entrate bloccate e riga `recupero_avviato`; ordini senza esito risolti; riconciliazione con classificazione (le orfane si chiudono, le esterne si riportano); serie riscaldate dall'API con le barre perse e `barsHeld` di ogni basket aperto aumentato delle barre trascorse; ogni basket aperto rivalutato come a una chiusura di barra ordinaria (stop di z, perdita massima, time-stop con le barre di inattività, correlazione rotta, spread anomalo): **chiudi** o **tieni**; rapporto `reports/recupero_<run_id>.csv` (una riga per posizione: `posizione;basket;decisione;motivo;z;rho;barsHeld;pnl;motivazione`), riga `recupero_concluso`, notifica; entrate riaperte dopo `recovery.warmupMinutes` (15) di quotazioni. Se il mercato è chiuso (fine settimana) il recupero aspetta le prime quotazioni fresche e lo dice ogni dieci minuti: niente di irreversibile a mercato chiuso. Un recupero fallito lascia le entrate bloccate con il motivo: guarda il log e fai un reset.
|
||||
|
||||
## Telegram
|
||||
|
||||
Configurazione in `encelado.json` → `notifications.telegram` (`enabled`, `chatId`, `hourlyStatus`, `eventAlerts`, `dailySummaryUtcHour`, `commands`) e in Impostazioni → Notifiche. Il token del bot va **solo** nella variabile d'ambiente `TELEGRAM_BOT_TOKEN` (la chat in `TELEGRAM_CHAT_ID`, che ha la precedenza sul file). Per creare il bot: `@BotFather` → `/newbot` → token; per il chat id: scrivi al bot e leggi `chat.id` da `https://api.telegram.org/bot<token>/getUpdates`, oppure `@userinfobot`. Cosa arriva:
|
||||
|
||||
- **stato ogni ora** (allo scoccare dell'ora UTC): attivo/fermo/bloccato, modalità, preset, uptime, equity/saldo/disponibile/margine, P&L di oggi e aperto (conto e basket), drawdown dal picco, basket aperti con z/pip/P&L/durata, in attesa, ordini pendenti, orfane, esterne, blocchi, prossimo evento, stato API e quote, stato del canale;
|
||||
- **eventi**: avvio e arresto del bot, basket aperto e chiuso (con motivo e P&L), gamba in attesa, ordine pendente risolto, unwind, gamba orfana, kill-switch (con esito e residui), equity stop, perdita giornaliera, margin guard, recupero dopo inattività, reset, cambio preset, API in errore persistente, scarto orologio;
|
||||
- **riepilogo giornaliero** all'ora impostata (21 UTC): basket chiusi, vinti, lordo, costi, netto, drawdown, motivi di uscita.
|
||||
|
||||
Comandi accettati **solo dalla chat autorizzata** (ogni altro mittente viene ignorato e annotato nel log; ogni comando eseguito finisce nel ledger come `comando`): `/stato`, `/posizioni`, `/storico 7d` (anche `1d`, `30d`), `/pausa` (blocca le entrate, le uscite restano attive), `/riprendi`, `/chiudi <basket>`, `/kill CONFERMO` (senza la parola non fa niente), `/reset <motivazione>` (almeno dieci caratteri); tutto il resto risponde «comando non riconosciuto». Il polling usa un solo task con `getUpdates` e non tocca le quote di eToro; gli invii sono al massimo uno al secondo con tre tentativi (2, 5, 15 s; su 429 rispetta `retry_after`). La card «Telegram» della dashboard e la riga di stato del log riportano lo stato del canale (ultimo invio, coda, falliti, ultimo errore).
|
||||
|
||||
## Bonifica delle gambe orfane
|
||||
|
||||
Una tantum, dopo un'anomalia: **Impostazioni ▸ Diagnostica ▸ Bonifica: elenca le orfane** mostra ogni posizione del conto con origine, P&L e motivo della classificazione e offre **Chiudi** su ciascuna orfana-bot; oppure `--bonifica` all'avvio (il motore parte senza chiudere le orfane da solo e chiede `chiudere? [s/N]` da console) o `bonifica` da console. Ogni chiusura confermata scrive una riga in `baskets.csv` (`exit_reason = bonifica_orfana`, P&L dallo storico) e in `reports/bonifica_YYYYMMDD.csv`. Alla fine il bot torna a chiudere le orfane da solo. Al 2026-09-23 il conto demo è piatto: non c'è niente da bonificare.
|
||||
|
||||
## Errori API
|
||||
|
||||
| Sintomo | Cosa fa il bot | Cosa fare |
|
||||
|---|---|---|
|
||||
| 401/403 | avvio fallito, "eToro ha rifiutato le chiavi" | rigenera le chiavi sul portale, reinseriscile da Impostazioni ▸ Chiavi eToro o nelle variabili del container |
|
||||
| 429 | rispetta `Retry-After`, rallenta | niente; se persiste alza `run.pollSeconds` |
|
||||
| 5 letture consecutive fallite | banner "entrate bloccate", uscite attive | aspetta; controlla rete e stato di eToro |
|
||||
| scarto orologio > 5 s | banner, entrate bloccate | sincronizza l'ora dell'host (il container eredita l'orologio) |
|
||||
| quotazioni più vecchie di 15 s | niente nuove entrate | come sopra |
|
||||
| `/api/health` rosso (`docker inspect`) | il container viene riavviato da `--restart` se il processo muore; un healthcheck rosso con processo vivo dice cosa manca (heartbeat vecchio, disco non scrivibile) | `docker logs`, poi `docker restart` |
|
||||
|
||||
## Feed
|
||||
|
||||
Calendario e notizie sono in cache su disco (`data/cache`) e vengono riletti ogni 10 minuti con attese crescenti dopo un errore. Un feed che non risponde non ferma il bot: senza calendario non c'è blackout, senza notizie il sentiment è 0, e il log lo dice. Google News blocca via `robots.txt` le ricerche RSS: quelle fonti vivono solo di cache (vedi `docs/DATA_SOURCES.md`).
|
||||
|
||||
## Storico
|
||||
|
||||
**Storico ordini** nell'interfaccia: *Ordini* da `orders.jsonl` (unità richieste ed eseguite — se differiscono il server ha ridotto l'ordine —, prezzi, slippage, stato, esito, id di ordine e posizione), *Posizioni* dallo storico eToro classificate per origine, *Profitti per periodo* con la curva dell'equity realizzata. Ogni vista si esporta in CSV (`;`, colonna `motivazione`). Senza chiavi la pagina mostra solo i dati del ledger e lo dice.
|
||||
|
||||
## File utili
|
||||
|
||||
| Cosa | Dove (container) |
|
||||
|---|---|
|
||||
| log | `/data/logs/encelado.log` (CSV `;`) e `docker logs` |
|
||||
| ledger | `/data/data/ledger/decisions.jsonl`, `baskets.csv`, `orders.jsonl` |
|
||||
| stato | `/data/data/state/baskets_state.json` (ripreso all'avvio), `pending_orders.json` (registro degli ordini), `heartbeat.json`, `instance.lock` |
|
||||
| barre | `/data/data/market/candles_<SYMBOL>_M15.csv` |
|
||||
| modelli | `/data/data/models/` |
|
||||
| conoscenza | `/data/knowledge/` |
|
||||
| rapporti | `/data/reports/` (qualità dati, bonifica, recupero) |
|
||||
| configurazione, strumenti, chiavi, STOP | `/config/encelado.json`, `strategy.json`, `instruments.json`, `etoro.keys.enc`, `STOP` |
|
||||
|
||||
Fuori dal container gli stessi percorsi stanno sotto `Documenti\Encelado` (`data\…`, `knowledge\…`, `logs\…`).
|
||||
|
||||
## Checklist prima del Live (§9.4 della specifica)
|
||||
|
||||
Tutte vere, altrimenti no:
|
||||
|
||||
- [ ] il forward test in Demo ha almeno 60 basket chiusi e 90 giorni;
|
||||
- [ ] il P&L netto del forward test è positivo con PSR ≥ 0,95 sulla metrica pre-registrata in `knowledge/preregistrazione.csv`;
|
||||
- [ ] nessun test di falsificazione contraddice il risultato (oggi `reports/falsificazione.csv` dice il contrario: vedi `docs/STRATEGY.md`);
|
||||
- [ ] `run.allowLive = true`, ambiente `real`, chiavi del reale inserite e verificate;
|
||||
- [ ] conto reale capiente rispetto a `riskPerBasketPct` e all'esposizione minima di 1000 USD per gamba (con 224,90 USD non lo è);
|
||||
- [ ] la frase `CONFERMO LIVE` scritta all'avvio (dialogo o `ENCELADO_CONFIRM_LIVE`).
|
||||
|
||||
## Aggiornare
|
||||
|
||||
`docker pull` dell'immagine nuova e riavvio del container (su Unraid: *Controlla aggiornamenti*). `/config` e `/data` restano; il riavvio è un'inattività come le altre e passa dal recupero. Se dopo un aggiornamento il log segnala una configurazione di una versione precedente, da Impostazioni → **Ripristina i valori predefiniti** (backup automatico con la data accanto al file) e poi rimetti i valori tuoi.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Stato del lavoro
|
||||
|
||||
Aggiornato: 2026-09-23 (sessione 5.0, Fasi 6-9 concluse: il piano 5.0 è completo).
|
||||
|
||||
## Fase in corso
|
||||
|
||||
**Piano 5.0 concluso** (`docs/PIANO_5.0.md`): tutte le fasi 0-9 sono fatte. Il bot è `Encelado.Server` (Kestrel + motore + interfaccia web incorporata) e gira nel container (ADR-0007, ADR-0008); la finestra WPF non esiste più (D-28). Versione di sviluppo 5.0.0 in `Directory.Build.props`; **il rilascio 5.0.0 non è ancora stato fatto** (tag, immagine sul registro di Gitea, release): lo decide l'utente con `dotnet msbuild build/Release.proj -t:Rilascia -p:Versione=5.0.0` dopo il push del ramo.
|
||||
|
||||
## Fatto nell'ultima sessione (2026-09-23, Fasi 6-9)
|
||||
|
||||
- **Fase 6 — Engine + Server**: `src/Encelado.Bot` → `src/Encelado.Engine` (libreria, senza UI); `src/Encelado.Server` (Sdk.Web, nessun NuGet) con `WebHost` (token in cookie HttpOnly o Bearer, senza token solo localhost), API JSON scritta a mano, SSE `/api/stream` a uno snapshot al secondo, `LogBuffer`, `HistoryService`; chiavi da variabili o file cifrato `etoro.keys.enc` (AES-256-GCM, `ENCELADO_KEY_PASSPHRASE`) al posto di DPAPI; `AppPaths` con `/config` e `/data`; `HeadlessRunner` ridotto a console (stato, comandi, bonifica); `--sample`, `--health`, `--no-autostart`, `ENCELADO_AUTOSTART`, `ENCELADO_CONFIRM_LIVE`.
|
||||
- **Fase 7 — interfaccia web Material 3** (`Web/wwwroot`, vanilla): navigation rail 80/256 px (drawer sotto 600 px), barra con orologi UTC e fuso, selettore della valuta (USD, EUR, GBP, CHF, JPY, AUD, CAD, NZD con tasso dalle quotazioni e tooltip in USD), AVVIA/FERMA, KILL-SWITCH con «chiudi anche le esterne»; dashboard con sei KPI (incluso il margine), tabella dei basket (cards da telefono), card evento/eToro/Telegram, attività, banner; **Storico ordini** (ordini, posizioni classificate, profitti per periodo con curva SVG, CSV); Log con filtri; Impostazioni con configurazione validata, chiavi eToro, ripristino in cinque passi, Ricerca, Diagnostica (con bonifica), Informazioni (versione, build, commit da `AssemblyMetadata`); temi scuro/chiaro. `HistoryBuilder` nel Core; `SnapshotJson`, `SampleSnapshot`, `SettingsService` nell'Engine; comando `learn` nello strumento (`Learn.cs`); `ui.displayCurrency/theme/navExpanded` e coppie di conversione nel polling.
|
||||
- **Fase 8 — Docker e Unraid**: `Dockerfile` multi-stage (build, **test**, publish, runtime `aspnet:10.0` + gosu + tzdata), `deploy/docker/entrypoint.sh` (TZ, PUID/PGID), `docker-compose.yml`, `.dockerignore`, `HEALTHCHECK --health`; `deploy/unraid/encelado.xml` (Container v2, icona `assets/encelado.png` raw da Gitea, D-29) e README; `build/Release.proj` senza Inno Setup: `Pubblica` framework-dependent, `Docker`, `Pacchetto` (zip + template + tag), `Rilascia` (push dell'immagine sul registro di Gitea + release); VS Code con F5 sul server (`Encelado (server)`, `Encelado (campione)`, backtest) e attività Docker. Immagine `192.168.30.23:3000/alby96/encelado:5.0.0` (346 MB) costruita dalla catena (`-t:Docker -p:Versione=5.0.0`, senza tag) con 210 test verdi nello stadio Linux e provata: avvio, semina di `/config`, permessi 99:100, `/api/health`, token, sonda `--health`, nessun avviso di Kestrel o di `useradd`. Il primo arresto con `docker stop` **non** era pulito (SIGKILL dopo la grazia): corretto con `PosixSignalRegistration` per SIGTERM in `Program.cs` e **verificato**: `docker stop` chiude in 1 s con «SIGTERM: arresto» ed exit 0.
|
||||
- **Avvio in locale (richiesta dell'utente)**: sandbox `deploy/local/` (`config/` = `/config`, `data/` = `/data`, ignorata da git) usata da F5 (`Encelado (server)`, `ENCELADO_CONFIG_DIR` + `ENCELADO_DATA_DIR`, che ora vale anche fuori dal container), dagli script `scripts/run-dev.ps1|.sh`, `run-docker.ps1|.sh` (compose, token `sviluppo`), `screenshots.ps1|.sh` e dalle attività di VS Code (`avvia in locale (script)`, `avvia in docker`, `ferma docker`, `screenshot`); provati tutti e tre i modi e lo script degli screenshot. `/api/health` in `--sample` non finge più un motore acceso.
|
||||
- **Fase 9 — documenti e pulizia**: ADR-0007, ADR-0008, `docs/DOCKER.md`, `docs/UI_GUIDELINES.md`, `README.md` con gli screenshot (`docs/img/`), `CLAUDE.md`, ARCHITECTURE, RUNBOOK, KNOWN_ISSUES, GLOSSARY, DATA_SOURCES, ML_AND_LEARNING, QUESTIONS (D-28, D-29, D-36, D-37 risposte), POSTMORTEM (gli «accrediti» erano crediti demo per ogni ordine ridotto), skill aggiornate, `build/README.md`. **Codice morto rimosso**: progetto WPF, DPAPI, test WPF, `UiRenderTests`, ~45 membri mai usati (statistiche di ricerca, `Psi`, `EmptyContextProvider`, `EnvironmentKeyStore`, display helper dello snapshot, `INotifyPropertyChanged` in `SettingField`, …). Test nuovi: `EmbeddedUiTests`, `WebHostTests`, `HistoryBuilderTests`, `KeyStoreTests`; suite a **210 verdi**.
|
||||
|
||||
## Prossimi passi
|
||||
|
||||
1. **Rilascio 5.0.0** (utente): push del ramo, `-t:Rilascia -p:Versione=5.0.0` (richiede Docker acceso, `build/gitea.json` con permesso `package`, registro `192.168.30.23:3000` dichiarato `insecure-registry`), poi installazione su Unraid dal template.
|
||||
2. Riaccendere il Demo nel container per 24 ore di verifica: contatore «orfane» a 0, `orders.jsonl` senza `Unknown` irrisolti, `sizing_bound = margin` sui primi ingressi; kill-switch a mano con verifica di piattezza; fermare il container un'ora con un basket aperto per vedere il recupero; messaggi Telegram reali.
|
||||
3. Revisione visiva dell'interfaccia con l'utente (gli screenshot sono dal campione `--sample`); eventuali ritocchi ai testi e ai tooltip.
|
||||
4. Avviso Telegram sui feed fermi da più di un'ora (resta in KNOWN_ISSUES).
|
||||
5. Rifare il backtest (`backtest baskets`) con i limiti di margine e aggiornare §3 di `docs/STRATEGY.md`.
|
||||
|
||||
## Problemi aperti
|
||||
|
||||
- Il disco C: del PC di sviluppo si era riempito (0 byte) a metà sessione; ora ha 12 GB liberi. Le immagini e la cache di build di Docker pesano: `docker system prune` ogni tanto.
|
||||
- Backtest negativo: la strategia non regge i costi (`docs/STRATEGY.md`).
|
||||
- Il conto reale vale 224,90 USD: il Live non è praticabile.
|
||||
- Google News blocca le ricerche RSS; RBA 403 a intermittenza; Fed 404 a tratti.
|
||||
- Il server non ha TLS e il registro di Gitea è in HTTP (rete locale).
|
||||
- `Modifiche.txt` nella radice è un appunto dell'utente, modificato e non committato: decidere se tenerlo nel repository.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Strategia: Correlation Baskets
|
||||
|
||||
Aggiornato: 2026-09-16. Questo documento dice come funziona la strategia e, con i numeri, **se regge**. La risposta sui dati disponibili è **no**: nessuna configurazione è profittevole al netto dei costi di eToro. Il modulo resta uno strumento di forward test in Demo; non c'è nessun risultato che giustifichi il reale.
|
||||
|
||||
## 1. Logica
|
||||
|
||||
Cinque basket di due coppie forex con una valuta in comune:
|
||||
|
||||
| Basket | Comune | Cross sintetico | Gambe |
|
||||
|---|---|---|---|
|
||||
| EURUSD / USDCHF | USD | EURCHF | stesso verso |
|
||||
| AUDUSD / USDCAD | USD | AUDCAD | stesso verso |
|
||||
| NZDUSD / EURNZD | NZD | EURUSD | stesso verso |
|
||||
| USDCAD / EURUSD | USD | EURCAD | stesso verso |
|
||||
| EURAUD / AUDCAD | AUD | EURCAD | stesso verso |
|
||||
|
||||
In tutti e cinque la valuta comune ha ruoli opposti nelle due coppie, quindi `X = ln A + ln B` è il logaritmo del cross e le due gambe si comprano (o si vendono) insieme; la correlazione attesa dei rendimenti è negativa.
|
||||
|
||||
**Segnale** (`ZScoreSynthetic`): `z = (X − media_W) / σ_W` su W = 100 barre M15. Ingresso quando `|z| ≥ z_in` (2,0 nel preset Moderate), venduto il cross se z > 0, comprato se z < 0. Modalità alternativa `PipDivergence`: divergenza in pip fra le due gambe dall'ultimo punto di allineamento.
|
||||
|
||||
**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. **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.
|
||||
|
||||
**Averaging** (§5.5): spento di fabbrica; `AddOnce` e `Grid` esistono solo per il test di falsificazione 2.
|
||||
|
||||
I preset (`strategy.json`):
|
||||
|
||||
| Preset | z_in | rischio/basket | basket max | TP pip | aggiunte | z_stop |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Conservative | 2,5 | 0,25 % | 2 | 8 | 0 | 3,0 |
|
||||
| Moderate | 2,0 | 0,50 % | 3 | 10 | 1 | 3,5 |
|
||||
| Aggressive | 1,5 | 1,00 % | 5 | 12 | 2 | 4,0 |
|
||||
|
||||
## 2. Dati e costi del backtest
|
||||
|
||||
- Tick MetaTrader 5 (UTC) dal 2018-12-12 al 2026-09-15, convertiti in barre M15 bid/ask (`backtest ticks`); ~192 000 barre per coppia, EURAUD solo 39 700 (parti del 2018, 2021, 2026).
|
||||
- Decisione alla chiusura della barra, esecuzione all'apertura della successiva sul lato giusto del book più 0,3 pip di slippage per gamba.
|
||||
- Due scenari di costo, entrambi assunzioni:
|
||||
- **etoro**: spread minimo per coppia = spread tipico pubblicato da eToro (EURUSD 1,0, USDCHF 1,5, AUDUSD 1,0, USDCAD 1,5, NZDUSD 2,5, EURNZD 5,0, EURAUD 3,0, AUDCAD 3,0 pip), overnight 0,3 pip/gamba/giorno;
|
||||
- **api**: spread dei tick senza pavimento (0,1-0,7 pip, come mostra l'API demo), overnight **0,9 pip/gamba/giorno** (0,91 USD/giorno per 10 000 EURUSD letti dall'endpoint dei costi il 2026-09-16).
|
||||
- Capitale iniziale 10 000 USD; equity stop al 9 % con ripartenza del picco (D-18), contando gli stop.
|
||||
- Niente calendario né notizie nel backtest: blackout e sentiment agiscono solo dal vivo.
|
||||
|
||||
## 3. Risultati
|
||||
|
||||
### 3.1 Baseline (strategy.json di fabbrica), costi etoro
|
||||
|
||||
| Preset | Basket | Win rate | Netto | Sharpe | Max DD | Costo medio | Break-even | Equity stop |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Conservative | 0 | — | 0 | — | — | — | — | 0 |
|
||||
| Moderate | 2 224 | 50 % | **−9 608 USD** | −3,38 | 96 % | 3,1 pip | −0,4 pip | 37 |
|
||||
| Aggressive | 2 219 | 53 % | **−9 802 USD** | −3,61 | 98 % | 3,4 pip | −0,2 pip | — |
|
||||
|
||||
Il Conservative non apre mai: con TP 8 pip il cost gate a 3× non passa mai (3 × 3,1 > 8). Il Moderate perde quasi tutto il capitale in 7,75 anni: 2 224 basket × ~4 USD di costo = il conto. Il break-even (il costo per basket che azzererebbe il P&L medio) è **−0,4 pip**: il segnale non produce nemmeno un pip lordo per basket.
|
||||
|
||||
### 3.2 Griglia (§9.1): 57 configurazioni, costi etoro
|
||||
|
||||
3 preset × W ∈ {60, 100, 150} × ρ_min ∈ {−0,5, −0,6, −0,7} × z_out ∈ {0,25, 0,5}, più le tre baseline. Risultato in `results/trials.csv` e `results/riepilogo_baskets.csv`:
|
||||
|
||||
- **nessuna configurazione con P&L netto positivo**;
|
||||
- la "migliore" per Sharpe è quella che non apre nulla (Sharpe 0);
|
||||
- PBO (CSCV, 16 blocchi) = 0,000 solo perché la selezione in-sample sceglie sempre la configurazione vuota: un numero degenere, non una prova di robustezza;
|
||||
- walk-forward "scegli il migliore degli ultimi 6 mesi, applicalo un mese" su 88 mesi: Sharpe −0,75, drawdown 13 %, PSR 0,005;
|
||||
- DSR di ogni prova: 0.
|
||||
|
||||
Motivi di non ingresso, in ordine: `no_signal`, `cost_gate`, `rho_low`, `half_life`. Il cancello ρ ≤ −0,6 è raro sulle barre M15: dal vivo il 2026-09-16 ρ_W è rimasta fra −0,13 e −0,42 per tutta la sessione.
|
||||
|
||||
### 3.3 Falsificazione (§9.2), entrambi gli scenari
|
||||
|
||||
`reports/falsificazione.csv` (etoro) e `reports/falsificazione_costi_api.csv` (api), preset Moderate:
|
||||
|
||||
| Test | Variante | Basket (etoro / api) | Win rate | Netto etoro | Netto api | Break-even etoro / api |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 segnale | ZScoreSynthetic | 2 224 / 2 076 | 50 % / 48 % | −9 608 | −9 802 | −0,4 / −0,5 pip |
|
||||
| 1 segnale | PipDivergence | 3 124 / 2 877 | 42 % / 43 % | −9 406 | −9 784 | −0,6 / −0,4 pip |
|
||||
| 2 averaging | AddOnce ×1,0 | 2 123 / 1 821 | 51 % / 48 % | −9 689 | −9 800 | +0,3 / +0,4 pip |
|
||||
| 2 averaging | AddOnce ×1,5 | 1 980 / 1 673 | 51 % / 47 % | −9 741 | −9 802 | +0,2 / +0,3 pip |
|
||||
| 3 stop | senza stop | 1 844 / 1 949 | 55 % / 54 % | −9 228 | −9 796 | −0,2 / −0,4 pip |
|
||||
| 4 cost gate | 2× | 2 135 / 2 052 | 48 % / 48 % | −9 800 | −9 800 | −0,7 / −0,6 pip |
|
||||
| 4 cost gate | 4× | 0 / 3 | — / 33 % | 0 | −81 | — / +3,8 pip |
|
||||
| 5 inverso | segnale invertito | 1 824 / 1 537 | 43 % / 39 % | −9 735 | −9 801 | **−2,5 / −2,9 pip** |
|
||||
|
||||
Letture:
|
||||
|
||||
- **Il segnale ha un contenuto, ma piccolo.** Invertirlo peggiora il break-even di circa 2-2,5 pip per basket (da −0,4 a −2,5). Quindi il verso del segnale vale ~2 pip; il costo medio di un basket è 3,1-3,2 pip. Non basta, in nessuno dei due scenari.
|
||||
- **L'averaging alza il break-even di ~0,7 pip** (compra i rientri) ma allunga la coda: il percentile 1 % delle perdite passa da −98 a −115 USD e il drawdown sale. Non cambia il segno del risultato.
|
||||
- **Senza stop** il win rate sale al 55 % e il netto migliora di 380 USD nello scenario etoro, ma la coda (1 %: −136 USD) e il drawdown restano quelli di un sistema che tiene le perdite aperte. Lo stop resta obbligatorio.
|
||||
- **Il cost gate non salva la strategia**: a 4× non apre quasi nulla, a 2× apre di più e perde di più. Il costo è il problema, ma non è l'unico: anche con lo spread a 0,1 pip (scenario api) l'overnight riporta il costo a 3,2 pip.
|
||||
- **PipDivergence** apre di più e perde di più (win rate 42 %).
|
||||
|
||||
### 3.4 Griglia con costi api
|
||||
|
||||
`results/trials_costi_api.csv` e `results/riepilogo_baskets_costi_api.csv` (57 prove):
|
||||
|
||||
- baseline Moderate: 2 076 basket, win rate 48 %, netto **−9 802 USD**, Sharpe −3,88, costo medio 3,2 pip;
|
||||
- **nessuna prova con P&L netto positivo**; la migliore per Sharpe (T013: Conservative, W 150, ρ_min −0,5) apre 8 basket in 7,75 anni e perde 45 USD (Sharpe −0,10, DSR 0);
|
||||
- PBO 0,001, di nuovo degenere (la selezione in-sample sceglie configurazioni quasi vuote);
|
||||
- walk-forward 6 m / 1 m: Sharpe −0,91, drawdown 13,5 %, PSR 0,000.
|
||||
|
||||
Lo scenario api sposta il costo dallo spread all'overnight senza cambiarne l'ordine di grandezza, perché un basket resta aperto in media più di un giorno (0,9 pip/gamba/giorno × 2 gambe × ~1,5 giorni ≈ 2,7 pip).
|
||||
|
||||
## 4. Verdetto
|
||||
|
||||
**Negativo.** Sui 7,75 anni disponibili la strategia perde in ogni configurazione provata, con entrambi i modelli di costo, e i test di falsificazione non trovano una variante che inverta il segno. Il segnale contiene circa 2 pip di informazione per basket contro 3 pip di costo.
|
||||
|
||||
Cosa ne segue:
|
||||
|
||||
1. Il bot **non va sul reale**. `run.allowLive` resta `false`; i cancelli di §9.4 non sono raggiungibili con questi numeri.
|
||||
2. Il Demo serve a **misurare** (spread reale in esecuzione, slippage, overnight effettivo, quanto spesso i cancelli si aprono), non a guadagnare. Ogni basket chiuso finisce nel ledger e nel dataset del meta-modello.
|
||||
3. Se qualcuno vuole cambiare un parametro, lo fa attraverso `knowledge/proposals.csv` e un forward test pre-registrato (`knowledge/preregistrazione.csv`), non ritoccando `strategy.json` dopo aver guardato i risultati: ogni prova in più abbassa il DSR di tutte le altre.
|
||||
|
||||
## 5. Cosa non è stato misurato
|
||||
|
||||
- L'effetto del blackout del calendario e del sentiment (assenti nel backtest).
|
||||
- Lo spread effettivo di esecuzione su eToro: l'API demo mostra 0,1 pip di mercato e markup 0; il ledger del Demo dirà se le esecuzioni lo confermano (slippage per gamba scritto a ogni ingresso).
|
||||
- EURAUD/AUDCAD su tutto il periodo (dati parziali).
|
||||
- Timeframe diversi da M15 e finestre oltre 150 barre.
|
||||
|
||||
## 6. Come rieseguire
|
||||
|
||||
```powershell
|
||||
backtest ticks --data "A:\Download\Trading" --out "%USERPROFILE%\Documents\Encelado\data\market"
|
||||
backtest baskets --data "%USERPROFILE%\Documents\Encelado\data\market" --out results [--costs api]
|
||||
backtest falsify --data "%USERPROFILE%\Documents\Encelado\data\market" --out reports [--costs api]
|
||||
```
|
||||
|
||||
Ogni esecuzione riscrive le tabelle; i numeri di questo documento vengono da quelle del 2026-09-16.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Linee guida dell'interfaccia web
|
||||
|
||||
Aggiornato: 2026-09-23 (5.0, ADR-0007). L'interfaccia è servita dal bot stesso (`src/Encelado.Server/Web/wwwroot/`: `index.html`, `login.html`, `app.css`, `app.js`, `icon.svg`, `manifest.webmanifest`, incorporati nell'assembly). Nessuna libreria, nessun font remoto, nessun `<script src=http…>`: un test lo verifica (`EmbeddedUiTests`).
|
||||
|
||||
## Principi
|
||||
|
||||
1. **La UI non decide niente.** Legge lo snapshot (`/api/snapshot`, `/api/stream`) e manda comandi (`POST /api/commands/<nome>`). Ogni regola di rischio sta nel motore.
|
||||
2. **Le stesse informazioni della dashboard di sempre**: equity, P&L di oggi, P&L aperto (conto e basket), drawdown, basket aperti con in attesa / orfane / esterne, margine; la tabella dei basket; prossimo evento, collegamento eToro, Telegram; l'attività. I dettagli stanno nei tooltip (`title`) e nel log, non in più riquadri.
|
||||
3. **Ogni numero ha un tooltip** con la formula o la fonte; ogni importo convertito porta nel tooltip il valore in USD e il tasso usato. Il ledger resta in USD.
|
||||
4. **Gli orari a schermo sono nel fuso scelto** (`ui.timeZone`, `TZ`); la barra in alto mostra sempre anche l'UTC, che è l'ora del ledger.
|
||||
5. **Le azioni irreversibili chiedono conferma** in un dialogo: chiusura di un basket, kill-switch (con la spunta «chiudi anche le esterne», deselezionata), reset (motivazione ≥ 10 caratteri), rimozione delle chiavi, ripristino della configurazione, avvio Live (frase `CONFERMO LIVE` scritta per intero).
|
||||
|
||||
## Material 3 senza libreria
|
||||
|
||||
### Colori (token CSS in `:root`)
|
||||
|
||||
| Ruolo | Scuro (default) | Chiaro | Uso |
|
||||
|---|---|---|---|
|
||||
| `--primary` / `--on-primary` | `#adc6ff` / `#002e69` | `#005ac1` / `#ffffff` | pulsante AVVIA, tab attiva, focus, curva dell'equity |
|
||||
| `--primary-container` / `--on-primary-container` | `#1f4e9c` / `#d8e2ff` | `#d8e2ff` / `#001a41` | voce attiva del rail |
|
||||
| `--surface`, `--surface-container-low/-/high/highest` | `#101418`, `#181c20`, `#1c2024`, `#262a2f`, `#31353a` | `#f8f9ff`, `#f2f3f9`, `#eceef4`, `#e6e8ee`, `#e0e2e8` | sfondo, rail, card, KPI, intestazioni delle tabelle |
|
||||
| `--on-surface` / `--on-surface-variant` | `#e0e2e8` / `#c3c6d0` | `#191c20` / `#43474e` | testo, testo secondario |
|
||||
| `--outline` / `--outline-variant` | `#8d9099` / `#43474e` | `#74777f` / `#c3c6d0` | bordi di input e tabelle |
|
||||
| `--error` / `--error-container` | `#ffb4ab` / `#93000a` | `#ba1a1a` / `#ffdad6` | kill-switch, banner rosso, stato `Halted` |
|
||||
| `--up` / `--down` / `--warn` | `#7fd39a` / `#ff8a80` / `#f5b74f` | `#1b7f3b` / `#c62828` / `#9a6400` | **solo** P&L, pip, stati (verde/rosso), avvisi (giallo) |
|
||||
|
||||
Una sola tinta d'accento; contrasto ≥ 4,5:1 per il testo. Il tema si sceglie in Impostazioni ▸ Interfaccia (`ui.theme`: `dark` | `light`) e viene ricordato nel browser (`localStorage`, solo comodità per chi guarda); `prefers-color-scheme` è rispettato quando il file dice `dark` ma il sistema è chiaro solo per `color-scheme`.
|
||||
|
||||
### Tipografia
|
||||
|
||||
`Roboto, "Segoe UI", system-ui, sans-serif`; monospazio `Cascadia Mono, JetBrains Mono, Consolas` per log, id e attività. Scala: titolo di pagina 20 px (title-large), titoli delle card 16 px (title-medium), KPI 24 px (headline-small), corpo 14 px, etichette 12 px. **Cifre tabulari** (`font-variant-numeric: tabular-nums`) su ogni numero.
|
||||
|
||||
### Forma, elevazione, stati
|
||||
|
||||
Angoli 12 px (card, banner), 16 px (dialoghi grandi 28 px come da M3), pulsanti a pillola (999 px). Elevazione tonale (superfici a livelli), niente ombre salvo dialoghi e snackbar. State layer su hover (8 %) e pressed (12 %) tramite `::after`. `:focus-visible` con anello di 2 px in `--primary`.
|
||||
|
||||
### Componenti
|
||||
|
||||
| Componente | Dove | Note |
|
||||
|---|---|---|
|
||||
| Navigation rail | sinistra, 80 px chiuso / 256 px aperto | quattro voci: Dashboard, Storico ordini, Log, Impostazioni; stato ricordato (`ui.navExpanded` e `localStorage`); sotto 600 px diventa un drawer con scrim; in fondo i chip ambiente e stato |
|
||||
| Top app bar | sopra la pagina | titolo della pagina, ora UTC e ora nel fuso, selettore della valuta, AVVIA/FERMA, KILL-SWITCH (attivo solo a motore acceso); linear progress durante un comando |
|
||||
| Cards | dashboard, contesto, impostazioni | KPI con etichetta, valore, riga secondaria |
|
||||
| Data table compatta | basket, storico, log | intestazione fissa, righe da 32 px, allineamento a destra dei numeri; sotto 600 px la tabella dei basket diventa cards |
|
||||
| Chips | ambiente (`PAPER` blu, `DEMO` giallo, `LIVE` rosso), stato del motore, stato del basket, origine della posizione | testo in minuscolo per gli stati |
|
||||
| Buttons | filled (AVVIA, Salva, conferme), tonal (Applica, Avvia il ripristino), outlined danger (KILL-SWITCH), text (Chiudi, Esporta) | mai due filled affiancati |
|
||||
| Select, input, switch | filtri, impostazioni, Segui nel log | validazione lato server con messaggio accanto al campo |
|
||||
| Dialog | conferma, prompt (motivazione, `CONFERMO LIVE`), ripristino in cinque passi | `<dialog>` nativo, `showModal`, Esc chiude |
|
||||
| Snackbar | esito dei comandi | 4 s, 8 s se errore |
|
||||
| Banner | dashboard | rosso per errore, kill-switch con residuo, equity stop, sospensione; giallo per entrate bloccate, non riconciliate, orfane |
|
||||
| Tooltip | `title` su ogni numero e riga | la formula, la fonte, il valore in USD |
|
||||
|
||||
### Classi di finestra
|
||||
|
||||
| Classe | Larghezza | Layout |
|
||||
|---|---|---|
|
||||
| compact | < 600 px | rail nascosto (menu ☰), KPI su due colonne, basket a cards, orologi nascosti |
|
||||
| medium | 600-839 px | rail 80 px, KPI su due colonne, contesto su una colonna, impostazioni su una colonna |
|
||||
| expanded | ≥ 840 px | rail 80/256 px, KPI su tre colonne (due righe), contesto su tre, impostazioni su due |
|
||||
|
||||
### Accessibilità
|
||||
|
||||
Tastiera completa (Tab su rail, pulsanti, tabelle, dialoghi; Esc chiude drawer e dialoghi), `aria-label` su icone e selettori, `aria-current="page"` sulla voce attiva, `role="tablist"/"tab"/"tabpanel"` nello storico, `role="status"` su banner e snackbar, link «Vai al contenuto», `prefers-reduced-motion` (nessuna transizione), `prefers-color-scheme`.
|
||||
|
||||
## Pagine
|
||||
|
||||
- **Dashboard**: banner, sei KPI, tabella dei basket (nome e cross, stato, z, ρ, HL, pip, TP, P&L, costo, prossimo evento, Chiudi), preset, tre card di contesto, attività (ultime righe di log).
|
||||
- **Storico ordini**: tre tab. *Ordini* (da `orders.jsonl`: unità richieste ed eseguite, prezzi, slippage, stato, esito, id), *Posizioni* (storico eToro classificato `basket` / `orfana-bot` / `esterna` / `movimento di cassa`, con P&L lordo, fee, netto, pip, durata, motivo), *Profitti per periodo* (oggi, ieri, 7 e 30 giorni, mese corrente e precedente, anno, tutto, intervallo personalizzato; curva dell'equity realizzata in SVG). Filtri e **Esporta CSV** (`;`, colonna `motivazione`).
|
||||
- **Log**: livello, ricerca, «Segui», 2000 righe per pagina lette a incrementi (`/api/log?after=`).
|
||||
- **Impostazioni**: configurazione a gruppi (eToro, esecuzione, notifiche, interfaccia/valuta, log) con validazione; chiavi eToro (verifica e salvataggio cifrato); ripristino in cinque passi e ripristino dei valori predefiniti; **Ricerca** (stato del modello in ombra, bandit, volatilità, criterio di riattivazione, «Esegui ciclo di apprendimento»); **Diagnostica** (strategia e run, percorsi, .NET, sistema, uptime, quote API, fuso, bonifica); **Informazioni** (nome, autore, versione, data di build, commit, licenza, documentazione).
|
||||
- **Login**: solo con `ENCELADO_WEB_TOKEN`; cookie HttpOnly per 30 giorni.
|
||||
|
||||
## Flusso dei dati
|
||||
|
||||
`/api/stream` (SSE, `event: snapshot`, uno al secondo) → `apply(snapshot)` → `render()`; se il browser non ha `EventSource`, polling di `/api/snapshot` ogni 2 s; `?once=1` scatta una sola lettura (per gli screenshot). I comandi rispondono `{ok, message, …}` e finiscono nella snackbar; l'esito vero arriva con lo snapshot successivo. Il log legge `/api/log` ogni 1,5 s solo mentre la pagina Log è aperta.
|
||||
|
||||
## Screenshot
|
||||
|
||||
`docs/img/` (dashboard, storico, log, impostazioni, mobile), presi dal server in modalità campione con `scripts\screenshots.ps1` (o `sh scripts/screenshots.sh`): avvia il server con `--sample` su una porta libera, apre ogni pagina con Edge headless (`?once=1`, 1440×900 e 420×860) e lo ferma. A mano, una pagina:
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/Encelado.Server -- --sample --port 8085
|
||||
& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --headless=new --disable-gpu --hide-scrollbars --window-size=1440,900 --virtual-time-budget=5000 --user-data-dir=$env:TEMP\edge-shot --screenshot=docs\img\dashboard.png "http://127.0.0.1:8085/?once=1#/dashboard"
|
||||
```
|
||||
|
||||
Prima di dichiarare finita una modifica: `EmbeddedUiTests` e `WebHostTests` verdi, screenshot rifatti se cambia una pagina, revisione visiva con l'utente.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-0001 — Broker: eToro
|
||||
|
||||
Data: 2026-09-16. Stato: accettata.
|
||||
|
||||
## Contesto
|
||||
|
||||
La strategia opera otto coppie forex (EURUSD, USDCHF, AUDUSD, USDCAD, NZDUSD, EURNZD, EURAUD, AUDCAD) come CFD. Il bot aveva un adattatore Binance (ritirato: dal 1° luglio 2026 l'utente non può operare in USDT) e un adattatore cTrader mai collegato. Alpaca è esclusa perché non offre forex.
|
||||
|
||||
## Decisione
|
||||
|
||||
Il modulo basket opera su **eToro Public API** (`https://public-api.etoro.com`), autenticazione con `x-api-key` + `x-user-key` (chiavi diverse per demo e reale, ambiente sempre visibile in UI), `x-request-id` obbligatorio (usato anche come `referenceId` idempotente degli ordini).
|
||||
|
||||
Fatti verificati il 2026-09-16 sulla specifica OpenAPI servita dall'API (v1.379.0):
|
||||
|
||||
- Quote: `GET /api/v2/market-data/rates?instrumentIds=…` (batch, quota condivisa 120/min).
|
||||
- Candele: `GET /api/v1/market-data/instruments/{id}/history/candles/{asc|desc}/{FifteenMinutes}/{≤1000}` — senza data di partenza: **non pagina lo storico**.
|
||||
- Strumenti: `GET /api/v2/market-data/instruments?symbols=…`; eligibility `POST /api/v2/trading/info/{demo/}eligibility`; costi what-if `POST /api/v2/trading/info/{demo/}costs`.
|
||||
- Conto e posizioni: `GET /api/v1/trading/info/{demo/}pnl` (posizioni con P&L non realizzato, `credit`); saldi `GET /api/v1/balances`.
|
||||
- Ordini: `POST /api/v2/trading/execution/{demo/}orders` (20/min), esito con `GET /api/v2/trading/info/{demo/}orders:lookup?referenceId=…`; chiusura `POST /api/v1/trading/execution/{demo/}market-close-orders/positions/{id}` con esito in `GET /api/v1/trading/info/{demo|real}/close-orders/{orderId}`; SL/TP `PATCH /api/v2/trading/{demo/}positions/{id}`.
|
||||
- Storico chiusure: `GET /api/v1/trading/info/trade/{demo/}history?minDate=…`.
|
||||
|
||||
## Alternative
|
||||
|
||||
- **IC Markets / cTrader**: spread ECN più stretti e Open API con streaming, ma l'utente ha chiesto eToro e usa già l'API; l'adattatore resta nel repo per il motore `proba`.
|
||||
- **Alpaca**: niente forex.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Nessun order book, spread con markup, esecuzione solo a mercato (o MIT) con SL/TP nativi: il cost gate deve leggere spread e markup reali a ogni decisione.
|
||||
- Lo storico per il backtest viene dai tick MT5 dell'utente, non dall'API.
|
||||
- Ogni ordine nasce con uno stop nativo (richiesto per `sellShort` e per leva > 1); il bot gestisce comunque lo stop di basket.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-0002 — Storage su file per il modulo basket
|
||||
|
||||
Data: 2026-09-16. Stato: accettata.
|
||||
|
||||
## Contesto
|
||||
|
||||
Il repository ha già un database SQLite (`Encelado.Storage`, unica dipendenza NuGet a runtime) usato dal motore `proba` per barre, dataset, modelli e journal. La specifica del modulo basket chiede storage su file: CSV `;` con `motivazione`, JSONL append-only per ledger e notizie, JSON per modelli e stato, scritture atomiche, rotazione mensile, nessuna riga del ledger modificata.
|
||||
|
||||
## Decisione
|
||||
|
||||
Il modulo basket **non usa SQLite**. Tutto vive in file sotto `Documenti\Encelado\`:
|
||||
|
||||
```
|
||||
data/market/candles_<SYMBOL>_M15.csv barre M15 bid/ask (dallo strumento ticks e dal delta API)
|
||||
data/calendar/events.jsonl eventi economici (dedup title+date+country)
|
||||
data/news/news_YYYYMM.jsonl notizie RSS (dedup hash(link))
|
||||
data/ledger/decisions.jsonl ogni valutazione di ogni basket (append-only, rotazione mensile in decisions_YYYYMM.jsonl)
|
||||
data/ledger/baskets.csv una riga per basket chiuso (label, P&L, costi, slippage)
|
||||
data/models/logreg_v<N>.json, mlp_v<N>.json, bandit.json, state.json
|
||||
knowledge/calibration.csv, insights_YYYYWW.md, proposals.csv, forward_registry.csv, models_registry.csv, preregistrazione.csv
|
||||
reports/*.csv, results/trials.csv
|
||||
```
|
||||
|
||||
## Alternative
|
||||
|
||||
- Riusare SQLite: comodo per query, ma introduce un binario nativo nel percorso del modulo, contraddice la specifica e rende il ledger modificabile per errore.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Le tabelle si aprono in un foglio di calcolo così come sono; il ledger è verificabile riga per riga.
|
||||
- L'analisi (ricostruzione del dataset, calibrazione) rilegge i file: costa qualche secondo per centinaia di migliaia di righe, accettabile per un ciclo settimanale.
|
||||
@@ -0,0 +1,15 @@
|
||||
# ADR-0003 — Motore cTrader mantenuto selezionabile
|
||||
|
||||
Data: 2026-09-16 (mattina). Stato: **superata da ADR-0004** (stesso giorno, pomeriggio).
|
||||
|
||||
## Contesto
|
||||
|
||||
All'inizio del lavoro sui Correlation Baskets l'albero conteneva un motore probabilistico su cTrader (`proba`) non committato e non compilante. La regola «non toccare i comportamenti esistenti se non richiesto» suggeriva di rimetterlo in compilazione e lasciarlo selezionabile con `engine.strategy = "proba"`, con `"baskets"` come predefinito.
|
||||
|
||||
## Decisione (originaria)
|
||||
|
||||
Tenere entrambi i motori dietro `IEngine`, con la finestra che sceglie le pagine in base al motore configurato.
|
||||
|
||||
## Esito
|
||||
|
||||
Nel pomeriggio l'utente ha chiesto la rimozione di tutto ciò che riguarda le gestioni precedenti e, alla domanda esplicita, ha incluso cTrader e la ricerca (D-21). La decisione è registrata in ADR-0004; questo documento resta per la cronologia.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-0004 — Rimozione dei motori precedenti (Binance, cTrader/proba, ricerca)
|
||||
|
||||
Data: 2026-09-16. Stato: accettata. Sostituisce ADR-0003.
|
||||
|
||||
## Contesto
|
||||
|
||||
Il repository portava tre generazioni di codice: l'arbitraggio statistico su Binance Futures (con adattatore già rimosso), il motore probabilistico su cTrader con la sua pipeline di ricerca (SQLite, GBDT, RL, TA-Lib, backtest a coppie) e il modulo Correlation Baskets su eToro. ADR-0003 aveva tenuto il motore cTrader selezionabile per non toccare comportamenti esistenti. L'utente ha chiesto un rework completo che elimini «qualsiasi cosa legata a vecchie gestioni (binance, alpaca, ecc ecc)» e, alla domanda esplicita, ha scelto di rimuovere anche cTrader e la ricerca (D-21).
|
||||
|
||||
## Decisione
|
||||
|
||||
Restano solo `Encelado.Core` (basket, broker, notizie, statistica condivisa), `Encelado.Etoro`, `Encelado.Bot` e lo strumento `tools/Encelado.Backtest` con i tre comandi `ticks`, `baskets`, `falsify`. Sono stati eliminati i progetti `Encelado.CTrader` e `Encelado.Storage`, le cartelle `Core/Backtest`, `Indicators`, `Journal`, `Market`, `Portfolio`, `Research`, `Risk`, `Rl`, `Strategies`, quasi tutto `Ml` (restano `Classification` e `Pbo`) e `Statistics` (restano `Performance`, `Ols`, `Distributions`), il motore `ProbaEngine`, le pagine e i test relativi, il selettore `engine.strategy`, le sezioni di configurazione `ctrader`, `engine`, `strategy`, `risk`, `storage`, `symbols`. Nessun pacchetto NuGet resta nei progetti dell'applicazione.
|
||||
|
||||
Il codice rimosso è nella storia git (tag `v3.5.0` e commit `b39e08b`).
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Una sola strategia, una sola configurazione, una sola finestra: meno codice da capire e da testare (da 322 a 172 test, tutti sul modulo che gira).
|
||||
- Le conclusioni delle ricerche precedenti (StatArb su BTC non valida fuori campione, ProbaBot) restano solo nei documenti e nella memoria di lavoro; non sono più riproducibili da questo albero.
|
||||
- Un file di configurazione della versione precedente viene letto con avvisi mirati («sezione di una versione precedente») e il ripristino dei valori di fabbrica lo riscrive.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-0005 — Nessuna approvazione manuale dei singoli ordini
|
||||
|
||||
Data: 2026-09-16. Stato: accettata (decisione dell'utente, D-20).
|
||||
|
||||
## Contesto
|
||||
|
||||
La specifica prevedeva cinque modalità (`Paper`, `DemoApprove`, `DemoAuto`, `LiveApprove`, `LiveAuto`) con `DemoApprove` predefinita: ogni apertura, aggiunta e take-profit era una proposta che aspettava una persona per quindici minuti. Nella sessione di prova del 2026-09-16 il bot in `DemoApprove` non ha mai potuto operare senza qualcuno alla finestra, e il test lungo richiesto dall'utente («sperando di piazzare trade») non è possibile in quel modo. Alla domanda «posso usare DemoAuto per il test lungo?» l'utente ha risposto: «tutti gli Approve devono sparire, almeno per il momento. Il bot deve girare in completa autonomia aprendo e chiudendo le posizioni senza il mio consenso».
|
||||
|
||||
## Decisione
|
||||
|
||||
Le modalità diventano tre: `Paper`, `Demo` (predefinita) e `Live`. In tutte il bot esegue da solo le decisioni del decisore. La coda delle approvazioni (`ApprovalQueue`), i comandi `approve`/`reject`, il flag `run.allowDemoAuto` e la conferma all'avvio del demo automatico sono rimossi. I nomi precedenti (`DemoApprove`, `DemoAuto`, `LiveApprove`, `LiveAuto`) vengono ancora letti dal file di configurazione, mappati su `Demo`/`Live` con un avviso.
|
||||
|
||||
Restano i gate umani che non riguardano il singolo ordine: la frase `CONFERMO LIVE` all'avvio del reale (con `run.allowLive = true`), il kill-switch, il reset motivato dopo un equity stop, il cambio di preset a caldo.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Le regole «mai un ordine reale senza flag e conferma» restano vere a livello di sessione: il reale non parte senza flag e frase.
|
||||
- Le uscite protettive erano già automatiche in ogni modalità; ora lo sono anche le aperture e i take-profit.
|
||||
- Se in futuro servisse una revisione umana, va reintrodotta come modalità esplicita e non come default: la specifica originaria resta documentata qui.
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-0006 — Apprendimento: livello 0 e logistica in ombra restano, MLP, bandit e ciclo settimanale si spengono a runtime
|
||||
|
||||
Data: 2026-09-23. Stato: accettata con il default di D-30 (§8 del piano 5.0); revocabile con una risposta diversa.
|
||||
|
||||
## Contesto
|
||||
|
||||
`docs/STRATEGY.md` dà un verdetto negativo sul backtest (7,75 anni, nessuna configurazione con P&L netto positivo, segnale di circa 2 pip contro 3 pip di costo, walk-forward Sharpe −0,91). Il forward test in Demo ha prodotto **0 basket chiusi** in sei giorni per il difetto degli ordini pendenti (post-mortem), e il ciclo settimanale ha girato a vuoto («ledger senza basket chiusi: niente da addestrare»). Un meta-modello può soltanto filtrare i basket: con un segnale sotto i costi il massimo che può fare è ridurre le perdite, e per attivarsi gli servono almeno 300 basket chiusi, cioè mesi alla frequenza osservata. Nel frattempo il bandit **applicava** il preset da solo in Demo: un parametro cambiato dal bot, contro la regola di `RISK_RULES.md`.
|
||||
|
||||
## Decisione
|
||||
|
||||
- **Restano attivi**: il ledger (è il dato), il livello 0 (tabelle di calibrazione: quasi gratis, utili a leggere il forward test), la previsione di volatilità (`VolForecaster`, usata dal decisore in modo deterministico per `z_in` effettivo), la regressione logistica **in ombra** (predice `p_ML` nel ledger a ogni chiusura di barra e impara a ogni chiusura di basket; non fa mai da cancello).
|
||||
- **Si spengono a runtime** (codice conservato nel Core): l'MLP challenger, il bandit di Thompson come attuatore (dalla Fase 1 propone soltanto), il ciclo settimanale. In `strategy.json` la sezione `learning` ha `enabled = false` di fabbrica: con `false` il motore non avvia il ciclo, non addestra il challenger e non può mai attivare il cancello `ml_gate`; il bandit registra i premi e propone nel log.
|
||||
- **Il ciclo settimanale passa allo strumento** `tools/Encelado.Backtest` (comando `learn` su un ledger esportato), così i modelli si valutano quando ci saranno dati, fuori dal processo che opera. Il comando arriva con la Fase 6, quando `LearningState` si sposta nel progetto portabile `Encelado.Engine` (oggi dipende dal log e dal ledger del Bot).
|
||||
- **Criterio di riattivazione** (`docs/ML_AND_LEARNING.md`): almeno **300 basket chiusi in Demo** *e* **P&L netto forward ≥ 0** sulla metrica pre-registrata (`knowledge/preregistrazione.csv`). Prima di allora ogni «adattamento automatico» è rumore, e `learning.enabled = true` è una decisione dell'operatore scritta nel file, non del bot.
|
||||
- **Interfaccia**: il riquadro «Apprendimento» esce dalla dashboard; lo stato del modello in ombra (basket visti, AUC mobile, PSI) e il pulsante «esegui ciclo di apprendimento» vanno in Impostazioni → Ricerca (web UI, Fase 7). Nella finestra WPF resta la riga di stato del modello in ombra, che ora dice «disattivato (ADR-0006)».
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Nessun cambiamento nelle decisioni del bot: il cancello ML non era mai stato attivo (0 basket chiusi).
|
||||
- `strategy.json` cresce della sezione `learning` (`enabled`, `weeklyCycle`, `challenger`); l'hash della configurazione la include.
|
||||
- I file `data/models/*` e `knowledge/*` restano dove sono e vengono letti dallo strumento.
|
||||
- Quando il criterio sarà soddisfatto, la riattivazione passa da una proposta in `knowledge/proposals.csv`, dalla pre-registrazione e da `learning.enabled = true`.
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-0007 — Interfaccia web servita dal bot al posto della finestra WPF
|
||||
|
||||
Data: 2026-09-23. Stato: accettata (risposta dell'utente a D-28: «il bot deve essere eseguito SOLO tramite container. Elimina tutta la parte della grafica Windows»).
|
||||
|
||||
## Contesto
|
||||
|
||||
Fino alla 4.0.0 Encelado era un'applicazione WPF (`Encelado.exe`, `net10.0-windows`) con la modalità `--headless` per i test lunghi. Il post-mortem del 16-21/9 (`docs/POSTMORTEM_ordini_pendenti.md`) ha mostrato che il bot deve girare senza interruzioni su una macchina sempre accesa — il server Unraid dell'utente — e che la finestra era diventata il modo meno affidabile di tenerlo d'occhio: apribile solo sul PC di sviluppo, chiusa con la sessione di Windows, con un secondo motore possibile per sbaglio. Una UI Windows non gira in un container Linux.
|
||||
|
||||
## Decisione
|
||||
|
||||
1. Il progetto WPF viene **rimosso** (`src/Encelado.Bot/App.xaml`, `MainWindow`, `Ui/*`, tema, finestre di login e prompt, test di binding e di rendering). Il motore passa in `src/Encelado.Engine` (libreria `net10.0`), l'eseguibile è `src/Encelado.Server` (Kestrel).
|
||||
2. L'interfaccia è **web**, servita dal bot stesso: HTML, CSS e JavaScript incorporati nell'assembly (`EmbeddedResource`), nessun framework, nessun font remoto, nessuna dipendenza NuGet (`Microsoft.AspNetCore.App` è un framework reference dell'SDK). Stile Material 3 (`docs/UI_GUIDELINES.md`).
|
||||
3. Aggiornamenti via Server-Sent Events (`/api/stream`, uno snapshot al secondo); comandi via `POST /api/commands/<nome>`; storico, log, impostazioni, chiavi e diagnostica via API JSON scritta a mano (`Utf8JsonWriter`). Token locale `ENCELADO_WEB_TOKEN` in cookie HttpOnly; senza token il server ascolta solo su localhost.
|
||||
4. La verifica visiva dei test WPF (`UiRenderTests`) è sostituita dal test dell'HTML incorporato, dal test dello snapshot JSON, dal test dello stream SSE e dagli screenshot (`docs/img/`) presi dal server in modalità `--sample`.
|
||||
5. Le chiavi eToro non sono più in DPAPI (Windows): variabili d'ambiente oppure file cifrato `etoro.keys.enc` (AES-256-GCM, passphrase in `ENCELADO_KEY_PASSPHRASE`).
|
||||
|
||||
## Alternative scartate
|
||||
|
||||
- **Tenere WPF e aggiungere la web UI**: due interfacce da tenere allineate su ogni campo dello snapshot, due catene di test, e la finestra non avrebbe comunque girato dove il bot deve girare. L'utente ha chiesto di eliminarla.
|
||||
- **Framework JavaScript (React, Vue, Blazor)**: una toolchain npm o una dipendenza NuGet contro la regola «nessun pacchetto»; per quattro pagine e un solo flusso di dati (lo snapshot) il vanilla è sufficiente e più leggibile.
|
||||
- **Blazor Server**: dipendenza da SignalR e da uno stato per circuito; l'SSE fa lo stesso lavoro con una riga per snapshot.
|
||||
- **Terminale (TUI)**: non raggiungibile da un telefono in rete locale.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- Niente più `net10.0-windows`: tutta la soluzione è portabile e la suite di test gira nello stadio di build dell'immagine Docker (ADR-0008).
|
||||
- Le regole «una barra in alto, pagine sotto, dettagli nei tooltip» restano; cambia il mezzo. La navigazione diventa un navigation rail a sinistra (80/256 px, stato ricordato).
|
||||
- La bonifica interattiva, il reset in cinque passi, il kill-switch con la spunta «chiudi anche le esterne» e la frase `CONFERMO LIVE` passano da dialoghi HTML.
|
||||
- Chi vuole la finestra non ce l'ha più: si apre il browser su `http://<ip>:8080/`.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-0008 — Engine + Server, immagine Docker come pacchetto, Unraid come destinazione
|
||||
|
||||
Data: 2026-09-23. Stato: accettata (D-28: «solo tramite container»; D-29: registro dei container di Gitea, icona nel repository).
|
||||
|
||||
## Contesto
|
||||
|
||||
Con la finestra WPF ritirata (ADR-0007) il bot ha bisogno di un processo che giri per mesi su una macchina sempre accesa, si riavvii da solo, non dipenda dal PC di sviluppo e sia aggiornabile con un comando. L'utente ha un server Unraid e un Gitea privato con il registro dei container.
|
||||
|
||||
## Decisione
|
||||
|
||||
1. **Tre progetti applicativi**: `Encelado.Core` (logica pura, zero I/O), `Encelado.Engine` (motore, ledger, feed, configurazione, impostazioni, storico, Telegram; ex `Encelado.Bot` senza UI), `Encelado.Server` (l'eseguibile: `Microsoft.NET.Sdk.Web`, Kestrel, API, UI incorporata, ciclo di vita del processo). `Encelado.Etoro` resta il client HTTP. Lo strumento `tools/Encelado.Backtest` referenzia anche Engine per il comando `learn`.
|
||||
2. **Il pacchetto è l'immagine Docker** (`Dockerfile` alla radice, multi-stage: restore, build, test, publish framework-dependent, runtime `mcr.microsoft.com/dotnet/aspnet:10.0`). Un'immagine con i test rossi non esiste: `dotnet test` gira dentro lo stadio di build. Il commit arriva come `--build-arg GIT_COMMIT` (il contesto non contiene `.git`) e finisce in `AssemblyMetadata` con la data di build, visibili in Impostazioni ▸ Informazioni e in `/api/info`.
|
||||
3. **Volumi**: `/config` (encelado.json, strategy.json, instruments.json, chiavi cifrate) e `/data` (ledger, stato, barre, cache, conoscenza, rapporti, log). Il rilevamento del container (`ENCELADO_IN_CONTAINER=1`, o `/config` esistente su Linux) rimappa `run.dataPath` e affini sotto `/data` senza toccare la configurazione dell'utente.
|
||||
4. **Entrypoint** con `gosu`: `PUID`/`PGID` (default 99/100, `nobody:users` di Unraid) diventano proprietari di `/config` e `/data`; `TZ` regola gli orari a schermo; `exec` del processo dotnet come PID 1 per ricevere `SIGTERM` (arresto pulito: ledger chiuso, heartbeat con nota, posizioni lasciate sul conto con gli stop nativi).
|
||||
5. **Healthcheck** `dotnet Encelado.Server.dll --health`: interroga `/api/health` (heartbeat fresco se il motore gira, disco scrivibile). Niente `curl` nell'immagine.
|
||||
6. **Catena di rilascio** (`build/Release.proj`): `Verifica` → `Pubblica` (cartella portabile framework-dependent) → `Docker` (`<registro>/alby96/encelado:<versione>` e `:latest`) → tag → `Rilascia` (push dell'immagine sul registro di Gitea con lo stesso token della release, release con lo zip portabile e il template Unraid allegati). Inno Setup e `Encelado.iss` sono rimossi.
|
||||
7. **Unraid**: template `deploy/unraid/encelado.xml` (Container v2) con porta 8080, i due volumi sotto `/mnt/user/appdata/encelado/`, tutte le variabili con descrizione in italiano e `Mask` sui segreti; icona `assets/encelado.png` servita come file raw da Gitea (D-29).
|
||||
|
||||
## Alternative scartate
|
||||
|
||||
- **Servizio Windows / VPS Windows**: obbliga a tenere un Windows acceso e non si aggiorna con un `docker pull`; l'utente ha già Unraid.
|
||||
- **Pubblicazione self-contained o AOT nel container**: il server ospita ASP.NET Core minimal e non è AOT-compatibile; l'immagine `aspnet` porta già il runtime e resta più piccola di un self-contained.
|
||||
- **Immagine su Docker Hub o GHCR**: il codice è privato su Gitea; il registro di Gitea usa lo stesso token della release e non richiede altri account.
|
||||
- **Compose come unica via**: resta per lo sviluppo locale (`docker-compose.yml`), ma la destinazione è il template Unraid.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- `Documenti\Encelado` sopravvive solo per l'esecuzione fuori dal container (sviluppo con F5); nel container tutto sta in `/config` e `/data`.
|
||||
- L'istanza unica per cartella dati (`instance.lock`) vale anche fra un container e un processo locale che montino la stessa cartella.
|
||||
- Un aggiornamento dell'immagine è un riavvio: il recupero dopo inattività (Fase 4) copre il buco.
|
||||
- La catena `build/` cambia struttura rispetto a Mimante/AutoBidder (niente installatore): la differenza è documentata in `build/README.md`.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-0009 — Registro persistente degli ordini e adozione delle gambe orfane
|
||||
|
||||
Data: 2026-09-23. Stato: accettata (Fase 1 del piano 5.0, `docs/PIANO_5.0.md`; post-mortem in `docs/POSTMORTEM_ordini_pendenti.md`).
|
||||
|
||||
## Contesto
|
||||
|
||||
Fra il 16 e il 21 settembre 2026 il bot ha lasciato sul conto demo 21 gambe singole senza copertura. L'esito di ogni ordine era cercato con la chiave sbagliata (`referenceId`, che eToro non registra per gli ordini v2), l'ordine senza esito veniva dichiarato «non eseguito» e dimenticato, e la posizione che ne nasceva era «sconosciuta» e per regola intoccabile. Tre difetti che, insieme, hanno trasformato una regola di prudenza («non toccare ciò che non è tuo») in un accumulo di rischio scoperto.
|
||||
|
||||
## Decisione
|
||||
|
||||
1. **Ogni ordine entra in un registro persistente prima della chiamata HTTP** (`OrderTracker`, `data/state/pending_orders.json`, scrittura atomica). Il registro tiene riferimento cliente, `orderId`, strumento, verso, unità richieste ed eseguite, basket, gamba, orario, ultimo stato del server, esito e posizione. All'avvio viene ricaricato e ogni ordine senza esito viene risolto **prima** di qualsiasi decisione.
|
||||
2. **La chiave dell'esito è l'`orderId`** (`orders:lookup?orderId=`, ripiego `api/v1/trading/info/{demo/}orders/{id}`). Il riferimento cliente resta nell'intestazione per l'idempotenza e serve solo quando la risposta al `POST` è andata persa. Quando il server non ha traccia sotto nessuna chiave, una posizione dello stesso strumento e verso comparsa entro 90 s dall'invio **è** l'esecuzione (le unità possono differire: il server può ridurre l'ordine).
|
||||
3. **Nessun esito sintetico.** `OrderOutcome.Status` riporta la parola del server, oppure `Unknown`. Un ordine senza esito allo scadere del timeout della gamba porta il basket in `PendingA` o `PendingB`: nessun nuovo ordine su quel basket, il registro continua a chiedere (ogni 2 s nel primo minuto, poi ogni 10 s, poi ogni minuto), e alla risoluzione l'ingresso viene completato (gamba B, ridimensionata sulle unità eseguite di A), oppure annullato con la chiusura immediata della gamba eseguita se il segnale è decaduto o il bot è bloccato.
|
||||
4. **Ogni posizione del conto viene classificata** a ogni riconciliazione (`PositionClassifier`): `basket` (gamba nota), `orfana-bot` (id nel registro, oppure strumento + verso + orario entro 90 s coerenti con una riga `segnale_ingresso`/`rifiuto`/`ingresso`/`pending` del ledger), `esterna` (tutto il resto). Le orfane-bot vengono **adottate e chiuse** (tre tentativi, poi blocco delle entrate con avviso); le esterne restano intoccate. Il contatore in dashboard distingue basket aperti, ingressi in attesa, orfane ed esterne.
|
||||
5. **Il picco di equity è al netto dei movimenti di cassa** (`EquityTracker`): un salto del saldo non spiegato dalle chiusure è un deposito o un prelievo, viene scritto nel ledger come `movimento_di_cassa` e non muove né il picco né il drawdown.
|
||||
6. **`orders.jsonl`** (append-only) riceve una riga a ogni invio e a ogni cambio di stato: è la fonte della scheda Storico → Ordini.
|
||||
|
||||
## Alternative scartate
|
||||
|
||||
- *Interrogare in parallelo `orderId` e `referenceId`*: la quota dei lookup (60/min, condivisa con l'esito delle chiusure) non regge due richieste ogni 400 ms per gamba, e il riferimento non è registrato dal server.
|
||||
- *Adottare le orfane solo con unità ± 1 %*: il server ha ridotto gli ordini a 2 000 USD di margine, le unità non sono un identificatore. La finestra temporale sullo strumento e sul verso lo è, sul conto demo dove nient'altro opera così.
|
||||
- *Ricomporre il basket con la gamba mancante quando si adotta un'orfana*: rifiutato (default della domanda D-35/§5.4): la gamba è vecchia di un tempo ignoto, il segnale che l'ha generata non c'è più; si chiude.
|
||||
|
||||
## Conseguenze
|
||||
|
||||
- `IBroker` ha due metodi in più (`LookupOrderByIdAsync`, `CancelOrderAsync`); ogni implementazione, anche quelle di prova, li fornisce.
|
||||
- `BasketExecutor` accetta un `OrderTracker` e pubblica `ResumeAfterAAsync`, `CompleteAfterBAsync`, `UnwindLegAsync`, `ClosePositionAsync`.
|
||||
- `BasketEngine` è spezzato in file parziali (loop e decisioni; registro; riconciliazione; stato; comandi; snapshot).
|
||||
- Il bandit **non applica** più il preset da solo in Demo: propone e basta (D-30).
|
||||
- Nuovi test (m)-(q) in `tests/Encelado.Tests/ExecutionTests.cs`.
|
||||
- La bonifica delle orfane esistenti è un comando (`--bonifica` in headless, `bonifica` da console): sul conto demo del 2026-09-23 non c'è niente da bonificare (chiusura manuale del 21/9).
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,14 @@
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
1_segnale;ZScoreSynthetic;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
1_segnale;PipDivergence;3124;0.4238;-9406.4611;-3.7746;0.9412;-75.4011;-31.0045;-0.6061;3.1477;0;0;3124 basket, win rate 42 %, netto -9406 USD, Sharpe -3.77, DD 94.1 %, 1% -75 USD, 5% -31 USD: perde al netto dei costi assunti
|
||||
2_averaging;Off x1.0;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
|
||||
2_averaging;Grid x1.0;2123;0.5087;-9689.2745;-3.2225;0.9693;-115.115;-55.905;0.282;3.1382;0;0;2123 basket, win rate 51 %, netto -9689 USD, Sharpe -3.22, DD 96.9 %, 1% -115 USD, 5% -56 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
2_averaging;Grid x1.5;1980;0.5066;-9740.7279;-3.2036;0.9745;-121.6451;-59.5631;0.2406;3.1524;0;0;1980 basket, win rate 51 %, netto -9741 USD, Sharpe -3.20, DD 97.4 %, 1% -122 USD, 5% -60 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
3_stop;con stop;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
3_stop;senza stop;1844;0.5542;-9228.1048;-2.3634;0.9251;-136.115;-67.6782;-0.1898;3.1101;0;0;1844 basket, win rate 55 %, netto -9228 USD, Sharpe -2.36, DD 92.5 %, 1% -136 USD, 5% -68 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
|
||||
4_cost_gate;2x;2135;0.4843;-9800.3487;-3.7111;0.9803;-93.8542;-45.5499;-0.7129;3.5108;0;0;2135 basket, win rate 48 %, netto -9800 USD, Sharpe -3.71, DD 98.0 %, 1% -94 USD, 5% -46 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;3x;2224;0.5018;-9608.2555;-3.3792;0.9612;-98.1631;-50.2921;-0.3966;3.1231;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, 1% -98 USD, 5% -50 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;4x;0;;0;0;0;;;;;0.5;0;0 basket, win rate NaN, netto 0 USD, Sharpe 0.00, DD 0.0 %, 1% NaN USD, 5% NaN USD: perde al netto dei costi assunti
|
||||
5_inverso;segnale invertito;1824;0.4331;-9735.0255;-4.0007;0.974;-82.2238;-45.0301;-2.5283;3.1747;0;0;1824 basket, win rate 43 %, netto -9735 USD, Sharpe -4.00, DD 97.4 %, 1% -82 USD, 5% -45 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
|
||||
|
@@ -0,0 +1,14 @@
|
||||
test;variante;n_baskets;win_rate;pnl_net;sharpe;maxdd;p1_pnl;p5_pnl;break_even_cost;avg_cost_pips;psr;dsr;motivazione
|
||||
1_segnale;ZScoreSynthetic;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
1_segnale;PipDivergence;2877;0.4282;-9783.9713;-4.2544;0.9786;-74.479;-33.8654;-0.4263;3.1921;0;0;2877 basket, win rate 43 %, netto -9784 USD, Sharpe -4.25, DD 97.9 %, 1% -74 USD, 5% -34 USD: perde al netto dei costi assunti
|
||||
2_averaging;Off x1.0;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
|
||||
2_averaging;Grid x1.0;1821;0.4811;-9800.0151;-3.6263;0.9802;-115.1734;-58.7312;0.3851;3.2102;0;0;1821 basket, win rate 48 %, netto -9800 USD, Sharpe -3.63, DD 98.0 %, 1% -115 USD, 5% -59 USD: perde al netto dei costi assunti
|
||||
2_averaging;AddOnce x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
2_averaging;Grid x1.5;1673;0.4728;-9802.2249;-3.5831;0.9805;-116.0597;-64.5705;0.2809;3.2087;0;0;1673 basket, win rate 47 %, netto -9802 USD, Sharpe -3.58, DD 98.0 %, 1% -116 USD, 5% -65 USD: moltiplicatore 1,5 ammesso solo qui, in backtest, per mostrare la coda; il bot usa 1,0
|
||||
3_stop;con stop;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
3_stop;senza stop;1949;0.5387;-9795.9761;-3.054;0.9799;-107.4238;-51.7478;-0.3737;3.1928;0;0;1949 basket, win rate 54 %, netto -9796 USD, Sharpe -3.05, DD 98.0 %, 1% -107 USD, 5% -52 USD: il win rate sale ma la coda delle perdite e il drawdown dicono dove finisce il rischio; è il motivo per cui lo stop è obbligatorio
|
||||
4_cost_gate;2x;2052;0.4771;-9800.0224;-3.7234;0.9802;-93.1449;-44.9303;-0.6448;3.4791;0;0;2052 basket, win rate 48 %, netto -9800 USD, Sharpe -3.72, DD 98.0 %, 1% -93 USD, 5% -45 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;3x;2076;0.4769;-9801.9491;-3.8807;0.9804;-96.1599;-51.6818;-0.4892;3.2085;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, 1% -96 USD, 5% -52 USD: perde al netto dei costi assunti
|
||||
4_cost_gate;4x;3;0.3333;-81.1762;-0.3256;0.0081;-72.0723;-72.0723;3.8259;2.4592;0.0228;0;3 basket, win rate 33 %, netto -81 USD, Sharpe -0.33, DD 0.8 %, 1% -72 USD, 5% -72 USD: perde al netto dei costi assunti
|
||||
5_inverso;segnale invertito;1537;0.3852;-9800.9519;-4.3499;0.9801;-83.2267;-48.4287;-2.8935;3.2095;0;0;1537 basket, win rate 39 %, netto -9801 USD, Sharpe -4.35, DD 98.0 %, 1% -83 USD, 5% -48 USD: se anche il segnale invertito ha un break-even vicino a zero, il segnale non contiene informazione e il risultato è il solo costo
|
||||
|
@@ -0,0 +1,8 @@
|
||||
voce;valore;motivazione
|
||||
periodo;2018-12-12 → 2026-09-15;barre M15 dai tick MT5, spread minimo = tipico eToro, slippage 0.3 pip/gamba, overnight 0.3 pip/gamba/giorno
|
||||
prove;57;ogni configurazione provata conta nel Sharpe deflazionato
|
||||
baseline Moderate;Sharpe -3.38, netto -9608 USD, 2224 basket, win rate 50 %;la configurazione di fabbrica così com'è
|
||||
migliore;BASE-CON: Sharpe 0.00, DSR 0.000;non distinguibile dalla selezione fra le prove
|
||||
PBO;0.000;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
|
||||
walk-forward;Sharpe -0.75, DD 13.2 %, PSR 0.005;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
|
||||
verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
|
||||
|
@@ -0,0 +1,8 @@
|
||||
voce;valore;motivazione
|
||||
periodo;2018-12-12 → 2026-09-16;barre M15 dai tick MT5, spread minimo = nessuno (spread dei tick), slippage 0.3 pip/gamba, overnight 0.9 pip/gamba/giorno
|
||||
prove;57;ogni configurazione provata conta nel Sharpe deflazionato
|
||||
baseline Moderate;Sharpe -3.88, netto -9802 USD, 2076 basket, win rate 48 %;la configurazione di fabbrica così com'è
|
||||
migliore;T013: Sharpe -0.10, DSR 0.000;non distinguibile dalla selezione fra le prove
|
||||
PBO;0.001;probabilità che la scelta in-sample sia sotto la mediana out-of-sample (CSCV, 16 blocchi); sotto 0,5 è il cancello
|
||||
walk-forward;Sharpe -0.91, DD 13.5 %, PSR 0.000;cosa avrebbe reso la procedura 'scegli il migliore degli ultimi 6 mesi, applicalo un mese' su 88 mesi
|
||||
verdetto;negativo;NESSUNA configurazione profittevole al netto dei costi assunti: la strategia non regge i costi di eToro su questi dati
|
||||
|
@@ -0,0 +1,58 @@
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
BASE-CON;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
BASE-MOD;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2224;0.5018;-9608.2555;-3.38;0.9612;-0.3966;3.1231;-98.1631;-50.2921;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
BASE-AGG;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;2219;0.5273;-9801.7437;-3.605;0.9808;-0.154;3.4299;-107.4078;-54.9455;0;0;2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T001;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T002;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T003;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T004;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T005;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T006;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T007;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T008;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T009;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T010;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T011;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T012;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T013;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T014;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.5;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T015;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T016;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.6;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T017;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T018;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.7;3;1;0;;0;0;0;;;;;0.5;0;nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme
|
||||
T019;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.5;3;1;2273;0.5095;-9776.971;-3.676;0.9777;-0.5181;3.1978;-100.8336;-48.8858;0;0;2273 basket, win rate 51 %, netto -9777 USD, Sharpe -3.68, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T020;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.5;3;1;2361;0.5078;-9780.3682;-3.8412;0.9781;-0.4561;3.1972;-93.6221;-45.8502;0;0;2361 basket, win rate 51 %, netto -9780 USD, Sharpe -3.84, DD 97.8 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T021;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.6;3;1;2114;0.5019;-9777.7272;-3.7077;0.9778;-0.9027;3.1945;-97.6587;-50.0165;0;0;2114 basket, win rate 50 %, netto -9778 USD, Sharpe -3.71, DD 97.8 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T022;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.6;3;1;2186;0.4973;-9775.8854;-3.9248;0.9777;-0.7664;3.1947;-91.6099;-48.0941;0;0;2186 basket, win rate 50 %, netto -9776 USD, Sharpe -3.92, DD 97.8 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T023;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.7;3;1;1904;0.4806;-9758.7378;-3.9609;0.9759;-1.3315;3.1716;-104.8435;-53.09;0;0;1904 basket, win rate 48 %, netto -9759 USD, Sharpe -3.96, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
|
||||
T024;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.7;3;1;1943;0.4668;-9761.2755;-4.0828;0.9761;-1.3364;3.1729;-99.5743;-52.3411;0;0;1943 basket, win rate 47 %, netto -9761 USD, Sharpe -4.08, DD 97.6 %, costo medio 3.2 pip, break-even -1.3 pip: perde al netto dei costi
|
||||
T025;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.5;3;1;2386;0.4987;-9698.3332;-3.4922;0.9703;-0.5028;3.1298;-97.1191;-49.2531;0;0;2386 basket, win rate 50 %, netto -9698 USD, Sharpe -3.49, DD 97.0 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T026;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.5;3;1;2325;0.4886;-9733.1429;-3.7736;0.9737;-0.716;3.1401;-89.9765;-47.1863;0;0;2325 basket, win rate 49 %, netto -9733 USD, Sharpe -3.77, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T027;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2224;0.5018;-9608.2555;-3.38;0.9612;-0.3966;3.1231;-98.1631;-50.2921;0;0;2224 basket, win rate 50 %, netto -9608 USD, Sharpe -3.38, DD 96.1 %, costo medio 3.1 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T028;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.6;3;1;2232;0.4928;-9626.41;-3.5471;0.963;-0.4681;3.1283;-92.7272;-48.5099;0;0;2232 basket, win rate 49 %, netto -9626 USD, Sharpe -3.55, DD 96.3 %, costo medio 3.1 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T029;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.7;3;1;1620;0.4969;-9097.1212;-3.0336;0.9104;-0.7071;3.1249;-110.3729;-57.6683;0;0;1620 basket, win rate 50 %, netto -9097 USD, Sharpe -3.03, DD 91.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T030;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.7;3;1;1659;0.4846;-9151.1206;-3.1928;0.9158;-0.6745;3.125;-105.9072;-54.6317;0;0;1659 basket, win rate 48 %, netto -9151 USD, Sharpe -3.19, DD 91.6 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T031;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.5;3;1;1724;0.518;-8946.7157;-2.9015;0.8957;-0.8274;3.1056;-96.5951;-58.2571;0;0;1724 basket, win rate 52 %, netto -8947 USD, Sharpe -2.90, DD 89.6 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T032;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.5;3;1;1750;0.5114;-8984.9522;-3.0214;0.8994;-0.7697;3.1053;-94.7592;-56.1468;0;0;1750 basket, win rate 51 %, netto -8985 USD, Sharpe -3.02, DD 89.9 %, costo medio 3.1 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T033;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.6;3;1;1524;0.523;-8546.383;-2.6559;0.856;-0.5673;3.1088;-108.0414;-60.4233;0;0;1524 basket, win rate 52 %, netto -8546 USD, Sharpe -2.66, DD 85.6 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T034;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.6;3;1;1555;0.5125;-8644.9455;-2.7834;0.8655;-0.5618;3.1095;-104.0531;-58.2323;0;0;1555 basket, win rate 51 %, netto -8645 USD, Sharpe -2.78, DD 86.5 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T035;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.7;3;1;1057;0.5232;-7365.987;-2.1831;0.7384;-0.7041;3.1237;-120.7347;-71.8169;0;0;1057 basket, win rate 52 %, netto -7366 USD, Sharpe -2.18, DD 73.8 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T036;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.7;3;1;1080;0.513;-7548.9926;-2.318;0.7562;-0.8988;3.124;-116.8562;-67.6427;0;0;1080 basket, win rate 51 %, netto -7549 USD, Sharpe -2.32, DD 75.6 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T037;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.5;3;1;2324;0.503;-9801.8989;-3.7439;0.9802;0.1247;3.453;-94.1066;-44.0792;0;0;2324 basket, win rate 50 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T038;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.5;3;1;2505;0.5022;-9801.8568;-3.8586;0.9802;0.2991;3.4527;-85.3644;-37.9877;0;0;2505 basket, win rate 50 %, netto -9802 USD, Sharpe -3.86, DD 98.0 %, costo medio 3.5 pip, break-even 0.3 pip: perde al netto dei costi
|
||||
T039;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.6;3;1;2206;0.4946;-9801.6714;-3.7449;0.9802;-0.122;3.4445;-95.2361;-46.4834;0;0;2206 basket, win rate 49 %, netto -9802 USD, Sharpe -3.74, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T040;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.6;3;1;2382;0.5008;-9801.8364;-3.8898;0.9802;0.1249;3.4433;-91.5065;-39.2958;0;0;2382 basket, win rate 50 %, netto -9802 USD, Sharpe -3.89, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T041;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.7;3;1;2066;0.4782;-9800.3225;-3.9569;0.9801;-0.3695;3.4129;-106.8203;-50.7168;0;0;2066 basket, win rate 48 %, netto -9800 USD, Sharpe -3.96, DD 98.0 %, costo medio 3.4 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T042;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.7;3;1;2228;0.478;-9800.5097;-4.0478;0.9802;-0.0551;3.4114;-99.1796;-46.4296;0;0;2228 basket, win rate 48 %, netto -9801 USD, Sharpe -4.05, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T043;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.5;3;1;2333;0.5362;-9800.5583;-3.6212;0.9807;-0.1462;3.455;-114.2527;-52.729;0;0;2333 basket, win rate 54 %, netto -9801 USD, Sharpe -3.62, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T044;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.5;3;1;2418;0.5434;-9801.6954;-3.7353;0.9807;-0.0542;3.4505;-106.0593;-47.7018;0;0;2418 basket, win rate 54 %, netto -9802 USD, Sharpe -3.74, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T045;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;2219;0.5273;-9801.7437;-3.605;0.9808;-0.154;3.4299;-107.4078;-54.9455;0;0;2219 basket, win rate 53 %, netto -9802 USD, Sharpe -3.61, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T046;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.6;3;1;2299;0.5385;-9800.1165;-3.8043;0.9805;-0.0498;3.4277;-102.5053;-49.1087;0;0;2299 basket, win rate 54 %, netto -9800 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T047;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.7;3;1;2034;0.5103;-9801.176;-3.5533;0.9805;-0.7064;3.3793;-110.5845;-56.0264;0;0;2034 basket, win rate 51 %, netto -9801 USD, Sharpe -3.55, DD 98.1 %, costo medio 3.4 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T048;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.7;3;1;2111;0.5216;-9801.5062;-3.6808;0.9805;-0.5194;3.3807;-102.1236;-50.7364;0;0;2111 basket, win rate 52 %, netto -9802 USD, Sharpe -3.68, DD 98.1 %, costo medio 3.4 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T049;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.5;3;1;2535;0.5582;-9800.4562;-3.2691;0.9804;-0.1898;3.4255;-122.7526;-63.8002;0;0;2535 basket, win rate 56 %, netto -9800 USD, Sharpe -3.27, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T050;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.5;3;1;2652;0.5618;-9800.054;-3.34;0.9804;-0.12;3.4243;-117.3288;-56.7329;0;0;2652 basket, win rate 56 %, netto -9800 USD, Sharpe -3.34, DD 98.0 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T051;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.6;3;1;2611;0.558;-9800.3025;-3.3139;0.9804;0.0259;3.3951;-122.3324;-61.2879;0;0;2611 basket, win rate 56 %, netto -9800 USD, Sharpe -3.31, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
T052;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.6;3;1;2722;0.5643;-9800.3917;-3.4336;0.9804;0.1646;3.3925;-117.9914;-53.3356;0;0;2722 basket, win rate 56 %, netto -9800 USD, Sharpe -3.43, DD 98.0 %, costo medio 3.4 pip, break-even 0.2 pip: perde al netto dei costi
|
||||
T053;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.7;3;1;2549;0.5457;-9799.5834;-3.5684;0.9806;-0.0172;3.3539;-130.1295;-56.5296;0;0;2549 basket, win rate 55 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T054;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.7;3;1;2602;0.5473;-9802.9899;-3.6937;0.9808;-0.0068;3.3538;-125.0607;-49.82;0;0;2602 basket, win rate 55 %, netto -9803 USD, Sharpe -3.69, DD 98.1 %, costo medio 3.4 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
|
@@ -0,0 +1,58 @@
|
||||
trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione
|
||||
BASE-CON;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
BASE-MOD;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2076;0.4769;-9801.9491;-3.8807;0.9804;-0.4892;3.2085;-96.1599;-51.6818;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
BASE-AGG;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;1995;0.5143;-9800.4836;-3.7229;0.9806;-0.1782;3.4269;-113.7756;-55.2381;0;0;1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T001;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.5;3;1;8;0.5;-69.5629;-0.2771;0.0086;4.9254;2.5879;-80.2036;-80.2036;0.1196;0;8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
|
||||
T002;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.5;3;1;8;0.375;-110.4189;-0.3764;0.0127;3.8879;2.5879;-80.2036;-80.2036;0.0455;0;8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
|
||||
T003;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.6;3;1;8;0.5;-69.5629;-0.2771;0.0086;4.9254;2.5879;-80.2036;-80.2036;0.1196;0;8 basket, win rate 50 %, netto -70 USD, Sharpe -0.28, DD 0.9 %, costo medio 2.6 pip, break-even 4.9 pip: perde al netto dei costi
|
||||
T004;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.6;3;1;8;0.375;-110.4189;-0.3764;0.0127;3.8879;2.5879;-80.2036;-80.2036;0.0455;0;8 basket, win rate 38 %, netto -110 USD, Sharpe -0.38, DD 1.3 %, costo medio 2.6 pip, break-even 3.9 pip: perde al netto dei costi
|
||||
T005;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;60;0.7;3;1;3;0.3333;-16.5569;-0.2092;0.0024;10.6949;2.5283;-24.5014;-24.5014;0.1936;0;3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
|
||||
T006;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;60;0.7;3;1;3;0.3333;-16.5569;-0.2092;0.0024;10.6949;2.5283;-24.5014;-24.5014;0.1936;0;3 basket, win rate 33 %, netto -17 USD, Sharpe -0.21, DD 0.2 %, costo medio 2.5 pip, break-even 10.7 pip: perde al netto dei costi
|
||||
T007;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.5;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T008;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.5;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T009;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T010;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.6;3;1;5;0.4;-68.6995;-0.3203;0.0092;5.8625;2.5825;-60.6171;-60.6171;0.0947;0;5 basket, win rate 40 %, netto -69 USD, Sharpe -0.32, DD 0.9 %, costo medio 2.6 pip, break-even 5.9 pip: perde al netto dei costi
|
||||
T011;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;100;0.7;3;1;3;0.3333;-31.767;-0.2049;0.0048;5.9618;2.5284;-38.4174;-38.4174;0.2014;0;3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
|
||||
T012;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;100;0.7;3;1;3;0.3333;-31.767;-0.2049;0.0048;5.9618;2.5284;-38.4174;-38.4174;0.2014;0;3 basket, win rate 33 %, netto -32 USD, Sharpe -0.20, DD 0.5 %, costo medio 2.5 pip, break-even 6.0 pip: perde al netto dei costi
|
||||
T013;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.5;3;1;8;0.625;-44.5609;-0.1017;0.0143;0.1426;2.3801;-111.7539;-111.7539;0.3732;0;8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T014;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.5;3;1;8;0.625;-44.5609;-0.1017;0.0143;0.1426;2.3801;-111.7539;-111.7539;0.3732;0;8 basket, win rate 62 %, netto -45 USD, Sharpe -0.10, DD 1.4 %, costo medio 2.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T015;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.6;3;1;6;0.5;-113.611;-0.2917;0.0152;-4.221;2.629;-111.7539;-111.7539;0.1381;0;6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
|
||||
T016;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.6;3;1;6;0.5;-113.611;-0.2917;0.0152;-4.221;2.629;-111.7539;-111.7539;0.1381;0;6 basket, win rate 50 %, netto -114 USD, Sharpe -0.29, DD 1.5 %, costo medio 2.6 pip, break-even -4.2 pip: perde al netto dei costi
|
||||
T017;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.25;3;8;150;0.7;3;1;4;0.5;-100.5194;-0.2632;0.0139;-8.8621;2.6129;-111.7035;-111.7035;0.1631;0;4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
|
||||
T018;Conservative;ZScoreSynthetic;First;Off;1;2.5;0.5;3;8;150;0.7;3;1;4;0.5;-100.5194;-0.2632;0.0139;-8.8621;2.6129;-111.7035;-111.7035;0.1631;0;4 basket, win rate 50 %, netto -101 USD, Sharpe -0.26, DD 1.4 %, costo medio 2.6 pip, break-even -8.9 pip: perde al netto dei costi
|
||||
T019;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.5;3;1;2018;0.4792;-9800.6546;-4.1396;0.9801;-0.7742;3.2141;-103.5455;-51.4822;0;0;2018 basket, win rate 48 %, netto -9801 USD, Sharpe -4.14, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T020;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.5;3;1;2192;0.4886;-9803.1597;-4.2704;0.9803;-0.5484;3.2139;-97.7234;-46.9665;0;0;2192 basket, win rate 49 %, netto -9803 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T021;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.6;3;1;1972;0.4787;-9801.7849;-4.0332;0.9802;-0.9003;3.2155;-100.0295;-49.8798;0;0;1972 basket, win rate 48 %, netto -9802 USD, Sharpe -4.03, DD 98.0 %, costo medio 3.2 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T022;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.6;3;1;2069;0.4819;-9803.6976;-4.2507;0.9804;-0.782;3.2167;-93.6889;-48.8346;0;0;2069 basket, win rate 48 %, netto -9804 USD, Sharpe -4.25, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T023;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;60;0.7;3;1;2055;0.4818;-9801.1624;-4.0098;0.9801;-0.5217;3.2143;-102.1531;-50.7697;0;0;2055 basket, win rate 48 %, netto -9801 USD, Sharpe -4.01, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T024;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;60;0.7;3;1;2132;0.4855;-9801.1437;-4.1302;0.9801;-0.4595;3.2148;-98.965;-47.3869;0;0;2132 basket, win rate 49 %, netto -9801 USD, Sharpe -4.13, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T025;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.5;3;1;2101;0.4731;-9801.082;-3.7918;0.9804;-0.5503;3.2078;-94.4597;-50.6244;0;0;2101 basket, win rate 47 %, netto -9801 USD, Sharpe -3.79, DD 98.0 %, costo medio 3.2 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T026;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.5;3;1;2140;0.4738;-9801.717;-3.9388;0.9804;-0.5081;3.2089;-80.9489;-48.5339;0;0;2140 basket, win rate 47 %, netto -9802 USD, Sharpe -3.94, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T027;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.6;3;1;2076;0.4769;-9801.9491;-3.8807;0.9804;-0.4892;3.2085;-96.1599;-51.6818;0;0;2076 basket, win rate 48 %, netto -9802 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T028;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.6;3;1;2108;0.4782;-9801.8954;-4.0225;0.9803;-0.4629;3.2091;-87.7027;-50.0178;0;0;2108 basket, win rate 48 %, netto -9802 USD, Sharpe -4.02, DD 98.0 %, costo medio 3.2 pip, break-even -0.5 pip: perde al netto dei costi
|
||||
T029;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;100;0.7;3;1;1984;0.4582;-9797.6241;-4.1086;0.9799;-0.798;3.1973;-92.7908;-47.5743;0;0;1984 basket, win rate 46 %, netto -9798 USD, Sharpe -4.11, DD 98.0 %, costo medio 3.2 pip, break-even -0.8 pip: perde al netto dei costi
|
||||
T030;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;100;0.7;3;1;2040;0.4574;-9797.1369;-4.2728;0.9799;-0.7345;3.1987;-87.6021;-45.6211;0;0;2040 basket, win rate 46 %, netto -9797 USD, Sharpe -4.27, DD 98.0 %, costo medio 3.2 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T031;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.5;3;1;2238;0.4915;-9733.8654;-3.7948;0.9738;-0.5971;3.1445;-90.112;-47.5574;0;0;2238 basket, win rate 49 %, netto -9734 USD, Sharpe -3.79, DD 97.4 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T032;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.5;3;1;2247;0.4887;-9738.5622;-3.9701;0.9742;-0.6768;3.1479;-89.2127;-45.6445;0;0;2247 basket, win rate 49 %, netto -9739 USD, Sharpe -3.97, DD 97.4 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T033;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.6;3;1;2090;0.4856;-9697.0682;-3.7782;0.97;-0.7223;3.1429;-95.5498;-51.6315;0;0;2090 basket, win rate 49 %, netto -9697 USD, Sharpe -3.78, DD 97.0 %, costo medio 3.1 pip, break-even -0.7 pip: perde al netto dei costi
|
||||
T034;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.6;3;1;2120;0.4863;-9681.6883;-3.8261;0.9684;-0.5759;3.1425;-96.173;-48.981;0;0;2120 basket, win rate 49 %, netto -9682 USD, Sharpe -3.83, DD 96.8 %, costo medio 3.1 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T035;Moderate;ZScoreSynthetic;First;Off;1;2;0.25;3.5;10;150;0.7;3;1;1616;0.4901;-9304.7978;-3.3946;0.9309;-0.9725;3.1466;-103.2808;-55.1427;0;0;1616 basket, win rate 49 %, netto -9305 USD, Sharpe -3.39, DD 93.1 %, costo medio 3.1 pip, break-even -1.0 pip: perde al netto dei costi
|
||||
T036;Moderate;ZScoreSynthetic;First;Off;1;2;0.5;3.5;10;150;0.7;3;1;1650;0.4818;-9315.3955;-3.4957;0.9319;-0.9219;3.1469;-103.8865;-53.903;0;0;1650 basket, win rate 48 %, netto -9315 USD, Sharpe -3.50, DD 93.2 %, costo medio 3.1 pip, break-even -0.9 pip: perde al netto dei costi
|
||||
T037;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.5;3;1;2222;0.5018;-9800.1789;-3.493;0.9801;0.1139;3.4884;-95.5877;-43.0006;0;0;2222 basket, win rate 50 %, netto -9800 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.5 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T038;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.5;3;1;2319;0.5071;-9801.3643;-3.6642;0.9802;0.1721;3.4888;-90.1871;-38.1818;0;0;2319 basket, win rate 51 %, netto -9801 USD, Sharpe -3.66, DD 98.0 %, costo medio 3.5 pip, break-even 0.2 pip: perde al netto dei costi
|
||||
T039;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.6;3;1;2068;0.4971;-9802.4311;-3.6774;0.9803;-0.3555;3.4622;-96.564;-45.9072;0;0;2068 basket, win rate 50 %, netto -9802 USD, Sharpe -3.68, DD 98.0 %, costo medio 3.5 pip, break-even -0.4 pip: perde al netto dei costi
|
||||
T040;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.6;3;1;2261;0.5042;-9801.8015;-3.7059;0.9802;-0.0458;3.4572;-87.7666;-43.2972;0;0;2261 basket, win rate 50 %, netto -9802 USD, Sharpe -3.71, DD 98.0 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T041;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;60;0.7;3;1;2032;0.4818;-9803.4939;-3.8824;0.9804;-0.2081;3.3942;-102.0523;-51.4273;0;0;2032 basket, win rate 48 %, netto -9803 USD, Sharpe -3.88, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T042;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;60;0.7;3;1;2223;0.4876;-9799.9736;-4.122;0.9801;0.1232;3.3884;-96.1687;-46.1443;0;0;2223 basket, win rate 49 %, netto -9800 USD, Sharpe -4.12, DD 98.0 %, costo medio 3.4 pip, break-even 0.1 pip: perde al netto dei costi
|
||||
T043;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.5;3;1;2161;0.5298;-9800.4196;-3.5742;0.9806;-0.0636;3.461;-110.6802;-51.9967;0;0;2161 basket, win rate 53 %, netto -9800 USD, Sharpe -3.57, DD 98.1 %, costo medio 3.5 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T044;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.5;3;1;2289;0.5382;-9801.341;-3.7496;0.9806;-0.0314;3.4567;-99.7287;-47.5057;0;0;2289 basket, win rate 54 %, netto -9801 USD, Sharpe -3.75, DD 98.1 %, costo medio 3.5 pip, break-even -0.0 pip: perde al netto dei costi
|
||||
T045;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.6;3;1;1995;0.5143;-9800.4836;-3.7229;0.9806;-0.1782;3.4269;-113.7756;-55.2381;0;0;1995 basket, win rate 51 %, netto -9800 USD, Sharpe -3.72, DD 98.1 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T046;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.6;3;1;2092;0.5268;-9801.2355;-3.8187;0.9806;-0.1043;3.4231;-103.851;-49.8169;0;0;2092 basket, win rate 53 %, netto -9801 USD, Sharpe -3.82, DD 98.1 %, costo medio 3.4 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T047;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;100;0.7;3;1;1790;0.4955;-9801.26;-3.7117;0.9805;-1.011;3.3543;-107.8876;-56.2754;0;0;1790 basket, win rate 50 %, netto -9801 USD, Sharpe -3.71, DD 98.1 %, costo medio 3.4 pip, break-even -1.0 pip: perde al netto dei costi
|
||||
T048;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;100;0.7;3;1;1972;0.5137;-9802.9285;-3.7993;0.9806;-0.6154;3.3518;-97.0256;-50.7654;0;0;1972 basket, win rate 51 %, netto -9803 USD, Sharpe -3.80, DD 98.1 %, costo medio 3.4 pip, break-even -0.6 pip: perde al netto dei costi
|
||||
T049;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.5;3;1;2267;0.5483;-9800.1872;-3.3236;0.9804;-0.2425;3.4352;-127.609;-57.2805;0;0;2267 basket, win rate 55 %, netto -9800 USD, Sharpe -3.32, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T050;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.5;3;1;2289;0.547;-9800.6111;-3.4919;0.9804;-0.2567;3.4319;-121.7571;-55.2384;0;0;2289 basket, win rate 55 %, netto -9801 USD, Sharpe -3.49, DD 98.0 %, costo medio 3.4 pip, break-even -0.3 pip: perde al netto dei costi
|
||||
T051;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.6;3;1;2280;0.55;-9799.948;-3.3521;0.9804;-0.177;3.383;-116.3317;-59.2741;0;0;2280 basket, win rate 55 %, netto -9800 USD, Sharpe -3.35, DD 98.0 %, costo medio 3.4 pip, break-even -0.2 pip: perde al netto dei costi
|
||||
T052;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.6;3;1;2412;0.5547;-9800.0702;-3.4091;0.9804;0.0354;3.3835;-114.6115;-56.6657;0;0;2412 basket, win rate 55 %, netto -9800 USD, Sharpe -3.41, DD 98.0 %, costo medio 3.4 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
T053;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.25;4;12;150;0.7;3;1;2298;0.5361;-9802.0629;-3.5954;0.9808;-0.121;3.3223;-127.3343;-57.6739;0;0;2298 basket, win rate 54 %, netto -9802 USD, Sharpe -3.60, DD 98.1 %, costo medio 3.3 pip, break-even -0.1 pip: perde al netto dei costi
|
||||
T054;Aggressive;ZScoreSynthetic;First;Off;1;1.5;0.5;4;12;150;0.7;3;1;2441;0.5453;-9800.0711;-3.6641;0.9805;0.0417;3.3249;-123.3318;-50.6626;0;0;2441 basket, win rate 55 %, netto -9800 USD, Sharpe -3.66, DD 98.1 %, costo medio 3.3 pip, break-even 0.0 pip: perde al netto dei costi
|
||||
|
@@ -0,0 +1,42 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Compila e avvia Encelado in locale, senza container, nella sandbox deploy/local.
|
||||
|
||||
.DESCRIPTION
|
||||
Stessa disposizione del container: deploy/local/config (encelado.json, strategy.json,
|
||||
chiavi cifrate) e deploy/local/data (ledger, stato, log). La cartella è ignorata da git.
|
||||
Senza ENCELADO_WEB_TOKEN il server ascolta solo su localhost e non chiede il login.
|
||||
Le chiavi eToro: variabili ETORO_API_KEY/ETORO_USER_KEY già nell'ambiente, oppure
|
||||
Impostazioni ▸ Chiavi eToro con ENCELADO_KEY_PASSPHRASE impostata.
|
||||
|
||||
scripts\run-dev.ps1 # motore fermo: premi AVVIA nella pagina
|
||||
scripts\run-dev.ps1 -Autostart # motore avviato subito (servono le chiavi)
|
||||
scripts\run-dev.ps1 -Sample # dati finti, nessuna chiave: per guardare le pagine
|
||||
scripts\run-dev.ps1 -Port 8090
|
||||
#>
|
||||
param(
|
||||
[int] $Port = 8080,
|
||||
[switch] $Autostart,
|
||||
[switch] $Sample,
|
||||
[switch] $NoBrowser
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
$env:ENCELADO_CONFIG_DIR = Join-Path $root "deploy\local\config"
|
||||
$env:ENCELADO_DATA_DIR = Join-Path $root "deploy\local\data"
|
||||
$env:ENCELADO_WEB_PORT = "$Port"
|
||||
New-Item -ItemType Directory -Force $env:ENCELADO_CONFIG_DIR, $env:ENCELADO_DATA_DIR | Out-Null
|
||||
|
||||
dotnet build src/Encelado.Server -c Debug -nologo -v q
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$args = @()
|
||||
if ($Sample) { $args += "--sample" } elseif (-not $Autostart) { $args += "--no-autostart" }
|
||||
$url = "http://localhost:$Port/"
|
||||
Write-Host "Encelado in locale: $url (configurazione in deploy\local\config, dati in deploy\local\data)" -ForegroundColor Cyan
|
||||
Write-Host "Comandi da tastiera: status, close <basket>, kill, residuo, preset <nome>, reset <motivazione>, bonifica, stop. Ctrl+C = arresto." -ForegroundColor DarkGray
|
||||
if (-not $NoBrowser) { Start-Job -ScriptBlock { param($u) Start-Sleep -Seconds 3; Start-Process $u } -ArgumentList $url | Out-Null }
|
||||
|
||||
dotnet src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll @args
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env sh
|
||||
# Builds and starts Encelado locally, without the container, in the deploy/local sandbox
|
||||
# (deploy/local/config as /config, deploy/local/data as /data; ignored by git).
|
||||
# sh scripts/run-dev.sh # engine stopped: press AVVIA in the page
|
||||
# sh scripts/run-dev.sh --sample # fake data, no keys: to look at the pages
|
||||
# sh scripts/run-dev.sh --autostart # engine started at once (keys needed)
|
||||
# Without ENCELADO_WEB_TOKEN the server listens on localhost only and asks no login.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
export ENCELADO_CONFIG_DIR="$(pwd)/deploy/local/config"
|
||||
export ENCELADO_DATA_DIR="$(pwd)/deploy/local/data"
|
||||
export ENCELADO_WEB_PORT="${ENCELADO_WEB_PORT:-8080}"
|
||||
mkdir -p "$ENCELADO_CONFIG_DIR" "$ENCELADO_DATA_DIR"
|
||||
|
||||
MODE="--no-autostart"
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--sample) MODE="--sample" ;;
|
||||
--autostart) MODE="" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
dotnet build src/Encelado.Server -c Debug -nologo -v q
|
||||
echo "Encelado in locale: http://localhost:$ENCELADO_WEB_PORT/ (config in deploy/local/config, dati in deploy/local/data)"
|
||||
exec dotnet src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll $MODE
|
||||
@@ -0,0 +1,40 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Costruisce l'immagine e avvia Encelado nel container con la docker-compose.yml alla radice.
|
||||
|
||||
.DESCRIPTION
|
||||
Le cartelle deploy/local/config e deploy/local/data vengono montate come /config e /data:
|
||||
sono le stesse di scripts\run-dev.ps1, quindi configurazione e chiavi salvate valgono
|
||||
in entrambi i modi. Il token dell'interfaccia è "sviluppo" salvo ENCELADO_WEB_TOKEN in
|
||||
un file .env accanto alla compose (dove vanno anche ETORO_API_KEY, ETORO_USER_KEY,
|
||||
TELEGRAM_*). Segue il log finché non premi Ctrl+C; il container resta acceso:
|
||||
scripts\run-docker.ps1 -Down per fermarlo (SIGTERM, stop pulito).
|
||||
|
||||
scripts\run-docker.ps1 # build + up -d + log
|
||||
scripts\run-docker.ps1 -NoBuild # senza ricostruire l'immagine
|
||||
scripts\run-docker.ps1 -Down # docker compose down
|
||||
#>
|
||||
param(
|
||||
[switch] $NoBuild,
|
||||
[switch] $Down,
|
||||
[switch] $NoBrowser
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
if ($Down) { docker compose down; exit $LASTEXITCODE }
|
||||
|
||||
New-Item -ItemType Directory -Force "deploy\local\config", "deploy\local\data" | Out-Null
|
||||
$env:GIT_COMMIT = (git rev-parse --short=12 HEAD 2>$null); if (-not $env:GIT_COMMIT) { $env:GIT_COMMIT = "n/d" }
|
||||
$env:PUID = "1000"; $env:PGID = "1000"
|
||||
|
||||
if ($NoBuild) { docker compose up -d } else { docker compose up -d --build }
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$port = if ($env:ENCELADO_WEB_PORT) { $env:ENCELADO_WEB_PORT } else { "8080" }
|
||||
$token = if ($env:ENCELADO_WEB_TOKEN) { "(da ENCELADO_WEB_TOKEN)" } else { "sviluppo" }
|
||||
$url = "http://localhost:$port/"
|
||||
Write-Host "Encelado nel container: $url token: $token (docker compose down per fermare)" -ForegroundColor Cyan
|
||||
if (-not $NoBrowser) { Start-Job -ScriptBlock { param($u) Start-Sleep -Seconds 4; Start-Process $u } -ArgumentList $url | Out-Null }
|
||||
docker compose logs -f encelado
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env sh
|
||||
# Builds the image and starts Encelado in the container with the docker-compose.yml at the root.
|
||||
# deploy/local/config and deploy/local/data are mounted as /config and /data (the same
|
||||
# folders scripts/run-dev.sh uses). Token "sviluppo" unless ENCELADO_WEB_TOKEN is in .env.
|
||||
# sh scripts/run-docker.sh # build + up -d + follow the log (Ctrl+C leaves it running)
|
||||
# sh scripts/run-docker.sh --no-build
|
||||
# sh scripts/run-docker.sh --down # docker compose down (SIGTERM, clean stop)
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
case "${1:-}" in
|
||||
--down) exec docker compose down ;;
|
||||
esac
|
||||
mkdir -p deploy/local/config deploy/local/data
|
||||
export GIT_COMMIT="$(git rev-parse --short=12 HEAD 2>/dev/null || echo n/d)"
|
||||
export PUID="${PUID:-1000}" PGID="${PGID:-1000}"
|
||||
if [ "${1:-}" = "--no-build" ]; then docker compose up -d; else docker compose up -d --build; fi
|
||||
echo "Encelado nel container: http://localhost:${ENCELADO_WEB_PORT:-8080}/ token: ${ENCELADO_WEB_TOKEN:-sviluppo} (sh scripts/run-docker.sh --down per fermare)"
|
||||
exec docker compose logs -f encelado
|
||||
@@ -0,0 +1,47 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Rigenera docs/img/*.png: server in modalità campione (--sample) e Edge headless su ogni pagina.
|
||||
|
||||
.DESCRIPTION
|
||||
Niente chiavi, niente mercato: lo snapshot è finto (SampleSnapshot). Serve Microsoft Edge.
|
||||
La pagina viene aperta con ?once=1 (una sola lettura dello snapshot, nessuno stream), altrimenti
|
||||
il browser headless non considera mai il caricamento concluso.
|
||||
|
||||
scripts\screenshots.ps1 [-Port 58085]
|
||||
#>
|
||||
param([int] $Port = 58085)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
$edge = @("$env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $edge) { throw "Microsoft Edge non trovato" }
|
||||
|
||||
dotnet build src/Encelado.Server -c Debug -nologo -v q
|
||||
$work = Join-Path $env:TEMP ("encelado-shot-" + [guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Force "$work\config", "docs\img" | Out-Null
|
||||
$env:ENCELADO_CONFIG_DIR = "$work\config"
|
||||
$server = Start-Process -FilePath "dotnet" -ArgumentList "src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll", "--sample", "--port", "$Port" -PassThru -WindowStyle Hidden
|
||||
try {
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 30 -and -not $ready; $i++) { Start-Sleep -Seconds 1; try { $ready = (Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:$Port/api/health" -TimeoutSec 2).StatusCode -eq 200 } catch {} }
|
||||
if (-not $ready) { throw "il server campione non risponde sulla porta $Port" }
|
||||
$shots = @(
|
||||
@{ name = "dashboard"; page = "dashboard"; size = "1440,900" },
|
||||
@{ name = "storico"; page = "storico"; size = "1440,900" },
|
||||
@{ name = "log"; page = "log"; size = "1440,900" },
|
||||
@{ name = "impostazioni"; page = "impostazioni"; size = "1440,900" },
|
||||
@{ name = "mobile"; page = "dashboard"; size = "420,860" }
|
||||
)
|
||||
foreach ($s in $shots) {
|
||||
$out = Join-Path $root ("docs\img\" + $s.name + ".png")
|
||||
$profile = Join-Path $work ("edge-" + $s.name)
|
||||
# Start-Process: Edge scrive avvisi su stderr e in PowerShell 5.1 diventerebbero errori bloccanti.
|
||||
$edgeArgs = @("--headless=new", "--disable-gpu", "--no-first-run", "--hide-scrollbars", "--user-data-dir=$profile", "--window-size=$($s.size)", "--virtual-time-budget=5000", "--screenshot=$out", "http://127.0.0.1:$Port/?once=1#/$($s.page)")
|
||||
Start-Process -FilePath $edge -ArgumentList $edgeArgs -Wait -NoNewWindow -RedirectStandardError "$profile.err" -RedirectStandardOutput "$profile.out"
|
||||
if (Test-Path $out) { Write-Host (" {0,-14} {1,7:N0} byte" -f $s.name, (Get-Item $out).Length) } else { Write-Warning "$($s.name): screenshot non scritto" }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force }
|
||||
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env sh
|
||||
# Regenerates docs/img/*.png: the server in sample mode (--sample) and a headless Edge/Chrome on every page.
|
||||
# No keys, no market: the snapshot is fake. Pages are opened with ?once=1 (one snapshot, no stream),
|
||||
# otherwise the headless browser never considers the page loaded.
|
||||
# sh scripts/screenshots.sh [port]
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
PORT="${1:-58085}"
|
||||
BROWSER="${ENCELADO_BROWSER:-}"
|
||||
for c in "/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" "/c/Program Files/Microsoft/Edge/Application/msedge.exe" "$(command -v microsoft-edge || true)" "$(command -v google-chrome || true)" "$(command -v chromium || true)"; do
|
||||
[ -n "$BROWSER" ] && break
|
||||
[ -n "$c" ] && [ -x "$c" ] && BROWSER="$c"
|
||||
done
|
||||
[ -n "$BROWSER" ] || { echo "nessun browser Chromium trovato (imposta ENCELADO_BROWSER)" >&2; exit 1; }
|
||||
|
||||
dotnet build src/Encelado.Server -c Debug -nologo -v q
|
||||
WORK="$(mktemp -d)"
|
||||
trap '[ -n "${PID:-}" ] && kill "$PID" 2>/dev/null; rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$WORK/config" docs/img
|
||||
ENCELADO_CONFIG_DIR="$WORK/config" dotnet src/Encelado.Server/bin/Debug/net10.0/Encelado.Server.dll --sample --port "$PORT" >/dev/null 2>&1 &
|
||||
PID=$!
|
||||
i=0; until curl -sf "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; do i=$((i+1)); [ "$i" -lt 30 ] || { echo "il server campione non risponde" >&2; exit 1; }; sleep 1; done
|
||||
|
||||
shot() { # shot <name> <page> <size>
|
||||
"$BROWSER" --headless=new --disable-gpu --no-first-run --hide-scrollbars "--user-data-dir=$WORK/edge-$1" "--window-size=$3" --virtual-time-budget=5000 "--screenshot=$(pwd)/docs/img/$1.png" "http://127.0.0.1:$PORT/?once=1#/$2" >/dev/null 2>&1 || true
|
||||
[ -s "docs/img/$1.png" ] && echo " $1: $(wc -c < "docs/img/$1.png") byte" || echo " $1: screenshot non scritto" >&2
|
||||
}
|
||||
shot dashboard dashboard 1440,900
|
||||
shot storico storico 1440,900
|
||||
shot log log 1440,900
|
||||
shot impostazioni impostazioni 1440,900
|
||||
shot mobile dashboard 420,860
|
||||
@@ -0,0 +1,255 @@
|
||||
using Encelado.Core.Baskets.Data;
|
||||
using Encelado.Core.Broker;
|
||||
|
||||
namespace Encelado.Core.Baskets.Backtest;
|
||||
|
||||
/// <summary>
|
||||
/// The replay venue. Fills at the current quote plus a configured slippage, keeps the
|
||||
/// account and the positions, charges overnight fees at 21:00 UTC. The clock is the
|
||||
/// backtest's, advanced bar by bar; every call answers synchronously.
|
||||
/// </summary>
|
||||
public sealed class BacktestBroker : IBroker
|
||||
{
|
||||
private readonly Dictionary<long, Instrument> _instruments = [];
|
||||
private readonly Dictionary<long, QuoteSnapshot> _quotes = [];
|
||||
private readonly Dictionary<long, Position> _positions = [];
|
||||
private readonly Dictionary<string, OrderOutcome> _orders = new(StringComparer.Ordinal);
|
||||
private readonly List<ClosedTrade> _closed = [];
|
||||
private readonly double _slippagePips;
|
||||
private readonly double _overnightPipsPerDay;
|
||||
private double _balance;
|
||||
private long _nextId = 1;
|
||||
private DateTime _now;
|
||||
private DateTime _lastOvernight = DateTime.MinValue;
|
||||
|
||||
private sealed class Position
|
||||
{
|
||||
public long Id;
|
||||
public Instrument Instrument = null!;
|
||||
public bool IsBuy;
|
||||
public double Units;
|
||||
public double Open;
|
||||
public DateTime OpenedUtc;
|
||||
public double Fees;
|
||||
public int Leverage;
|
||||
}
|
||||
|
||||
public BacktestBroker(IEnumerable<Instrument> instruments, double startingBalance, double slippagePips, double overnightPipsPerDay)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(instruments);
|
||||
foreach (Instrument i in instruments)
|
||||
{
|
||||
_instruments[i.Id] = i;
|
||||
}
|
||||
|
||||
_balance = startingBalance;
|
||||
StartingBalance = startingBalance;
|
||||
_slippagePips = slippagePips;
|
||||
_overnightPipsPerDay = overnightPipsPerDay;
|
||||
}
|
||||
|
||||
public double StartingBalance { get; }
|
||||
|
||||
public BrokerEnvironment Environment => BrokerEnvironment.Backtest;
|
||||
|
||||
public string Name => "backtest";
|
||||
|
||||
public bool SupportsTrading => true;
|
||||
|
||||
public TimeSpan ClockSkew => TimeSpan.Zero;
|
||||
|
||||
public DateTime Now => _now;
|
||||
|
||||
public IReadOnlyList<ClosedTrade> Closed => _closed;
|
||||
|
||||
/// <summary>Advances the clock and the quotes; charges overnight once per day at 21:00 UTC.</summary>
|
||||
public void Advance(DateTime nowUtc, IReadOnlyList<QuoteSnapshot> quotes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quotes);
|
||||
_now = nowUtc;
|
||||
foreach (QuoteSnapshot q in quotes)
|
||||
{
|
||||
if (q.IsValid)
|
||||
{
|
||||
_quotes[q.InstrumentId] = q;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime today21 = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, 21, 0, 0, DateTimeKind.Utc);
|
||||
if (nowUtc >= today21 && _lastOvernight < today21 && _positions.Count > 0)
|
||||
{
|
||||
int nights = nowUtc.DayOfWeek == DayOfWeek.Friday ? 3 : 1;
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
if (p.OpenedUtc < today21)
|
||||
{
|
||||
double pipUsd = PipMath.PipValueUsd(p.Instrument.Symbol, p.Units, MidOf);
|
||||
if (!double.IsNaN(pipUsd))
|
||||
{
|
||||
p.Fees += _overnightPipsPerDay * nights * pipUsd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lastOvernight = today21;
|
||||
}
|
||||
}
|
||||
|
||||
public double? MidOf(string symbol)
|
||||
{
|
||||
foreach ((long id, Instrument i) in _instruments)
|
||||
{
|
||||
if (i.Symbol == symbol && _quotes.TryGetValue(id, out QuoteSnapshot q) && q.IsValid)
|
||||
{
|
||||
return q.Mid;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Instrument>> GetInstrumentsAsync(IReadOnlyList<string> symbols, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<Instrument>>([.. symbols.Select(s => _instruments.Values.First(i => i.Symbol == s))]);
|
||||
|
||||
public Task<IReadOnlyList<QuoteSnapshot>> GetQuotesAsync(IReadOnlyList<long> instrumentIds, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<QuoteSnapshot>>([.. instrumentIds.Where(_quotes.ContainsKey).Select(id => _quotes[id])]);
|
||||
|
||||
public Task<IReadOnlyList<BidAskBar>> GetCandlesAsync(long instrumentId, TimeSpan interval, int count, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<BidAskBar>>([]);
|
||||
|
||||
public Task<AccountSnapshot> GetAccountAsync(CancellationToken ct)
|
||||
{
|
||||
double unrealized = 0, margin = 0;
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
unrealized += Unrealized(p);
|
||||
margin += Margin(p);
|
||||
}
|
||||
|
||||
double equity = _balance + 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
|
||||
{
|
||||
get
|
||||
{
|
||||
double unrealized = 0;
|
||||
foreach (Position p in _positions.Values)
|
||||
{
|
||||
unrealized += Unrealized(p);
|
||||
}
|
||||
|
||||
return _balance + unrealized;
|
||||
}
|
||||
}
|
||||
|
||||
public double Balance => _balance;
|
||||
|
||||
public Task<IReadOnlyList<BrokerPosition>> GetPositionsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<BrokerPosition>>([.. _positions.Values.Select(p =>
|
||||
new BrokerPosition(p.Id, p.Instrument.Id, p.IsBuy, p.Units, p.Open, p.OpenedUtc, 0, 0, p.Leverage, Margin(p), Unrealized(p), p.Fees, Exit(p)))]);
|
||||
|
||||
public Task<OrderOutcome> OpenAsync(OrderRequest request, CancellationToken ct)
|
||||
{
|
||||
if (_orders.TryGetValue(request.ClientRef, out OrderOutcome? done))
|
||||
{
|
||||
return Task.FromResult(done);
|
||||
}
|
||||
|
||||
if (!_instruments.TryGetValue(request.InstrumentId, out Instrument? instrument) || !_quotes.TryGetValue(request.InstrumentId, out QuoteSnapshot q) || !q.IsValid)
|
||||
{
|
||||
return Task.FromResult(Reject(request, "strumento o quotazione mancante"));
|
||||
}
|
||||
|
||||
double slip = _slippagePips * instrument.Pip;
|
||||
double fill = instrument.RoundPrice(request.IsBuy ? q.Ask + slip : q.Bid - slip);
|
||||
double notional = PipMath.NotionalUsd(instrument.Symbol, request.Units, fill, MidOf);
|
||||
if (double.IsNaN(notional) || notional < instrument.MinExposure)
|
||||
{
|
||||
return Task.FromResult(Reject(request, $"esposizione {notional:F0} USD sotto il minimo {instrument.MinExposure:F0}"));
|
||||
}
|
||||
|
||||
Position p = new()
|
||||
{
|
||||
Id = _nextId++,
|
||||
Instrument = instrument,
|
||||
IsBuy = request.IsBuy,
|
||||
Units = request.Units,
|
||||
Open = fill,
|
||||
OpenedUtc = _now,
|
||||
Leverage = Math.Max(1, request.Leverage),
|
||||
};
|
||||
_positions[p.Id] = p;
|
||||
OrderOutcome o = new(true, false, p.Id, p.Id, fill, request.Units, _now, 0, "Filled", string.Empty);
|
||||
_orders[request.ClientRef] = o;
|
||||
return Task.FromResult(o);
|
||||
}
|
||||
|
||||
private OrderOutcome Reject(OrderRequest request, string why)
|
||||
{
|
||||
OrderOutcome o = new(false, true, 0, 0, 0, request.Units, _now, 0, "Rejected", why);
|
||||
_orders[request.ClientRef] = o;
|
||||
return o;
|
||||
}
|
||||
|
||||
public Task<OrderOutcome?> LookupOrderAsync(string clientRef, CancellationToken ct) =>
|
||||
Task.FromResult(_orders.TryGetValue(clientRef, out OrderOutcome? o) ? o : null);
|
||||
|
||||
public Task<OrderOutcome?> LookupOrderByIdAsync(long orderId, CancellationToken ct) =>
|
||||
Task.FromResult(_orders.Values.FirstOrDefault(o => o.OrderId == orderId));
|
||||
|
||||
public Task<bool> CancelOrderAsync(long orderId, CancellationToken ct) => Task.FromResult(false);
|
||||
|
||||
public Task<CloseOutcome> CloseAsync(long positionId, long instrumentId, CancellationToken ct)
|
||||
{
|
||||
if (!_positions.Remove(positionId, out Position? p))
|
||||
{
|
||||
return Task.FromResult(new CloseOutcome(false, true, 0, 0, 0, _now, 0, "posizione inesistente"));
|
||||
}
|
||||
|
||||
double exit = Exit(p, withSlippage: true);
|
||||
double pnl = PipMath.LegPnlUsd(p.Instrument.Symbol, p.IsBuy, p.Units, p.Open, exit, MidOf);
|
||||
pnl = (double.IsNaN(pnl) ? 0 : pnl) - p.Fees;
|
||||
_balance += pnl;
|
||||
_closed.Add(new ClosedTrade(p.Id, p.Instrument.Id, p.IsBuy, p.Units, p.Open, exit, p.OpenedUtc, _now, pnl, p.Fees));
|
||||
return Task.FromResult(new CloseOutcome(true, false, _nextId++, exit, p.Units, _now, pnl, string.Empty));
|
||||
}
|
||||
|
||||
public Task<bool> UpdateStopsAsync(long positionId, double? stopLoss, double? takeProfit, CancellationToken ct) => Task.FromResult(true);
|
||||
|
||||
public Task<CostEstimate?> GetCostAsync(OrderRequest request, CancellationToken ct) => Task.FromResult<CostEstimate?>(null);
|
||||
|
||||
public Task<IReadOnlyList<ClosedTrade>> GetClosedTradesAsync(DateTime fromUtc, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<ClosedTrade>>([.. _closed.Where(c => c.ClosedUtc >= fromUtc)]);
|
||||
|
||||
private double Exit(Position p, bool withSlippage = false)
|
||||
{
|
||||
if (!_quotes.TryGetValue(p.Instrument.Id, out QuoteSnapshot q) || !q.IsValid)
|
||||
{
|
||||
return p.Open;
|
||||
}
|
||||
|
||||
double slip = withSlippage ? _slippagePips * p.Instrument.Pip : 0;
|
||||
return p.Instrument.RoundPrice(p.IsBuy ? q.Bid - slip : q.Ask + slip);
|
||||
}
|
||||
|
||||
private double Unrealized(Position p)
|
||||
{
|
||||
double pnl = PipMath.LegPnlUsd(p.Instrument.Symbol, p.IsBuy, p.Units, p.Open, Exit(p), MidOf);
|
||||
return (double.IsNaN(pnl) ? 0 : pnl) - p.Fees;
|
||||
}
|
||||
|
||||
private double Margin(Position p)
|
||||
{
|
||||
double n = PipMath.NotionalUsd(p.Instrument.Symbol, p.Units, p.Open, MidOf);
|
||||
return (double.IsNaN(n) ? 0 : n) / p.Leverage;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Baskets.Data;
|
||||
using Encelado.Core.Broker;
|
||||
using Encelado.Core.Statistics;
|
||||
|
||||
namespace Encelado.Core.Baskets.Backtest;
|
||||
|
||||
/// <summary>The M15 bid/ask history of every symbol, loaded once and shared by every trial.</summary>
|
||||
public sealed class MarketData
|
||||
{
|
||||
public Dictionary<string, List<BidAskBar>> Bars { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public DateTime From { get; private set; } = DateTime.MaxValue;
|
||||
|
||||
public DateTime To { get; private set; } = DateTime.MinValue;
|
||||
|
||||
public static MarketData Load(string directory, IEnumerable<string> symbols, DateTime? from = null, DateTime? to = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
MarketData data = new();
|
||||
foreach (string symbol in symbols)
|
||||
{
|
||||
string path = Path.Combine(directory, BidAskBarCsv.FileName(symbol));
|
||||
List<BidAskBar> bars = BidAskBarCsv.Read(path, from, to);
|
||||
data.Bars[symbol.ToUpperInvariant()] = bars;
|
||||
if (bars.Count > 0)
|
||||
{
|
||||
data.From = bars[0].TimeUtc < data.From ? bars[0].TimeUtc : data.From;
|
||||
data.To = bars[^1].TimeUtc > data.To ? bars[^1].TimeUtc : data.To;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every bar time across every symbol, sorted.</summary>
|
||||
public DateTime[] Timeline()
|
||||
{
|
||||
SortedSet<DateTime> times = [];
|
||||
foreach (List<BidAskBar> bars in Bars.Values)
|
||||
{
|
||||
foreach (BidAskBar b in bars)
|
||||
{
|
||||
times.Add(b.TimeUtc);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. times];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Knobs of a backtest run: costs and the period.</summary>
|
||||
public sealed record BacktestSettings
|
||||
{
|
||||
public double StartingBalance { get; init; } = 10_000;
|
||||
|
||||
public double SlippagePips { get; init; } = 0.3;
|
||||
|
||||
public double OvernightPipsPerDay { get; init; } = 0.3;
|
||||
|
||||
/// <summary>Per-symbol spread floor in pips: the venue's typical spread, applied when the tick spread is narrower.</summary>
|
||||
public IReadOnlyDictionary<string, double> SpreadFloorPips { get; init; } = DefaultEtoroSpreads();
|
||||
|
||||
/// <summary>Markup in pips the venue adds on top of the market spread (0 when the floor already represents the full spread).</summary>
|
||||
public double MarkupPips { get; init; }
|
||||
|
||||
public DateTime? From { get; init; }
|
||||
|
||||
public DateTime? To { get; init; }
|
||||
|
||||
/// <summary>Set to false only for the falsification test that documents why the basket stop is mandatory.</summary>
|
||||
public bool UseBasketStop { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// eToro's typical (advertised) spreads for the pairs in scope, in pips. Used as a
|
||||
/// floor on the tick spreads of the MT5 export, which came from a different broker.
|
||||
/// Verified against the live quotes only once keys are available; until then this
|
||||
/// is the conservative assumption written in <c>docs/STRATEGY.md</c>.
|
||||
/// </summary>
|
||||
public static Dictionary<string, double> DefaultEtoroSpreads() => new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["EURUSD"] = 1.0,
|
||||
["USDCHF"] = 1.5,
|
||||
["AUDUSD"] = 1.0,
|
||||
["USDCAD"] = 1.5,
|
||||
["NZDUSD"] = 2.5,
|
||||
["EURNZD"] = 5.0,
|
||||
["EURAUD"] = 3.0,
|
||||
["AUDCAD"] = 3.0,
|
||||
["EURCHF"] = 2.0,
|
||||
["EURCAD"] = 3.0,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>One basket, opened and closed, as the backtest saw it.</summary>
|
||||
public sealed record BacktestTrade(
|
||||
string Basket,
|
||||
DateTime OpenedUtc,
|
||||
DateTime ClosedUtc,
|
||||
bool BuyCross,
|
||||
double EntryZ,
|
||||
double ExitZ,
|
||||
double PnlUsd,
|
||||
double Pips,
|
||||
double CostPips,
|
||||
int Adds,
|
||||
int BarsHeld,
|
||||
string ExitReason,
|
||||
double EquityAtEntry)
|
||||
{
|
||||
public int Label => PnlUsd > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>Everything one run produced, with the daily return series every later analysis needs.</summary>
|
||||
public sealed record BacktestResult(
|
||||
string TrialId,
|
||||
PresetName Preset,
|
||||
IReadOnlyList<BacktestTrade> Trades,
|
||||
DateTime[] DailyDates,
|
||||
double[] DailyReturns,
|
||||
double[] DailyEquity,
|
||||
double FinalEquity,
|
||||
double MaxDrawdown,
|
||||
double SharpeAnnual,
|
||||
double WinRate,
|
||||
double PnlNet,
|
||||
double BreakEvenCostPips,
|
||||
double Psr,
|
||||
double Skewness,
|
||||
double Kurtosis,
|
||||
int Bars,
|
||||
TimeSpan Elapsed,
|
||||
IReadOnlyDictionary<string, int> SkipReasons,
|
||||
int EquityStops)
|
||||
{
|
||||
public int Count => Trades.Count;
|
||||
|
||||
/// <summary>The skip reasons, most frequent first, as one line.</summary>
|
||||
public string DescribeSkips(int top = 6) =>
|
||||
string.Join(", ", SkipReasons.OrderByDescending(static k => k.Value).Take(top).Select(static k => $"{k.Key} {k.Value}"));
|
||||
|
||||
public double AverageCostPips => Trades.Count > 0 ? Trades.Average(static t => t.CostPips) : double.NaN;
|
||||
|
||||
/// <summary>Percentile of the per-basket P&L (1 % and 5 % show the tail the win rate hides).</summary>
|
||||
public double PnlPercentile(double p)
|
||||
{
|
||||
if (Trades.Count == 0)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double[] sorted = [.. Trades.Select(static t => t.PnlUsd).Order()];
|
||||
int i = Math.Clamp((int)Math.Floor(p * (sorted.Length - 1)), 0, sorted.Length - 1);
|
||||
return sorted[i];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The event-driven replay: bar by bar, the same <see cref="BasketDecider"/> and
|
||||
/// <see cref="BasketExecutor"/> the live engine uses, over a <see cref="BacktestBroker"/>.
|
||||
/// Decisions are taken on a bar's close and filled at the next bar's open, at that bar's
|
||||
/// bid/ask plus slippage; overnight fees accrue daily; the leg-risk protocol runs for
|
||||
/// real (the replay venue never rejects, so it is exercised by a dedicated test).
|
||||
/// <para>
|
||||
/// What the replay cannot do, and says so: the economic-calendar blackout and the
|
||||
/// sentiment features need history the free feeds do not provide, so in the backtest
|
||||
/// they are absent (no blackout applied). The live bot applies them; the comparison
|
||||
/// backtest → paper → forward is where their effect shows.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class BasketBacktest
|
||||
{
|
||||
public static BacktestResult Run(MarketData data, BasketStrategyConfig config, PresetName preset, BacktestSettings settings, string trialId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
Stopwatch sw = Stopwatch.StartNew();
|
||||
|
||||
// Instruments as the venue would describe them (ids are local to the replay).
|
||||
List<string> symbols = config.Symbols(includeDirectCrosses: false);
|
||||
Dictionary<string, Instrument> instruments = new(StringComparer.OrdinalIgnoreCase);
|
||||
long nextId = 1;
|
||||
foreach (string s in symbols)
|
||||
{
|
||||
instruments[s] = new Instrument(nextId++, s, s, "Forex", PipMath.Pip(s), PipMath.Digits(s), 0.01, 2_000_000, 1000, [1, 2, 5, 10, 20], true, true, 0, 50, "replay");
|
||||
}
|
||||
|
||||
BacktestBroker broker = new(instruments.Values, settings.StartingBalance, settings.SlippagePips, settings.OvernightPipsPerDay);
|
||||
Dictionary<string, SymbolSeries> series = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, int> cursor = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string s in symbols)
|
||||
{
|
||||
series[s] = new SymbolSeries(instruments[s], TimeSpan.FromMinutes(15), 2000);
|
||||
cursor[s] = 0;
|
||||
}
|
||||
|
||||
BasketDecider decider = new(config, preset);
|
||||
BasketExecutor executor = new(broker, config, broker.MidOf, static _ => { });
|
||||
BasketPreset effective = decider.Preset;
|
||||
|
||||
List<Slot> slots = [];
|
||||
foreach (BasketDefinition d in config.Baskets.Where(static b => b.Enabled))
|
||||
{
|
||||
if (!SyntheticCross.TryDerive(d.A, d.B, out SyntheticCross? cross) || !series.ContainsKey(d.A) || !series.ContainsKey(d.B))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
slots.Add(new Slot(d.Name, cross!, series[d.A], series[d.B]));
|
||||
}
|
||||
|
||||
List<BacktestTrade> trades = [];
|
||||
Dictionary<string, int> skips = new(StringComparer.Ordinal);
|
||||
int equityStops = 0;
|
||||
List<(DateOnly Day, double Equity)> daily = [];
|
||||
DateOnly currentDay = default;
|
||||
double dayStartEquity = settings.StartingBalance;
|
||||
double peak = settings.StartingBalance;
|
||||
double lastEquity = settings.StartingBalance;
|
||||
int bars = 0;
|
||||
|
||||
DateTime[] timeline = data.Timeline();
|
||||
DateTime from = settings.From ?? DateTime.MinValue;
|
||||
DateTime to = settings.To ?? DateTime.MaxValue;
|
||||
|
||||
foreach (DateTime t in timeline)
|
||||
{
|
||||
if (t < from || t > to)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 1: this bar's open is where yesterday's decisions get filled.
|
||||
List<QuoteSnapshot> opens = [];
|
||||
Dictionary<string, BidAskBar> current = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string s in symbols)
|
||||
{
|
||||
List<BidAskBar> list = data.Bars[s];
|
||||
int i = cursor[s];
|
||||
if (i < list.Count && list[i].TimeUtc == t)
|
||||
{
|
||||
BidAskBar b = Floor(list[i], settings, instruments[s]);
|
||||
current[s] = b;
|
||||
cursor[s] = i + 1;
|
||||
opens.Add(new QuoteSnapshot(instruments[s].Id, t, b.BidOpen, b.AskOpen, true));
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
broker.Advance(t, opens);
|
||||
foreach ((string s, BidAskBar b) in current)
|
||||
{
|
||||
series[s].OnQuote(new QuoteSnapshot(instruments[s].Id, t, b.BidOpen, b.AskOpen, true), t);
|
||||
}
|
||||
|
||||
double equityNow = broker.Equity;
|
||||
foreach (Slot slot in slots)
|
||||
{
|
||||
if (slot.Pending is null || !current.ContainsKey(slot.A.Symbol) || !current.ContainsKey(slot.B.Symbol))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BasketContext ctx = Context(slot, t, broker, equityNow, peak, slots, isBarClose: false);
|
||||
Fill(slot, ctx, executor, decider.Preset, trades, t);
|
||||
}
|
||||
|
||||
// Phase 2: the bar closes; append it, decide.
|
||||
List<QuoteSnapshot> closes = [];
|
||||
foreach ((string s, BidAskBar b) in current)
|
||||
{
|
||||
closes.Add(new QuoteSnapshot(instruments[s].Id, t.AddMinutes(15).AddMilliseconds(-1), b.BidClose, b.AskClose, true));
|
||||
}
|
||||
|
||||
broker.Advance(t, closes);
|
||||
foreach ((string s, BidAskBar b) in current)
|
||||
{
|
||||
series[s].Append(b);
|
||||
series[s].OnQuote(new QuoteSnapshot(instruments[s].Id, t.AddMinutes(15).AddMilliseconds(-1), b.BidClose, b.AskClose, true), t);
|
||||
}
|
||||
|
||||
bars++;
|
||||
equityNow = broker.Equity;
|
||||
if (equityNow > peak)
|
||||
{
|
||||
peak = equityNow;
|
||||
}
|
||||
|
||||
// The equity stop closes everything, as live; but a backtest has no operator to
|
||||
// lift it, so the peak restarts from here and the event is counted. The number
|
||||
// of equity stops is reported: it is a result, not a detail.
|
||||
bool equityStopped = peak > 0 && (peak - equityNow) / peak >= config.EquityStopPct / 100.0;
|
||||
if (equityStopped)
|
||||
{
|
||||
equityStops++;
|
||||
peak = equityNow;
|
||||
}
|
||||
|
||||
DateOnly day = DateOnly.FromDateTime(t);
|
||||
if (day != currentDay)
|
||||
{
|
||||
if (currentDay != default)
|
||||
{
|
||||
daily.Add((currentDay, lastEquity));
|
||||
}
|
||||
|
||||
currentDay = day;
|
||||
dayStartEquity = equityNow;
|
||||
}
|
||||
|
||||
bool dailyLoss = dayStartEquity > 0 && (dayStartEquity - equityNow) / dayStartEquity >= config.DailyLossPct / 100.0;
|
||||
|
||||
foreach (Slot slot in slots)
|
||||
{
|
||||
if (!current.ContainsKey(slot.A.Symbol) || !current.ContainsKey(slot.B.Symbol))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (slot.Position is { } p)
|
||||
{
|
||||
p.BarsHeld++;
|
||||
}
|
||||
|
||||
BasketContext ctx = Context(slot, t, broker, equityNow, peak, slots, isBarClose: true, config.OrderLeverage) with
|
||||
{
|
||||
DailyLossHit = dailyLoss,
|
||||
EquityStopped = equityStopped,
|
||||
};
|
||||
|
||||
BasketDecision d = decider.Evaluate(ctx);
|
||||
if (d.Kind == DecisionKind.Skip)
|
||||
{
|
||||
foreach (string code in d.ReasonCodes)
|
||||
{
|
||||
skips[code] = skips.GetValueOrDefault(code) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.UseBasketStop && d.Kind == DecisionKind.Exit && d.ReasonCodes.Any(static c => c is "stop_z" or "stop_max_loss" or "rho_break"))
|
||||
{
|
||||
continue; // the "no stop" arm of the falsification test
|
||||
}
|
||||
|
||||
if (d.Kind is DecisionKind.Enter or DecisionKind.Exit or DecisionKind.Add)
|
||||
{
|
||||
slot.Pending = d;
|
||||
slot.PendingZ = d.Evaluation.Z;
|
||||
}
|
||||
}
|
||||
|
||||
lastEquity = equityNow;
|
||||
}
|
||||
|
||||
if (currentDay != default)
|
||||
{
|
||||
daily.Add((currentDay, lastEquity));
|
||||
}
|
||||
|
||||
// Close what is still open at the end, so the trade list is complete.
|
||||
foreach (Slot slot in slots)
|
||||
{
|
||||
if (slot.Position is { } p && slot.A.HasQuote && slot.B.HasQuote)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Daily returns, Sharpe, drawdown, PSR.
|
||||
DateTime[] dates = new DateTime[daily.Count];
|
||||
double[] equity = new double[daily.Count];
|
||||
for (int i = 0; i < daily.Count; i++)
|
||||
{
|
||||
dates[i] = daily[i].Day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
equity[i] = daily[i].Equity;
|
||||
}
|
||||
|
||||
double[] returns = new double[Math.Max(0, daily.Count - 1)];
|
||||
for (int i = 1; i < daily.Count; i++)
|
||||
{
|
||||
returns[i - 1] = equity[i - 1] > 0 ? (equity[i] / equity[i - 1]) - 1 : 0;
|
||||
}
|
||||
|
||||
double sharpeDaily = Performance.Sharpe(returns);
|
||||
double sharpe = Performance.Annualise(sharpeDaily, 260);
|
||||
double maxDd = Performance.MaxDrawdown(equity);
|
||||
double skew = returns.Length > 2 ? Performance.Skewness(returns) : 0;
|
||||
double kurt = returns.Length > 3 ? Performance.Kurtosis(returns) : 3;
|
||||
double psr = Performance.ProbabilisticSharpe(sharpeDaily, 0, returns.Length, skew, kurt);
|
||||
double winRate = trades.Count > 0 ? trades.Count(static x => x.PnlUsd > 0) / (double)trades.Count : double.NaN;
|
||||
double pnlNet = trades.Sum(static x => x.PnlUsd);
|
||||
double breakEven = trades.Count > 0 ? trades.Average(static x => x.Pips + x.CostPips) : double.NaN;
|
||||
|
||||
return new BacktestResult(trialId, preset, trades, dates, returns, equity, broker.Equity, maxDd, sharpe, winRate, pnlNet, breakEven, psr, skew, kurt, bars, sw.Elapsed, skips, equityStops);
|
||||
}
|
||||
|
||||
private sealed class Slot(string name, SyntheticCross cross, SymbolSeries a, SymbolSeries b)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
|
||||
public SyntheticCross Cross { get; } = cross;
|
||||
|
||||
public SymbolSeries A { get; } = a;
|
||||
|
||||
public SymbolSeries B { get; } = b;
|
||||
|
||||
public BasketPosition? Position { get; set; }
|
||||
|
||||
public BasketDecision? Pending { get; set; }
|
||||
|
||||
public double PendingZ { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Applies the venue's typical spread as a floor around the mid, and the markup.</summary>
|
||||
private static BidAskBar Floor(in BidAskBar b, BacktestSettings s, Instrument instrument)
|
||||
{
|
||||
double floor = (s.SpreadFloorPips.TryGetValue(instrument.Symbol, out double f) ? f : 0) + s.MarkupPips;
|
||||
double target = floor * instrument.Pip;
|
||||
if (target <= 0)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
|
||||
return new BidAskBar(b.TimeUtc,
|
||||
Widen(b.BidOpen, b.AskOpen, target, true), Widen(b.BidHigh, b.AskHigh, target, true), Widen(b.BidLow, b.AskLow, target, true), Widen(b.BidClose, b.AskClose, target, true),
|
||||
Widen(b.BidOpen, b.AskOpen, target, false), Widen(b.BidHigh, b.AskHigh, target, false), Widen(b.BidLow, b.AskLow, target, false), Widen(b.BidClose, b.AskClose, target, false),
|
||||
Math.Max(b.SpreadMean, target), b.Ticks, b.Source);
|
||||
|
||||
static double Widen(double bid, double ask, double target, bool wantBid)
|
||||
{
|
||||
double spread = ask - bid;
|
||||
if (spread >= target)
|
||||
{
|
||||
return wantBid ? bid : ask;
|
||||
}
|
||||
|
||||
double mid = (bid + ask) / 2;
|
||||
return wantBid ? mid - (target / 2) : mid + (target / 2);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
double pipA = PipMath.PipValueUsd(slot.A.Symbol, 1, broker.MidOf);
|
||||
double pipB = PipMath.PipValueUsd(slot.B.Symbol, 1, broker.MidOf);
|
||||
double usdA = PipMath.QuoteToUsd(PipMath.QuoteCurrency(slot.A.Symbol), broker.MidOf) ?? double.NaN;
|
||||
double usdB = PipMath.QuoteToUsd(PipMath.QuoteCurrency(slot.B.Symbol), broker.MidOf) ?? double.NaN;
|
||||
|
||||
return new BasketContext
|
||||
{
|
||||
TimeUtc = t,
|
||||
BasketId = slot.Name,
|
||||
Name = slot.Name,
|
||||
Cross = slot.Cross,
|
||||
A = slot.A,
|
||||
B = slot.B,
|
||||
Equity = equity,
|
||||
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,
|
||||
UsdPerQuoteA = double.IsNaN(usdA) ? 0 : usdA,
|
||||
UsdPerQuoteB = double.IsNaN(usdB) ? 0 : usdB,
|
||||
Mid = broker.MidOf,
|
||||
Position = slot.Position,
|
||||
};
|
||||
}
|
||||
|
||||
private static void Fill(Slot slot, BasketContext ctx, BasketExecutor executor, BasketPreset preset, List<BacktestTrade> trades, DateTime t)
|
||||
{
|
||||
BasketDecision d = slot.Pending!;
|
||||
slot.Pending = null;
|
||||
switch (d.Kind)
|
||||
{
|
||||
case DecisionKind.Enter when slot.Position is null:
|
||||
EntryOutcome entry = executor.OpenAsync(ctx, d, preset, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (entry.Ok)
|
||||
{
|
||||
slot.Position = entry.Position;
|
||||
}
|
||||
|
||||
break;
|
||||
case DecisionKind.Add when slot.Position is not null:
|
||||
executor.AddAsync(ctx, d, CancellationToken.None).GetAwaiter().GetResult();
|
||||
break;
|
||||
case DecisionKind.Exit when slot.Position is { } p:
|
||||
ExitOutcome x = executor.CloseAsync(ctx, p, d.Motivazione, CancellationToken.None).GetAwaiter().GetResult();
|
||||
trades.Add(new BacktestTrade(slot.Name, p.OpenedUtc, t, p.BuyCross, p.EntryZ, slot.PendingZ, x.RealizedPnlUsd, x.PipsTotal, p.EntryCostPips, p.Adds, p.BarsHeld, d.ReasonCodes.FirstOrDefault() ?? "exit", p.EquityAtEntry));
|
||||
slot.Position = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Encelado.Core.Ml;
|
||||
using Encelado.Core.Statistics;
|
||||
|
||||
namespace Encelado.Core.Baskets.Backtest;
|
||||
|
||||
/// <summary>One row of <c>results/trials.csv</c>: a configuration that was tried and what it produced.</summary>
|
||||
public sealed record TrialRecord(
|
||||
string TrialId,
|
||||
PresetName Preset,
|
||||
SignalMode SignalMode,
|
||||
ExitMode ExitMode,
|
||||
AveragingMode Averaging,
|
||||
double LotMultiplier,
|
||||
double ZIn,
|
||||
double ZOut,
|
||||
double ZStop,
|
||||
double TpPips,
|
||||
int Window,
|
||||
double RhoMin,
|
||||
double CostMultiple,
|
||||
bool BasketStop,
|
||||
BacktestResult Result)
|
||||
{
|
||||
public const string Header =
|
||||
"trial_id;preset;signalMode;exitMode;averaging;lot_multiplier;z_in;z_out;z_stop;TP;W;rho_min;cost_multiple;basket_stop;n_baskets;win_rate;pnl_net;sharpe;maxdd;break_even_cost;avg_cost_pips;p1_pnl;p5_pnl;psr;dsr;motivazione";
|
||||
|
||||
public string ToCsv(double dsr, string motivazione)
|
||||
{
|
||||
BacktestResult r = Result;
|
||||
return string.Join(';',
|
||||
[
|
||||
TrialId, Preset.ToString(), SignalMode.ToString(), ExitMode.ToString(), Averaging.ToString(), N(LotMultiplier),
|
||||
N(ZIn), N(ZOut), N(ZStop), N(TpPips), Window.ToString(CultureInfo.InvariantCulture), N(RhoMin), N(CostMultiple), BasketStop ? "1" : "0",
|
||||
r.Count.ToString(CultureInfo.InvariantCulture), N(r.WinRate), N(r.PnlNet), N(r.SharpeAnnual), N(r.MaxDrawdown), N(r.BreakEvenCostPips), N(r.AverageCostPips),
|
||||
N(r.PnlPercentile(0.01)), N(r.PnlPercentile(0.05)), N(r.Psr), N(dsr), motivazione.Replace(';', ',').Replace('\n', ' '),
|
||||
]);
|
||||
}
|
||||
|
||||
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.####", CultureInfo.InvariantCulture) : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Walk-forward selection: which trial was chosen for each test month, and the stitched returns.</summary>
|
||||
public sealed record WalkForwardResult(
|
||||
IReadOnlyList<(DateTime Month, string TrialId, double TrainSharpe, double TestSharpe)> Choices,
|
||||
double[] DailyReturns,
|
||||
double SharpeAnnual,
|
||||
double MaxDrawdown,
|
||||
double Psr);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a population of configurations on the same data and reports them honestly:
|
||||
/// the deflated Sharpe counts every row, the PBO comes from combinatorially symmetric
|
||||
/// cross-validation over the daily returns of every trial, and the walk-forward result
|
||||
/// is what the <i>procedure</i> (choose the best of the last six months, run it for one
|
||||
/// month) would have earned — not the best row of the table.
|
||||
/// </summary>
|
||||
public static class BasketTrials
|
||||
{
|
||||
/// <summary>The grid of §9.1 around the three presets (the default when no explicit grid is given).</summary>
|
||||
public static List<(BasketStrategyConfig Config, PresetName Preset, string Id)> DefaultGrid(BasketStrategyConfig baseline)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseline);
|
||||
List<(BasketStrategyConfig, PresetName, string)> grid = [];
|
||||
int n = 0;
|
||||
foreach (PresetName preset in new[] { PresetName.Conservative, PresetName.Moderate, PresetName.Aggressive })
|
||||
{
|
||||
foreach (int window in new[] { 60, 100, 150 })
|
||||
{
|
||||
foreach (double rho in new[] { 0.5, 0.6, 0.7 })
|
||||
{
|
||||
foreach (double zOut in new[] { 0.25, 0.5 })
|
||||
{
|
||||
BasketStrategyConfig c = Clone(baseline);
|
||||
c.Preset = preset;
|
||||
c.Window = window;
|
||||
c.RhoMin = rho;
|
||||
c.ZOut = zOut;
|
||||
grid.Add((c, preset, $"T{++n:000}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
public static BasketStrategyConfig Clone(BasketStrategyConfig c)
|
||||
{
|
||||
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.Recovery = new RecoveryOptions { ThresholdMinutes = c.Recovery.ThresholdMinutes, WarmupMinutes = c.Recovery.WarmupMinutes, HeartbeatSeconds = c.Recovery.HeartbeatSeconds };
|
||||
copy.Learning = new LearningOptions { Enabled = c.Learning.Enabled, WeeklyCycle = c.Learning.WeeklyCycle, Challenger = c.Learning.Challenger };
|
||||
copy.Preset = c.Preset;
|
||||
copy.SignalMode = c.SignalMode;
|
||||
copy.ExitMode = c.ExitMode;
|
||||
copy.AveragingMode = c.AveragingMode;
|
||||
copy.TpMode = c.TpMode;
|
||||
copy.SameCrossPolicy = c.SameCrossPolicy;
|
||||
copy.PreferDirectCross = c.PreferDirectCross;
|
||||
copy.InvertSignal = c.InvertSignal;
|
||||
copy.Window = c.Window;
|
||||
copy.WindowShort = c.WindowShort;
|
||||
copy.RhoMin = c.RhoMin;
|
||||
copy.RhoShortMin = c.RhoShortMin;
|
||||
copy.HalfLifeMinBars = c.HalfLifeMinBars;
|
||||
copy.HalfLifeMaxBars = c.HalfLifeMaxBars;
|
||||
copy.HalfLifeRecalcHours = c.HalfLifeRecalcHours;
|
||||
copy.AtrPeriod = c.AtrPeriod;
|
||||
copy.EwmaSpan = c.EwmaSpan;
|
||||
copy.TrendPeriod = c.TrendPeriod;
|
||||
copy.ZOut = c.ZOut;
|
||||
copy.DIn = c.DIn;
|
||||
copy.AnchorBars = c.AnchorBars;
|
||||
copy.GridStepZ = c.GridStepZ;
|
||||
copy.LotMultiplier = c.LotMultiplier;
|
||||
copy.MaxLossPerBasketPct = c.MaxLossPerBasketPct;
|
||||
copy.RhoBreak = c.RhoBreak;
|
||||
copy.RhoBreakBars = c.RhoBreakBars;
|
||||
copy.MaxHoldingBars = c.MaxHoldingBars;
|
||||
copy.TpAtrMultiple = c.TpAtrMultiple;
|
||||
copy.CostMultiple = c.CostMultiple;
|
||||
copy.SpreadMedianMultiple = c.SpreadMedianMultiple;
|
||||
copy.SpreadAnomalyMultiple = c.SpreadAnomalyMultiple;
|
||||
copy.SlippagePipsPerLeg = c.SlippagePipsPerLeg;
|
||||
copy.OvernightPipsPerDay = c.OvernightPipsPerDay;
|
||||
copy.BlackoutBeforeMin = c.BlackoutBeforeMin;
|
||||
copy.BlackoutAfterMin = c.BlackoutAfterMin;
|
||||
copy.FridayCutoffUtcHour = c.FridayCutoffUtcHour;
|
||||
copy.OpenDelayMinutes = c.OpenDelayMinutes;
|
||||
copy.Sessions = [.. c.Sessions];
|
||||
copy.MaxEffectiveLeverage = c.MaxEffectiveLeverage;
|
||||
copy.OrderLeverage = c.OrderLeverage;
|
||||
copy.VolScaleMin = c.VolScaleMin;
|
||||
copy.VolScaleMax = c.VolScaleMax;
|
||||
copy.VolAverageDays = c.VolAverageDays;
|
||||
copy.MlMinProbability = c.MlMinProbability;
|
||||
copy.EquityStopPct = c.EquityStopPct;
|
||||
copy.DailyLossPct = c.DailyLossPct;
|
||||
copy.LegTimeoutSec = c.LegTimeoutSec;
|
||||
copy.ClockSkewMaxSeconds = c.ClockSkewMaxSeconds;
|
||||
copy.ZInOverride = c.ZInOverride;
|
||||
copy.RiskPerBasketPctOverride = c.RiskPerBasketPctOverride;
|
||||
copy.MaxBasketsOverride = c.MaxBasketsOverride;
|
||||
copy.TpPipsOverride = c.TpPipsOverride;
|
||||
copy.MaxAddsOverride = c.MaxAddsOverride;
|
||||
copy.ZStopOverride = c.ZStopOverride;
|
||||
copy.Baskets = [.. c.Baskets.Select(static b => new BasketDefinition { A = b.A, B = b.B, Enabled = b.Enabled, Note = b.Note })];
|
||||
return copy;
|
||||
}
|
||||
|
||||
public static TrialRecord Record(string id, BasketStrategyConfig c, PresetName preset, BacktestSettings s, BacktestResult r)
|
||||
{
|
||||
BasketPreset e = c.Effective(preset);
|
||||
return new TrialRecord(id, preset, c.SignalMode, c.ExitMode, c.AveragingMode, c.LotMultiplier, e.ZIn, c.ZOut, e.ZStop, e.TpPips, c.Window, c.RhoMin, c.CostMultiple, s.UseBasketStop, r);
|
||||
}
|
||||
|
||||
/// <summary>Deflated Sharpe of one trial against the whole population (N = every row, variance of the annual Sharpes).</summary>
|
||||
public static double Dsr(BacktestResult r, IReadOnlyList<TrialRecord> population)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(r);
|
||||
ArgumentNullException.ThrowIfNull(population);
|
||||
if (r.DailyReturns.Length < 3)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double[] sharpes = [.. population.Select(static t => t.Result.SharpeAnnual / Math.Sqrt(260)).Where(double.IsFinite)];
|
||||
double variance = sharpes.Length > 1 ? Math.Pow(Performance.StandardDeviation(sharpes), 2) : 0;
|
||||
double sharpeDaily = r.SharpeAnnual / Math.Sqrt(260);
|
||||
return Performance.DeflatedSharpe(sharpeDaily, r.DailyReturns.Length, r.Skewness, r.Kurtosis, population.Count, variance);
|
||||
}
|
||||
|
||||
/// <summary>PBO by CSCV with S blocks over the aligned daily returns of every trial.</summary>
|
||||
public static PboResult Pbo(IReadOnlyList<TrialRecord> population, int blocks = 16)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(population);
|
||||
if (population.Count < 2)
|
||||
{
|
||||
return new PboResult(double.NaN, double.NaN, 0);
|
||||
}
|
||||
|
||||
int length = population.Min(static t => t.Result.DailyReturns.Length);
|
||||
List<double[]> aligned = [.. population.Select(t => t.Result.DailyReturns[^length..])];
|
||||
return Ml.Pbo.Compute(aligned, blocks, 2000, 42);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk-forward: for every test month, the trial with the best Sharpe over the six
|
||||
/// preceding months is applied to that month. Uses the cached daily returns, which is
|
||||
/// legitimate because a trial's returns do not depend on whether it gets chosen.
|
||||
/// </summary>
|
||||
public static WalkForwardResult WalkForward(IReadOnlyList<TrialRecord> population, int trainMonths = 6)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(population);
|
||||
if (population.Count == 0)
|
||||
{
|
||||
return new WalkForwardResult([], [], double.NaN, double.NaN, double.NaN);
|
||||
}
|
||||
|
||||
// Align on the shortest series; dates come from the first trial (all trials share the data).
|
||||
int length = population.Min(static t => t.Result.DailyReturns.Length);
|
||||
DateTime[] dates = population[0].Result.DailyDates[^length..];
|
||||
double[][] returns = [.. population.Select(t => t.Result.DailyReturns[^length..])];
|
||||
|
||||
List<(DateTime, string, double, double)> choices = [];
|
||||
List<double> stitched = [];
|
||||
DateTime firstMonth = new(dates[0].Year, dates[0].Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime month = firstMonth.AddMonths(trainMonths);
|
||||
DateTime end = dates[^1];
|
||||
|
||||
while (month <= end)
|
||||
{
|
||||
DateTime trainFrom = month.AddMonths(-trainMonths);
|
||||
int best = -1;
|
||||
double bestSharpe = double.NegativeInfinity;
|
||||
for (int t = 0; t < returns.Length; t++)
|
||||
{
|
||||
List<double> train = [];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
if (dates[i] >= trainFrom && dates[i] < month)
|
||||
{
|
||||
train.Add(returns[t][i]);
|
||||
}
|
||||
}
|
||||
|
||||
double s = train.Count > 20 ? Performance.Sharpe(train) : double.NegativeInfinity;
|
||||
if (s > bestSharpe)
|
||||
{
|
||||
bestSharpe = s;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime next = month.AddMonths(1);
|
||||
if (best >= 0)
|
||||
{
|
||||
List<double> test = [];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
if (dates[i] >= month && dates[i] < next)
|
||||
{
|
||||
test.Add(returns[best][i]);
|
||||
}
|
||||
}
|
||||
|
||||
stitched.AddRange(test);
|
||||
choices.Add((month, population[best].TrialId, Performance.Annualise(bestSharpe, 260), Performance.Annualise(Performance.Sharpe(test), 260)));
|
||||
}
|
||||
|
||||
month = next;
|
||||
}
|
||||
|
||||
double[] series = [.. stitched];
|
||||
double[] equity = new double[series.Length + 1];
|
||||
equity[0] = 1;
|
||||
for (int i = 0; i < series.Length; i++)
|
||||
{
|
||||
equity[i + 1] = equity[i] * (1 + series[i]);
|
||||
}
|
||||
|
||||
double daily = Performance.Sharpe(series);
|
||||
double psr = series.Length > 3 ? Performance.ProbabilisticSharpe(daily, 0, series.Length, Performance.Skewness(series), Performance.Kurtosis(series)) : double.NaN;
|
||||
return new WalkForwardResult(choices, series, Performance.Annualise(daily, 260), Performance.MaxDrawdown(equity), psr);
|
||||
}
|
||||
|
||||
/// <summary>Writes <c>trials.csv</c> atomically.</summary>
|
||||
public static void WriteTrials(string path, IReadOnlyList<TrialRecord> population)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
ArgumentNullException.ThrowIfNull(population);
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine(TrialRecord.Header);
|
||||
foreach (TrialRecord t in population)
|
||||
{
|
||||
double dsr = Dsr(t.Result, population);
|
||||
sb.AppendLine(t.ToCsv(dsr, Describe(t, dsr, population.Count)));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
File.WriteAllText(path + ".tmp", sb.ToString(), new UTF8Encoding(false));
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
}
|
||||
|
||||
public static string Describe(TrialRecord t, double dsr, int population)
|
||||
{
|
||||
BacktestResult r = t.Result;
|
||||
if (r.Count == 0)
|
||||
{
|
||||
return "nessun basket aperto: le condizioni di ingresso non si sono mai verificate insieme";
|
||||
}
|
||||
|
||||
string verdict = r.PnlNet <= 0
|
||||
? "perde al netto dei costi"
|
||||
: dsr >= 0.95 ? "regge la deflazione per il numero di prove"
|
||||
: r.SharpeAnnual > 0 ? $"positivo ma non distinguibile dalla selezione fra {population} prove (DSR {dsr:F2})"
|
||||
: "Sharpe negativo";
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"{r.Count} basket, win rate {r.WinRate:P0}, netto {r.PnlNet:F0} USD, Sharpe {r.SharpeAnnual:F2}, DD {r.MaxDrawdown:P1}, costo medio {r.AverageCostPips:F1} pip, break-even {r.BreakEvenCostPips:F1} pip: {verdict}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>Everything one evaluation of one basket sees. Built by the engine or the backtest, read by the decider.</summary>
|
||||
public sealed record BasketContext
|
||||
{
|
||||
public required DateTime TimeUtc { get; init; }
|
||||
|
||||
public required string BasketId { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required SyntheticCross Cross { get; init; }
|
||||
|
||||
public required SymbolSeries A { get; init; }
|
||||
|
||||
public required SymbolSeries B { get; init; }
|
||||
|
||||
public required double Equity { get; init; }
|
||||
|
||||
public double PeakEquity { get; init; }
|
||||
|
||||
public double DailyPnlUsd { get; init; }
|
||||
|
||||
public int OpenBaskets { get; init; }
|
||||
|
||||
/// <summary>Another basket with the same synthetic cross is open (or opening).</summary>
|
||||
public bool SameCrossOpen { 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 KillSwitched { get; init; }
|
||||
|
||||
/// <summary>Clock skew, persistent API errors, data-quality issue: no new entries, exits still allowed.</summary>
|
||||
public string? EntriesBlockedReason { get; init; }
|
||||
|
||||
/// <summary>Whether this evaluation happens on a bar close (signals) or on a quote (exit monitoring only).</summary>
|
||||
public bool IsBarClose { get; init; } = true;
|
||||
|
||||
// ---- calendar (int.MaxValue / NaN when unknown) ----
|
||||
public int MinutesToNextHigh { get; init; } = int.MaxValue;
|
||||
|
||||
public int MinutesSinceLastHigh { get; init; } = int.MaxValue;
|
||||
|
||||
public double SurpriseLast { get; init; } = double.NaN;
|
||||
|
||||
/// <summary>Whether the weekly market opening happened less than OpenDelayMinutes ago.</summary>
|
||||
public bool JustOpened { get; init; }
|
||||
|
||||
// ---- sentiment differences (long currency minus short currency of the cross) ----
|
||||
public double NetSentimentDiff1h { get; init; } = double.NaN;
|
||||
|
||||
public double NetSentimentDiff4h { get; init; } = double.NaN;
|
||||
|
||||
public double NetSentimentDiff24h { get; init; } = double.NaN;
|
||||
|
||||
public double HawkishDiff { get; init; } = double.NaN;
|
||||
|
||||
public double RiskOff { get; init; } = double.NaN;
|
||||
|
||||
public int NewsCount { get; init; }
|
||||
|
||||
// ---- volatility forecast ----
|
||||
public double SigmaForecast { get; init; } = double.NaN;
|
||||
|
||||
public double SigmaAverage30d { get; init; } = double.NaN;
|
||||
|
||||
// ---- learning ----
|
||||
public double PMl { get; init; } = double.NaN;
|
||||
|
||||
public bool MlActive { get; init; }
|
||||
|
||||
public double LastOutcomes { get; init; } = double.NaN;
|
||||
|
||||
// ---- costs from the venue (0 when unknown) ----
|
||||
public double MarkupPipsA { get; init; }
|
||||
|
||||
public double MarkupPipsB { get; init; }
|
||||
|
||||
public double CommissionPipsA { get; init; }
|
||||
|
||||
public double OvernightPipsPerDay { get; init; } = double.NaN;
|
||||
|
||||
// ---- conversions ----
|
||||
public required double PipValueUsdA { get; init; }
|
||||
|
||||
public required double PipValueUsdB { get; init; }
|
||||
|
||||
public required double UsdPerQuoteA { get; init; }
|
||||
|
||||
public required double UsdPerQuoteB { get; init; }
|
||||
|
||||
public required Func<string, double?> Mid { get; init; }
|
||||
|
||||
public BasketPosition? Position { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>The features of one evaluation — the row of the decisions ledger, computed before the decision.</summary>
|
||||
public sealed record BasketEvaluation
|
||||
{
|
||||
public double Z { get; init; } = double.NaN;
|
||||
|
||||
public double ZInEffective { get; init; } = double.NaN;
|
||||
|
||||
public double DPips { get; init; } = double.NaN;
|
||||
|
||||
public double RhoW { get; init; } = double.NaN;
|
||||
|
||||
public double RhoShort { get; init; } = double.NaN;
|
||||
|
||||
public double HalfLife { get; init; } = double.NaN;
|
||||
|
||||
public double AtrPipsA { get; init; } = double.NaN;
|
||||
|
||||
public double AtrPipsB { get; init; } = double.NaN;
|
||||
|
||||
public double SigmaX { get; init; } = double.NaN;
|
||||
|
||||
public double EwmaVolX { get; init; } = double.NaN;
|
||||
|
||||
public double TrendStrength { get; init; } = double.NaN;
|
||||
|
||||
public double SpreadPipsA { get; init; } = double.NaN;
|
||||
|
||||
public double SpreadPipsB { get; init; } = double.NaN;
|
||||
|
||||
public double CostPips { get; init; } = double.NaN;
|
||||
|
||||
public double BreakEvenWinRate { get; init; } = double.NaN;
|
||||
|
||||
public double HourSin { get; init; }
|
||||
|
||||
public double HourCos { get; init; }
|
||||
|
||||
public int DayOfWeek { get; init; }
|
||||
|
||||
public double PriceA { get; init; }
|
||||
|
||||
public double PriceB { get; init; }
|
||||
|
||||
public double PipsOpen { get; init; } = double.NaN;
|
||||
|
||||
public double PnlOpenUsd { get; init; } = double.NaN;
|
||||
|
||||
public int BarsHeld { get; init; }
|
||||
}
|
||||
|
||||
public enum DecisionKind
|
||||
{
|
||||
Skip = 0,
|
||||
Enter,
|
||||
Add,
|
||||
Exit,
|
||||
Hold,
|
||||
}
|
||||
|
||||
/// <summary>One decision: what to do, why, and the numbers behind it.</summary>
|
||||
public sealed record BasketDecision(
|
||||
DecisionKind Kind,
|
||||
bool BuyCross,
|
||||
SizingResult? Sizing,
|
||||
IReadOnlyList<string> ReasonCodes,
|
||||
string Motivazione,
|
||||
BasketEvaluation Evaluation,
|
||||
CostGateResult? Cost)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The strategy's brain, free of I/O: it turns a <see cref="BasketContext"/> into a
|
||||
/// <see cref="BasketDecision"/> by the rules of §5. The live engine and the backtest
|
||||
/// call the same code, which is the only way a backtest can say anything about the
|
||||
/// bot that will trade.
|
||||
/// </summary>
|
||||
public sealed class BasketDecider
|
||||
{
|
||||
private readonly BasketStrategyConfig _cfg;
|
||||
private readonly Dictionary<string, Anchor> _anchors = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, (DateTime At, double Value)> _halfLives = new(StringComparer.Ordinal);
|
||||
private BasketPreset _preset;
|
||||
|
||||
private sealed class Anchor
|
||||
{
|
||||
public double PriceA;
|
||||
public double PriceB;
|
||||
public int BarsSince;
|
||||
}
|
||||
|
||||
public BasketDecider(BasketStrategyConfig config, PresetName? preset = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_cfg = config;
|
||||
_preset = config.Effective(preset);
|
||||
}
|
||||
|
||||
public BasketStrategyConfig Config => _cfg;
|
||||
|
||||
public BasketPreset Preset => _preset;
|
||||
|
||||
/// <summary>Hot swap of the preset: open baskets keep the numbers they were opened with.</summary>
|
||||
public void SetPreset(PresetName name) => _preset = _cfg.Effective(name);
|
||||
|
||||
/// <summary>Bars of the timeframe in one day (M15 → 96).</summary>
|
||||
public const int BarsPerDay = 96;
|
||||
|
||||
public BasketDecision Evaluate(BasketContext ctx)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
BasketEvaluation eval = Compute(ctx);
|
||||
return ctx.Position is null ? DecideEntry(ctx, eval) : DecideOpen(ctx, eval);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Features
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public BasketEvaluation Compute(BasketContext ctx)
|
||||
{
|
||||
SymbolSeries a = ctx.A, b = ctx.B;
|
||||
int w = _cfg.Window;
|
||||
int n = Math.Min(a.Count, b.Count);
|
||||
(double hs, double hc) = BasketMath.HourFeatures(ctx.TimeUtc);
|
||||
|
||||
double priceA = a.Mid, priceB = b.Mid;
|
||||
BasketEvaluation partial = new()
|
||||
{
|
||||
HourSin = hs,
|
||||
HourCos = hc,
|
||||
DayOfWeek = (int)ctx.TimeUtc.DayOfWeek,
|
||||
PriceA = priceA,
|
||||
PriceB = priceB,
|
||||
SpreadPipsA = a.SpreadPips,
|
||||
SpreadPipsB = b.SpreadPips,
|
||||
BarsHeld = ctx.Position?.BarsHeld ?? 0,
|
||||
};
|
||||
|
||||
if (n < w + 2)
|
||||
{
|
||||
return partial;
|
||||
}
|
||||
|
||||
// Returns of the two legs, aligned on the last w bars (the series are appended in lockstep).
|
||||
ReadOnlySpan<double> ra = a.Returns(w);
|
||||
ReadOnlySpan<double> rb = b.Returns(w);
|
||||
double rho = BasketMath.Correlation(ra, rb);
|
||||
int ws = _cfg.WindowShort;
|
||||
double rhoShort = BasketMath.Correlation(ra[^ws..], rb[^ws..]);
|
||||
|
||||
// Synthetic cross level over the window and its z-score.
|
||||
ReadOnlySpan<double> la = a.LogCloses(w);
|
||||
ReadOnlySpan<double> lb = b.LogCloses(w);
|
||||
double[] x = new double[w];
|
||||
for (int i = 0; i < w; i++)
|
||||
{
|
||||
x[i] = la[i] + (ctx.Cross.SignB * lb[i]);
|
||||
}
|
||||
|
||||
double sigmaX = BasketMath.StdDev(x);
|
||||
double z = BasketMath.ZScore(x);
|
||||
|
||||
// Live value of the cross from the quotes, so the z the exit sees is the current one.
|
||||
if (!ctx.IsBarClose && priceA > 0 && priceB > 0 && sigmaX > 0)
|
||||
{
|
||||
double live = Math.Log(priceA) + (ctx.Cross.SignB * Math.Log(priceB));
|
||||
z = (live - BasketMath.Mean(x)) / sigmaX;
|
||||
}
|
||||
|
||||
// Half-life over a longer window, refreshed every few hours.
|
||||
double hl = HalfLifeFor(ctx, a, b);
|
||||
|
||||
double atrA = a.AtrPips(_cfg.AtrPeriod);
|
||||
double atrB = b.AtrPips(_cfg.AtrPeriod);
|
||||
|
||||
// EWMA vol of the cross's returns.
|
||||
int span = _cfg.EwmaSpan;
|
||||
ReadOnlySpan<double> ra4 = a.Returns(4 * span);
|
||||
ReadOnlySpan<double> rb4 = b.Returns(4 * span);
|
||||
int m = Math.Min(ra4.Length, rb4.Length);
|
||||
double[] rx = new double[m];
|
||||
for (int i = 0; i < m; i++)
|
||||
{
|
||||
rx[i] = ra4[^m..][i] + (ctx.Cross.SignB * rb4[^m..][i]);
|
||||
}
|
||||
|
||||
double ewma = BasketMath.EwmaVolatility(rx, span);
|
||||
|
||||
double trend = TrendOfCross(ctx, a, b);
|
||||
double zIn = EffectiveZIn(ctx);
|
||||
|
||||
double dPips = double.NaN;
|
||||
if (_cfg.SignalMode == SignalMode.PipDivergence)
|
||||
{
|
||||
dPips = Divergence(ctx, priceA, priceB);
|
||||
}
|
||||
|
||||
double pipsOpen = double.NaN, pnlOpen = double.NaN;
|
||||
if (ctx.Position is { } p && priceA > 0 && priceB > 0)
|
||||
{
|
||||
(double exitA, double exitB) = ExitPrices(ctx, p);
|
||||
pipsOpen = p.PipsTotal(exitA, exitB, a.Instrument.Pip, b.Instrument.Pip);
|
||||
pnlOpen = p.NetPnlUsd(exitA, exitB, ctx.Mid);
|
||||
}
|
||||
|
||||
return partial with
|
||||
{
|
||||
Z = z,
|
||||
ZInEffective = zIn,
|
||||
DPips = dPips,
|
||||
RhoW = rho,
|
||||
RhoShort = rhoShort,
|
||||
HalfLife = hl,
|
||||
AtrPipsA = atrA,
|
||||
AtrPipsB = atrB,
|
||||
SigmaX = sigmaX,
|
||||
EwmaVolX = ewma,
|
||||
TrendStrength = trend,
|
||||
PipsOpen = pipsOpen,
|
||||
PnlOpenUsd = pnlOpen,
|
||||
};
|
||||
}
|
||||
|
||||
private double HalfLifeFor(BasketContext ctx, SymbolSeries a, SymbolSeries b)
|
||||
{
|
||||
if (_halfLives.TryGetValue(ctx.BasketId, out (DateTime At, double Value) cached) &&
|
||||
ctx.TimeUtc - cached.At < TimeSpan.FromHours(_cfg.HalfLifeRecalcHours))
|
||||
{
|
||||
return cached.Value;
|
||||
}
|
||||
|
||||
int window = Math.Min(4 * _cfg.Window, Math.Min(a.Count, b.Count));
|
||||
if (window < 2 * _cfg.Window)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
ReadOnlySpan<double> la = a.LogCloses(window);
|
||||
ReadOnlySpan<double> lb = b.LogCloses(window);
|
||||
double[] x = new double[window];
|
||||
for (int i = 0; i < window; i++)
|
||||
{
|
||||
x[i] = la[i] + (ctx.Cross.SignB * lb[i]);
|
||||
}
|
||||
|
||||
double hl = BasketMath.HalfLife(x);
|
||||
_halfLives[ctx.BasketId] = (ctx.TimeUtc, hl);
|
||||
return hl;
|
||||
}
|
||||
|
||||
private double TrendOfCross(BasketContext ctx, SymbolSeries a, SymbolSeries b)
|
||||
{
|
||||
int period = _cfg.TrendPeriod;
|
||||
int count = 6 * period + 1;
|
||||
ReadOnlySpan<Data.BidAskBar> ba = a.LastBars(count);
|
||||
ReadOnlySpan<Data.BidAskBar> bb = b.LastBars(count);
|
||||
int n = Math.Min(ba.Length, bb.Length);
|
||||
if (n < (2 * period) + 1)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
// A synthetic bar of the cross: exp(ln A ± ln B) on open/high/low/close of the mids.
|
||||
Data.BidAskBar[] x = new Data.BidAskBar[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
Data.BidAskBar p = ba[^n..][i];
|
||||
Data.BidAskBar q = bb[^n..][i];
|
||||
double o = Cross(p.MidOpen, q.MidOpen), c = Cross(p.MidClose, q.MidClose);
|
||||
double h = ctx.Cross.SignB > 0 ? Cross(p.MidHigh, q.MidHigh) : Cross(p.MidHigh, q.MidLow);
|
||||
double l = ctx.Cross.SignB > 0 ? Cross(p.MidLow, q.MidLow) : Cross(p.MidLow, q.MidHigh);
|
||||
x[i] = new Data.BidAskBar(p.TimeUtc, o, Math.Max(h, l), Math.Min(h, l), c, o, Math.Max(h, l), Math.Min(h, l), c, 0, 0, "cross");
|
||||
}
|
||||
|
||||
return BasketMath.TrendStrength(x, period);
|
||||
|
||||
double Cross(double pa, double pb) => Math.Exp(Math.Log(pa) + (ctx.Cross.SignB * Math.Log(pb)));
|
||||
}
|
||||
|
||||
/// <summary>§5.8: zIn scaled by the volatility forecast relative to its 30-day average, within [0.8, 1.5].</summary>
|
||||
public double EffectiveZIn(BasketContext ctx)
|
||||
{
|
||||
double zIn = _preset.ZIn;
|
||||
if (double.IsFinite(ctx.SigmaForecast) && double.IsFinite(ctx.SigmaAverage30d) && ctx.SigmaAverage30d > 0)
|
||||
{
|
||||
zIn *= Math.Clamp(ctx.SigmaForecast / ctx.SigmaAverage30d, _cfg.VolScaleMin, _cfg.VolScaleMax);
|
||||
}
|
||||
|
||||
return zIn;
|
||||
}
|
||||
|
||||
private double Divergence(BasketContext ctx, double priceA, double priceB)
|
||||
{
|
||||
if (!_anchors.TryGetValue(ctx.BasketId, out Anchor? anchor) || anchor.BarsSince >= _cfg.AnchorBars)
|
||||
{
|
||||
anchor = new Anchor { PriceA = priceA, PriceB = priceB, BarsSince = 0 };
|
||||
_anchors[ctx.BasketId] = anchor;
|
||||
}
|
||||
else if (ctx.IsBarClose)
|
||||
{
|
||||
anchor.BarsSince++;
|
||||
}
|
||||
|
||||
double pipsA = (priceA - anchor.PriceA) / ctx.A.Instrument.Pip;
|
||||
double pipsB = (priceB - anchor.PriceB) / ctx.B.Instrument.Pip;
|
||||
return pipsA + (ctx.Cross.SignB * pipsB);
|
||||
}
|
||||
|
||||
private static (double ExitA, double ExitB) ExitPrices(BasketContext ctx, BasketPosition p)
|
||||
{
|
||||
double exitA = p.A.IsBuy ? (ctx.A.HasQuote ? ctx.A.Quote.Bid : ctx.A.Last.BidClose) : (ctx.A.HasQuote ? ctx.A.Quote.Ask : ctx.A.Last.AskClose);
|
||||
double exitB = p.B.IsBuy ? (ctx.B.HasQuote ? ctx.B.Quote.Bid : ctx.B.Last.BidClose) : (ctx.B.HasQuote ? ctx.B.Quote.Ask : ctx.B.Last.AskClose);
|
||||
return (exitA, exitB);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Entry (§5.3)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private BasketDecision DecideEntry(BasketContext ctx, BasketEvaluation e)
|
||||
{
|
||||
List<string> codes = [];
|
||||
|
||||
if (!ctx.IsBarClose)
|
||||
{
|
||||
return Skip(e, ["not_bar_close"], "in attesa della chiusura della barra", null);
|
||||
}
|
||||
|
||||
// Hard blocks first: they are the reasons nothing else matters, and the ledger
|
||||
// must say "halted" rather than "warming up" while a halt is active.
|
||||
if (ctx.KillSwitched) { codes.Add("kill_switch"); }
|
||||
if (ctx.EquityStopped) { codes.Add("equity_stop"); }
|
||||
if (ctx.DailyLossHit) { codes.Add("daily_loss"); }
|
||||
if (ctx.EntriesBlockedReason is not null) { codes.Add("entries_blocked"); }
|
||||
if (ctx.A.QualityIssue is not null || ctx.B.QualityIssue is not null) { codes.Add("data_quality"); }
|
||||
if (codes.Count > 0)
|
||||
{
|
||||
string blocked = codes[0] switch
|
||||
{
|
||||
"kill_switch" => "kill-switch attivo: nessuna nuova entrata",
|
||||
"equity_stop" => "equity stop attivo: serve un reset manuale",
|
||||
"daily_loss" => "perdita giornaliera massima raggiunta: niente entrate fino a domani",
|
||||
"entries_blocked" => $"entrate bloccate: {ctx.EntriesBlockedReason}",
|
||||
_ => $"qualità dati: {ctx.A.QualityIssue ?? ctx.B.QualityIssue}",
|
||||
};
|
||||
return Skip(e, codes, blocked, null);
|
||||
}
|
||||
|
||||
if (double.IsNaN(e.Z))
|
||||
{
|
||||
return Skip(e, ["warmup"], $"riscaldamento: servono {_cfg.Window + 2} barre su entrambe le gambe", null);
|
||||
}
|
||||
|
||||
// Signal (§5.2) and its direction.
|
||||
bool signal;
|
||||
bool buyCross;
|
||||
double zIn = e.ZInEffective;
|
||||
if (_cfg.SignalMode == SignalMode.PipDivergence)
|
||||
{
|
||||
signal = double.IsFinite(e.DPips) && Math.Abs(e.DPips) >= _cfg.DIn;
|
||||
buyCross = e.DPips < 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
signal = Math.Abs(e.Z) >= zIn;
|
||||
buyCross = e.Z < 0;
|
||||
}
|
||||
|
||||
// Correlation (1), half-life (2), regime.
|
||||
double expectedSign = ctx.Cross.ExpectedCorrelationSign;
|
||||
bool rhoOk = double.IsFinite(e.RhoW) && Math.Abs(e.RhoW) >= _cfg.RhoMin && Math.Sign(e.RhoW) == Math.Sign(expectedSign);
|
||||
bool rhoShortOk = double.IsFinite(e.RhoShort) && Math.Abs(e.RhoShort) >= _cfg.RhoShortMin;
|
||||
bool hlOk = double.IsFinite(e.HalfLife) && e.HalfLife >= _cfg.HalfLifeMinBars && e.HalfLife <= _cfg.HalfLifeMaxBars;
|
||||
|
||||
if (!signal)
|
||||
{
|
||||
string what = _cfg.SignalMode == SignalMode.PipDivergence
|
||||
? F($"|D| {Math.Abs(e.DPips):F1} pip sotto dIn {_cfg.DIn:F0}")
|
||||
: F($"|z| {Math.Abs(e.Z):F2} sotto zIn {zIn:F2}");
|
||||
return Skip(e, ["no_signal"], F($"{what}; ρ {e.RhoW:+0.00;-0.00}, HL {Fmt(e.HalfLife)}"), null);
|
||||
}
|
||||
|
||||
if (!rhoOk) { codes.Add("rho_low"); }
|
||||
if (!rhoShortOk) { codes.Add("rho_short_low"); }
|
||||
if (!hlOk) { codes.Add("half_life"); }
|
||||
|
||||
// Calendar (5) and time (6).
|
||||
if (ctx.MinutesToNextHigh != int.MaxValue && ctx.MinutesToNextHigh >= 0 && ctx.MinutesToNextHigh <= _cfg.BlackoutBeforeMin) { codes.Add("blackout_before"); }
|
||||
if (ctx.MinutesSinceLastHigh != int.MaxValue && ctx.MinutesSinceLastHigh >= 0 && ctx.MinutesSinceLastHigh <= _cfg.BlackoutAfterMin) { codes.Add("blackout_after"); }
|
||||
if (IsWeekendWindow(ctx.TimeUtc)) { codes.Add("weekend"); }
|
||||
if (ctx.JustOpened) { codes.Add("just_opened"); }
|
||||
if (!InSession(ctx.TimeUtc)) { codes.Add("session"); }
|
||||
|
||||
// Risk limits (7).
|
||||
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"); }
|
||||
|
||||
// Cost gate (4).
|
||||
double stopDistancePips = double.IsFinite(e.SigmaX) && e.AtrPipsA > 0
|
||||
? (_preset.ZStop - Math.Abs(e.Z)) * e.SigmaX / ctx.A.Instrument.Pip
|
||||
: 24;
|
||||
double overnight = double.IsFinite(ctx.OvernightPipsPerDay) ? ctx.OvernightPipsPerDay : _cfg.OvernightPipsPerDay;
|
||||
CostGateResult cost = CostGate.Evaluate(
|
||||
e.SpreadPipsA, e.SpreadPipsB, ctx.PipValueUsdA, ctx.PipValueUsdB,
|
||||
ctx.MarkupPipsA, ctx.MarkupPipsB, ctx.CommissionPipsA,
|
||||
overnight, (double)_cfg.MaxHoldingBars / BarsPerDay,
|
||||
TpPips(e), stopDistancePips, _cfg.CostMultiple,
|
||||
ctx.A.SpreadMedianPips24h(), ctx.B.SpreadMedianPips24h(), _cfg.SpreadMedianMultiple);
|
||||
if (!cost.Passed) { codes.Add("cost_gate"); }
|
||||
|
||||
BasketEvaluation withCost = e with { CostPips = cost.CostPips, BreakEvenWinRate = cost.BreakEvenWinRate };
|
||||
|
||||
if (codes.Count > 0)
|
||||
{
|
||||
return Skip(withCost, codes, Explain(codes, ctx, e, cost), cost);
|
||||
}
|
||||
|
||||
// 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,
|
||||
maxMargin, ctx.LeverageA, ctx.LeverageB);
|
||||
|
||||
if (sizing.Ok)
|
||||
{
|
||||
// Second pass with the cost in USD now that the units are known.
|
||||
costUsd *= sizing.UnitsA;
|
||||
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,
|
||||
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,
|
||||
maxMargin, ctx.LeverageA, ctx.LeverageB);
|
||||
}
|
||||
|
||||
if (!sizing.Ok)
|
||||
{
|
||||
return Skip(withCost, [sizing.Bound == "min_exposure" ? "min_exposure" : sizing.Bound == "margin" ? "margin" : "sizing"], $"size non calcolabile: {sizing.Reason}", cost);
|
||||
}
|
||||
|
||||
if (_cfg.InvertSignal)
|
||||
{
|
||||
buyCross = !buyCross;
|
||||
}
|
||||
|
||||
(bool buyA, bool buyB) = ctx.Cross.Legs(buyCross);
|
||||
string why = F($"{(buyCross ? "COMPRO" : "VENDO")} il cross {ctx.Cross.Symbol}: z {e.Z:+0.00;-0.00} oltre ±{zIn:0.00}, ρ_W {e.RhoW:+0.00;-0.00}, ρ_20 {e.RhoShort:+0.00;-0.00}, HL {Fmt(e.HalfLife)} barre; ") +
|
||||
$"{cost.Reason}; {sizing.Reason}; gambe {(buyA ? "long" : "short")} {ctx.A.Symbol} + {(buyB ? "long" : "short")} {ctx.B.Symbol}" +
|
||||
(ctx.MlActive ? F($"; p_ML {ctx.PMl:0.00} × scala {scale:0.00}") : double.IsFinite(ctx.PMl) ? F($"; p_ML {ctx.PMl:0.00} (ombra)") : string.Empty);
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>No entries from Friday's cutoff to the Sunday reopen.</summary>
|
||||
private bool IsWeekendWindow(DateTime utc) =>
|
||||
utc.DayOfWeek == DayOfWeek.Saturday ||
|
||||
(utc.DayOfWeek == DayOfWeek.Friday && utc.Hour >= _cfg.FridayCutoffUtcHour) ||
|
||||
(utc.DayOfWeek == DayOfWeek.Sunday && utc.Hour < 22);
|
||||
|
||||
private bool InSession(DateTime utc)
|
||||
{
|
||||
if (_cfg.Sessions.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ((int from, int to) in _cfg.Sessions)
|
||||
{
|
||||
if (from <= to ? utc.Hour >= from && utc.Hour < to : utc.Hour >= from || utc.Hour < to)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string Explain(List<string> codes, BasketContext ctx, BasketEvaluation e, CostGateResult cost)
|
||||
{
|
||||
List<string> parts = [];
|
||||
foreach (string c in codes)
|
||||
{
|
||||
parts.Add(c switch
|
||||
{
|
||||
"rho_low" => F($"ρ_W {e.RhoW:+0.00;-0.00} non è {(ctx.Cross.ExpectedCorrelationSign < 0 ? "≤ −" : "≥ +")}{_cfg.RhoMin:0.00}"),
|
||||
"rho_short_low" => F($"|ρ_20| {Math.Abs(e.RhoShort):0.00} sotto {_cfg.RhoShortMin:0.00}: correlazione rotta di recente"),
|
||||
"half_life" => F($"half-life {Fmt(e.HalfLife)} fuori da [{_cfg.HalfLifeMinBars:0}, {_cfg.HalfLifeMaxBars:0}] barre"),
|
||||
"blackout_before" => F($"evento ad alto impatto fra {ctx.MinutesToNextHigh} min (blackout {_cfg.BlackoutBeforeMin})"),
|
||||
"blackout_after" => F($"evento ad alto impatto {ctx.MinutesSinceLastHigh} min fa (blackout {_cfg.BlackoutAfterMin})"),
|
||||
"weekend" => F($"finestra del fine settimana (dal venerdì {_cfg.FridayCutoffUtcHour}:00 UTC alla riapertura)"),
|
||||
"just_opened" => F($"primi {_cfg.OpenDelayMinutes} minuti dopo l'apertura settimanale"),
|
||||
"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,
|
||||
});
|
||||
}
|
||||
|
||||
string signal = _cfg.SignalMode == SignalMode.PipDivergence ? F($"D {e.DPips:+0.0;-0.0} pip") : F($"z {e.Z:+0.00;-0.00}");
|
||||
return $"segnale ({signal}) ma NON ENTRO: " + string.Join("; ", parts);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Open basket: exits (§5.4) and adds (§5.5)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private BasketDecision DecideOpen(BasketContext ctx, BasketEvaluation e)
|
||||
{
|
||||
BasketPosition p = ctx.Position!;
|
||||
double tp = p.TpPips;
|
||||
bool pipsOk = double.IsFinite(e.PipsOpen);
|
||||
bool pnlOk = double.IsFinite(e.PnlOpenUsd);
|
||||
|
||||
// Forced exits that are valid on any quote.
|
||||
if (ctx.KillSwitched)
|
||||
{
|
||||
return Exit(e, ["kill_switch"], "kill-switch: chiudo il basket");
|
||||
}
|
||||
|
||||
if (ctx.EquityStopped)
|
||||
{
|
||||
return Exit(e, ["equity_stop"], "equity stop: chiudo il basket");
|
||||
}
|
||||
|
||||
if (pnlOk && e.PnlOpenUsd <= -p.MaxLossUsd)
|
||||
{
|
||||
return Exit(e, ["stop_max_loss"], F($"perdita netta {e.PnlOpenUsd:F2} USD oltre il massimo per basket {p.MaxLossUsd:F2} USD ({_cfg.MaxLossPerBasketPct:0.##} % dell'equity all'ingresso)"));
|
||||
}
|
||||
|
||||
// An anomalous spread is a forced exit only when it persists (three bar closes,
|
||||
// 45 minutes): closing into a momentary spike pays the very spread the rule is
|
||||
// meant to avoid, and the spike at a fixing or a news print is over in minutes.
|
||||
double medA = ctx.A.SpreadMedianPips24h();
|
||||
double medB = ctx.B.SpreadMedianPips24h();
|
||||
bool anomalous = (double.IsFinite(medA) && e.SpreadPipsA > _cfg.SpreadAnomalyMultiple * medA) || (double.IsFinite(medB) && e.SpreadPipsB > _cfg.SpreadAnomalyMultiple * medB);
|
||||
if (ctx.IsBarClose)
|
||||
{
|
||||
p.BarsWithSpreadAnomaly = anomalous ? p.BarsWithSpreadAnomaly + 1 : 0;
|
||||
if (p.BarsWithSpreadAnomaly >= 3)
|
||||
{
|
||||
return Exit(e, ["spread_anomaly"], F($"spread anomalo da {p.BarsWithSpreadAnomaly} barre (A {e.SpreadPipsA:F1} / mediana {medA:F1}, B {e.SpreadPipsB:F1} / mediana {medB:F1}): chiusura forzata"));
|
||||
}
|
||||
}
|
||||
|
||||
// Take-profit (either rule, per ExitMode).
|
||||
bool tpByPips = pipsOk && e.PipsOpen >= tp;
|
||||
bool tpByZ = double.IsFinite(e.Z) && Math.Abs(e.Z) <= _cfg.ZOut && (p.BuyCross ? e.Z >= -_cfg.ZOut : e.Z <= _cfg.ZOut);
|
||||
bool tpHit = _cfg.ExitMode switch
|
||||
{
|
||||
ExitMode.FixedPips => tpByPips,
|
||||
ExitMode.ZReturn => tpByZ,
|
||||
_ => tpByPips || tpByZ,
|
||||
};
|
||||
|
||||
if (tpHit)
|
||||
{
|
||||
string why = tpByPips
|
||||
? F($"take-profit: {e.PipsOpen:+0.0;-0.0} pip di basket ≥ TP {tp:0.0} (netto {Fmt2(e.PnlOpenUsd)} USD)")
|
||||
: F($"convergenza: |z| {Math.Abs(e.Z):0.00} ≤ zOut {_cfg.ZOut:0.00} ({e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD)");
|
||||
return Exit(e, [tpByPips ? "tp_pips" : "tp_z"], why);
|
||||
}
|
||||
|
||||
// z stop can be judged on any quote too; correlation and time only on bar close.
|
||||
if (double.IsFinite(e.Z) && (p.BuyCross ? e.Z <= -_preset.ZStop : e.Z >= _preset.ZStop))
|
||||
{
|
||||
return Exit(e, ["stop_z"], F($"stop di basket: z {e.Z:+0.00;-0.00} oltre ±{_preset.ZStop:0.00} ({e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD)"));
|
||||
}
|
||||
|
||||
if (!ctx.IsBarClose)
|
||||
{
|
||||
return Hold(e, F($"IN POSIZIONE — {e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD, z {e.Z:+0.00;-0.00}"));
|
||||
}
|
||||
|
||||
if (p.BarsHeld >= _cfg.MaxHoldingBars)
|
||||
{
|
||||
return Exit(e, ["time_stop"], F($"time-stop: {p.BarsHeld} barre ≥ {_cfg.MaxHoldingBars} ({e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD)"));
|
||||
}
|
||||
|
||||
if (double.IsFinite(e.RhoShort) && Math.Abs(e.RhoShort) < _cfg.RhoBreak)
|
||||
{
|
||||
p.BarsWithBrokenCorrelation++;
|
||||
if (p.BarsWithBrokenCorrelation >= _cfg.RhoBreakBars)
|
||||
{
|
||||
return Exit(e, ["rho_break"], F($"correlazione rotta: |ρ_20| {Math.Abs(e.RhoShort):0.00} < {_cfg.RhoBreak:0.00} per {p.BarsWithBrokenCorrelation} barre"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
p.BarsWithBrokenCorrelation = 0;
|
||||
}
|
||||
|
||||
// Averaging (§5.5): only when |z| grew by GridStepZ since the last entry, within MaxAdds.
|
||||
int maxAdds = _cfg.AveragingMode switch
|
||||
{
|
||||
AveragingMode.Off => 0,
|
||||
AveragingMode.AddOnce => Math.Min(1, _preset.MaxAdds),
|
||||
_ => _preset.MaxAdds,
|
||||
};
|
||||
|
||||
if (maxAdds > 0 && p.Adds < maxAdds && double.IsFinite(e.Z) &&
|
||||
Math.Abs(e.Z) - Math.Abs(p.LastAddZ) >= _cfg.GridStepZ && Math.Abs(e.Z) < _preset.ZStop &&
|
||||
ctx.EntriesBlockedReason is null && !ctx.DailyLossHit)
|
||||
{
|
||||
double scale = Math.Pow(_cfg.LotMultiplier, p.Adds + 1);
|
||||
SizingResult add = new(true, Math.Round(p.A.Units * scale, 2), Math.Round(p.B.Units * scale, 2), 0, 0, 0, 0,
|
||||
F($"aggiunta {p.Adds + 1}/{maxAdds}: |z| cresciuto di {Math.Abs(e.Z) - Math.Abs(p.LastAddZ):0.00} ≥ {_cfg.GridStepZ:0.00}, moltiplicatore {scale:0.00}"));
|
||||
return new BasketDecision(DecisionKind.Add, p.BuyCross, add, ["add"], add.Reason, e, null);
|
||||
}
|
||||
|
||||
return Hold(e, F($"IN POSIZIONE — {e.PipsOpen:+0.0;-0.0} pip, netto {Fmt2(e.PnlOpenUsd)} USD, z {e.Z:+0.00;-0.00} (entrata {p.EntryZ:+0.00;-0.00}), {p.BarsHeld}/{_cfg.MaxHoldingBars} barre"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static BasketDecision Skip(BasketEvaluation e, IReadOnlyList<string> codes, string why, CostGateResult? cost) =>
|
||||
new(DecisionKind.Skip, false, null, codes, why, e, cost);
|
||||
|
||||
private static BasketDecision Exit(BasketEvaluation e, IReadOnlyList<string> codes, string why) =>
|
||||
new(DecisionKind.Exit, false, null, codes, why, e, null);
|
||||
|
||||
private static BasketDecision Hold(BasketEvaluation e, string why) =>
|
||||
new(DecisionKind.Hold, false, null, ["hold"], why, e, null);
|
||||
|
||||
private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Fmt(double v) => double.IsFinite(v) ? v.ToString("0", CultureInfo.InvariantCulture) : "n/d";
|
||||
|
||||
private static string Fmt2(double v) => double.IsFinite(v) ? v.ToString("+0.00;-0.00;0.00", CultureInfo.InvariantCulture) : "n/d";
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Broker;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>Which leg, if any, is still waiting for the venue's word after an entry attempt.</summary>
|
||||
public enum PendingLeg
|
||||
{
|
||||
None = 0,
|
||||
A,
|
||||
B,
|
||||
}
|
||||
|
||||
/// <summary>What opening (or adding to) a basket produced.</summary>
|
||||
public sealed record EntryOutcome(
|
||||
bool Ok,
|
||||
BasketPosition? Position,
|
||||
bool Unwound,
|
||||
string Error,
|
||||
double SlippagePipsA,
|
||||
double SlippagePipsB,
|
||||
double LatencyMs)
|
||||
{
|
||||
/// <summary>When a leg's outcome is unknown the basket is neither open nor flat: the order register follows it.</summary>
|
||||
public PendingLeg PendingLeg { get; init; }
|
||||
|
||||
/// <summary>Everything needed to finish (or undo) the entry once the pending leg resolves.</summary>
|
||||
public PendingEntry? Pending { get; init; }
|
||||
|
||||
public TrackedOrder? PendingOrder { get; init; }
|
||||
|
||||
public bool IsPending => PendingLeg != PendingLeg.None;
|
||||
}
|
||||
|
||||
/// <summary>What closing a basket produced.</summary>
|
||||
public sealed record ExitOutcome(
|
||||
bool Ok,
|
||||
double ExitPriceA,
|
||||
double ExitPriceB,
|
||||
double RealizedPnlUsd,
|
||||
double PipsTotal,
|
||||
double SlippagePipsA,
|
||||
double SlippagePipsB,
|
||||
string Error,
|
||||
DateTime ClosedUtc,
|
||||
IReadOnlyList<long> StuckPositionIds);
|
||||
|
||||
/// <summary>
|
||||
/// The two-leg execution protocol of §5.7, over any <see cref="IBroker"/>, with the
|
||||
/// order register of the 5.0 plan.
|
||||
/// <list type="number">
|
||||
/// <item>Every order is registered <b>before</b> it is sent, with a unique client reference.</item>
|
||||
/// <item>Send leg A at market and wait for its fill: the venue's own answer, then the
|
||||
/// lookup by <c>orderId</c>, then the position list. Past the leg timeout the basket
|
||||
/// becomes <c>PendingA</c> and the register keeps asking; nothing is resent.</item>
|
||||
/// <item>Leg B is sized on the units leg A really got, then sent. If B is rejected,
|
||||
/// A is closed at once (<c>leg_risk_unwind</c>); if B is unknown past the timeout the
|
||||
/// basket becomes <c>PendingB</c>.</item>
|
||||
/// <item>On exit both legs are closed; a leg that fails is retried three times with
|
||||
/// backoff and then reported as stuck.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class BasketExecutor
|
||||
{
|
||||
private readonly IBroker _broker;
|
||||
private readonly BasketStrategyConfig _cfg;
|
||||
private readonly Func<string, double?> _mid;
|
||||
private readonly Action<string> _log;
|
||||
private readonly OrderTracker? _tracker;
|
||||
private readonly string _mode;
|
||||
|
||||
public BasketExecutor(IBroker broker, BasketStrategyConfig config, Func<string, double?> mid, Action<string> log, OrderTracker? tracker = null, string mode = "")
|
||||
{
|
||||
_broker = broker ?? throw new ArgumentNullException(nameof(broker));
|
||||
_cfg = config ?? throw new ArgumentNullException(nameof(config));
|
||||
_mid = mid ?? throw new ArgumentNullException(nameof(mid));
|
||||
_log = log ?? (static _ => { });
|
||||
_tracker = tracker;
|
||||
_mode = mode ?? string.Empty;
|
||||
}
|
||||
|
||||
public Task<EntryOutcome> OpenAsync(BasketContext ctx, BasketDecision decision, BasketPreset preset, CancellationToken ct) =>
|
||||
OpenAsync(ctx, decision, preset, ctx?.BasketId ?? string.Empty, ct);
|
||||
|
||||
/// <summary>Opens a basket: leg A, then leg B. <paramref name="basketId"/> is the instance id written to the ledger.</summary>
|
||||
public async Task<EntryOutcome> OpenAsync(BasketContext ctx, BasketDecision decision, BasketPreset preset, string basketId, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
ArgumentNullException.ThrowIfNull(preset);
|
||||
if (decision.Kind != DecisionKind.Enter || decision.Sizing is not { Ok: true } sizing)
|
||||
{
|
||||
return new EntryOutcome(false, null, false, "nessuna decisione di ingresso", 0, 0, 0);
|
||||
}
|
||||
|
||||
long t0 = Environment.TickCount64;
|
||||
(bool buyA, bool buyB) = ctx.Cross.Legs(decision.BuyCross);
|
||||
double quoteA = buyA ? ctx.A.Quote.Ask : ctx.A.Quote.Bid;
|
||||
double quoteB = buyB ? ctx.B.Quote.Ask : ctx.B.Quote.Bid;
|
||||
|
||||
PendingEntry plan = new()
|
||||
{
|
||||
BasketId = basketId,
|
||||
BuyCross = decision.BuyCross,
|
||||
EntryZ = decision.Evaluation.Z,
|
||||
UnitsA = sizing.UnitsA,
|
||||
UnitsB = sizing.UnitsB,
|
||||
TpPips = _cfg.TpMode == TpMode.AtrMultiple && double.IsFinite(decision.Evaluation.AtrPipsA) ? Math.Max(1, _cfg.TpAtrMultiple * decision.Evaluation.AtrPipsA) : preset.TpPips,
|
||||
MaxLossUsd = ctx.Equity * _cfg.MaxLossPerBasketPct / 100.0,
|
||||
EntryCostPips = decision.Cost?.CostPips ?? double.NaN,
|
||||
EquityAtEntry = ctx.Equity,
|
||||
Motivazione = decision.Motivazione,
|
||||
DecidedUtc = ctx.TimeUtc,
|
||||
QuoteA = quoteA,
|
||||
QuoteB = quoteB,
|
||||
};
|
||||
|
||||
OrderRequest reqA = Request(ctx.A, buyA, plan.UnitsA, quoteA, decision.Motivazione);
|
||||
plan.ClientRefA = reqA.ClientRef;
|
||||
TrackedOrder trackA = Track(reqA, ctx.Name, basketId, OrderLeg.A, quoteA, decision.Motivazione);
|
||||
OrderOutcome a = await SendAsync(reqA, trackA, ct).ConfigureAwait(false);
|
||||
if (a.Pending)
|
||||
{
|
||||
_log($"[{ctx.Name}] gamba A ({ctx.A.Symbol}) senza esito dopo {_cfg.LegTimeoutSec} s: resta nel registro degli ordini, il basket aspetta ({Describe(a)})");
|
||||
return new EntryOutcome(false, null, false, $"gamba A ({ctx.A.Symbol}) in attesa di esito: {Describe(a)}", 0, 0, Environment.TickCount64 - t0)
|
||||
{
|
||||
PendingLeg = PendingLeg.A,
|
||||
Pending = plan,
|
||||
PendingOrder = trackA,
|
||||
};
|
||||
}
|
||||
|
||||
if (!a.Filled)
|
||||
{
|
||||
return new EntryOutcome(false, null, false, $"gamba A ({ctx.A.Symbol}) non eseguita: {Describe(a)}", 0, 0, Environment.TickCount64 - t0);
|
||||
}
|
||||
|
||||
plan.LegA = LegFrom(ctx.A, buyA, a, plan.UnitsA, quoteA, reqA.ClientRef, reqA.StopLossRate ?? 0);
|
||||
return await SendLegBAsync(ctx, plan, t0, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Leg A, sent earlier and left pending, has been filled: carries on with leg B.</summary>
|
||||
public Task<EntryOutcome> ResumeAfterAAsync(BasketContext ctx, PendingEntry plan, OrderOutcome legAOutcome, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(legAOutcome);
|
||||
(bool buyA, _) = ctx.Cross.Legs(plan.BuyCross);
|
||||
plan.LegA = LegFrom(ctx.A, buyA, legAOutcome, plan.UnitsA, plan.QuoteA, plan.ClientRefA, 0);
|
||||
return SendLegBAsync(ctx, plan, Environment.TickCount64, ct);
|
||||
}
|
||||
|
||||
/// <summary>Leg B, sent earlier and left pending, has resolved: completes the basket or undoes leg A.</summary>
|
||||
public async Task<EntryOutcome> CompleteAfterBAsync(BasketContext ctx, PendingEntry plan, OrderOutcome legBOutcome, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(legBOutcome);
|
||||
if (plan.LegA is not { } legA)
|
||||
{
|
||||
return new EntryOutcome(false, null, false, "gamba A non registrata nel piano: impossibile completare", 0, 0, 0);
|
||||
}
|
||||
|
||||
(_, bool buyB) = ctx.Cross.Legs(plan.BuyCross);
|
||||
if (!legBOutcome.Filled)
|
||||
{
|
||||
return await UnwindAAsync(ctx, plan, legA, $"gamba B non eseguita: {Describe(legBOutcome)}", 0, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Complete(ctx, plan, legA, legBOutcome, buyB, plan.QuoteB, plan.ClientRefB, 0);
|
||||
}
|
||||
|
||||
private async Task<EntryOutcome> SendLegBAsync(BasketContext ctx, PendingEntry plan, long t0, CancellationToken ct)
|
||||
{
|
||||
BasketLeg legA = plan.LegA!;
|
||||
(_, bool buyB) = ctx.Cross.Legs(plan.BuyCross);
|
||||
double quoteB = buyB ? ctx.B.Quote.Ask : ctx.B.Quote.Bid;
|
||||
|
||||
// B is sized on what A really got: the venue may have reduced A (observed on
|
||||
// 2026-09-16), and a full-size B against a reduced A is not the basket that was decided.
|
||||
double unitsB = plan.UnitsB;
|
||||
if (plan.UnitsA > 0 && legA.Units > 0 && legA.Units < plan.UnitsA * 0.99)
|
||||
{
|
||||
unitsB = Math.Round(plan.UnitsB * legA.Units / plan.UnitsA, 2);
|
||||
_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);
|
||||
OrderOutcome b = await SendAsync(reqB, trackB, ct).ConfigureAwait(false);
|
||||
if (b.Pending)
|
||||
{
|
||||
_log($"[{ctx.Name}] gamba B ({ctx.B.Symbol}) senza esito dopo {_cfg.LegTimeoutSec} s: resta nel registro, il basket aspetta con la gamba A aperta ({Describe(b)})");
|
||||
return new EntryOutcome(false, null, false, $"gamba B ({ctx.B.Symbol}) in attesa di esito: {Describe(b)}", SlipPips(ctx.A, legA.IsBuy, plan.QuoteA, legA.EntryPrice), 0, Environment.TickCount64 - t0)
|
||||
{
|
||||
PendingLeg = PendingLeg.B,
|
||||
Pending = plan,
|
||||
PendingOrder = trackB,
|
||||
};
|
||||
}
|
||||
|
||||
if (!b.Filled)
|
||||
{
|
||||
// Leg risk: A is alone in the market. Undo it now.
|
||||
_log($"[{ctx.Name}] gamba B ({ctx.B.Symbol}) non eseguita ({Describe(b)}): chiudo subito la gamba A (leg_risk_unwind)");
|
||||
return await UnwindAAsync(ctx, plan, legA, $"gamba B non eseguita: {Describe(b)}", t0, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Complete(ctx, plan, legA, b, buyB, quoteB, reqB.ClientRef, t0);
|
||||
}
|
||||
|
||||
private async Task<EntryOutcome> UnwindAAsync(BasketContext ctx, PendingEntry plan, BasketLeg legA, string why, long t0, CancellationToken ct)
|
||||
{
|
||||
CloseOutcome undo = await CloseLegAsync(legA.PositionId, legA.InstrumentId, TrackClose(legA, ctx.Name, plan.BasketId, OrderLeg.Unwind, "leg_risk_unwind: " + why), ct).ConfigureAwait(false);
|
||||
string error = $"{why}; gamba A {(undo.Closed ? "richiusa" : "NON richiusa: " + undo.Error)}";
|
||||
return new EntryOutcome(false, null, undo.Closed, error, SlipPips(ctx.A, legA.IsBuy, plan.QuoteA, legA.EntryPrice), 0, Environment.TickCount64 - t0);
|
||||
}
|
||||
|
||||
/// <summary>Closes a lone leg (a pending A whose signal decayed, an orphan): three attempts, verified on the position list.</summary>
|
||||
public Task<CloseOutcome> UnwindLegAsync(BasketLeg leg, string basket, string basketId, string reason, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(leg);
|
||||
return CloseLegAsync(leg.PositionId, leg.InstrumentId, TrackClose(leg, basket, basketId, OrderLeg.Unwind, reason), ct);
|
||||
}
|
||||
|
||||
/// <summary>Closes one position that is not a leg of any basket (an orphan, or a residue at the kill-switch).</summary>
|
||||
public Task<CloseOutcome> ClosePositionAsync(BrokerPosition position, string symbol, string basket, string reason, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(position);
|
||||
TrackedOrder track = new()
|
||||
{
|
||||
ClientRef = Guid.NewGuid().ToString("D"),
|
||||
Symbol = symbol,
|
||||
InstrumentId = position.InstrumentId,
|
||||
IsBuy = !position.IsBuy,
|
||||
RequestedUnits = position.Units,
|
||||
RequestedPrice = position.CurrentRate,
|
||||
Basket = basket,
|
||||
Leg = OrderLeg.Unwind,
|
||||
SentUtc = DateTime.UtcNow,
|
||||
Mode = _mode,
|
||||
Motivazione = reason,
|
||||
};
|
||||
return CloseLegAsync(position.PositionId, position.InstrumentId, track, ct);
|
||||
}
|
||||
|
||||
private EntryOutcome Complete(BasketContext ctx, PendingEntry plan, BasketLeg legA, OrderOutcome b, bool buyB, double quoteB, string clientRefB, long t0)
|
||||
{
|
||||
BasketLeg legB = LegFrom(ctx.B, buyB, b, plan.UnitsB, quoteB, clientRefB, 0);
|
||||
BasketPosition position = new()
|
||||
{
|
||||
BasketId = plan.BasketId,
|
||||
Name = ctx.Name,
|
||||
BuyCross = plan.BuyCross,
|
||||
A = legA,
|
||||
B = legB,
|
||||
OpenedUtc = plan.DecidedUtc,
|
||||
EntryZ = plan.EntryZ,
|
||||
LastAddZ = plan.EntryZ,
|
||||
EntryCostPips = plan.EntryCostPips,
|
||||
TpPips = plan.TpPips,
|
||||
MaxLossUsd = plan.MaxLossUsd,
|
||||
EquityAtEntry = plan.EquityAtEntry,
|
||||
EntryMotivazione = plan.Motivazione,
|
||||
};
|
||||
|
||||
return new EntryOutcome(true, position, false, string.Empty,
|
||||
SlipPips(ctx.A, legA.IsBuy, plan.QuoteA, legA.EntryPrice), SlipPips(ctx.B, buyB, quoteB, legB.EntryPrice), Environment.TickCount64 - t0);
|
||||
}
|
||||
|
||||
private static BasketLeg LegFrom(SymbolSeries s, bool isBuy, OrderOutcome o, double requestedUnits, double quote, string clientRef, double stop) => new()
|
||||
{
|
||||
Symbol = s.Symbol,
|
||||
InstrumentId = s.Instrument.Id,
|
||||
IsBuy = isBuy,
|
||||
Units = o.Units > 0 ? o.Units : requestedUnits,
|
||||
EntryPrice = o.FillRate > 0 ? o.FillRate : quote,
|
||||
PositionId = o.PositionId,
|
||||
ClientRef = clientRef,
|
||||
OpenedUtc = o.TimeUtc == default ? DateTime.UtcNow : o.TimeUtc,
|
||||
EntryFeesUsd = o.Fees,
|
||||
StopLossRate = stop,
|
||||
};
|
||||
|
||||
/// <summary>Adds to both legs of an open basket (a new position per leg on eToro).</summary>
|
||||
public async Task<EntryOutcome> AddAsync(BasketContext ctx, BasketDecision decision, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
if (ctx.Position is not { } p || decision.Kind != DecisionKind.Add || decision.Sizing is not { Ok: true } sizing)
|
||||
{
|
||||
return new EntryOutcome(false, null, false, "nessuna decisione di aggiunta", 0, 0, 0);
|
||||
}
|
||||
|
||||
long t0 = Environment.TickCount64;
|
||||
double quoteA = p.A.IsBuy ? ctx.A.Quote.Ask : ctx.A.Quote.Bid;
|
||||
double quoteB = p.B.IsBuy ? ctx.B.Quote.Ask : ctx.B.Quote.Bid;
|
||||
|
||||
OrderRequest reqA = Request(ctx.A, p.A.IsBuy, sizing.UnitsA, quoteA, decision.Motivazione);
|
||||
OrderOutcome a = await SendAsync(reqA, Track(reqA, ctx.Name, p.BasketId, OrderLeg.Add, quoteA, decision.Motivazione), ct).ConfigureAwait(false);
|
||||
if (!a.Filled)
|
||||
{
|
||||
// A pending add stays in the register: if it fills later it is an orphan and the reconciliation closes it.
|
||||
return new EntryOutcome(false, p, false, $"aggiunta su A non eseguita: {Describe(a)}", 0, 0, Environment.TickCount64 - t0);
|
||||
}
|
||||
|
||||
OrderRequest reqB = Request(ctx.B, p.B.IsBuy, sizing.UnitsB, quoteB, decision.Motivazione);
|
||||
OrderOutcome b = await SendAsync(reqB, Track(reqB, ctx.Name, p.BasketId, OrderLeg.Add, quoteB, decision.Motivazione), ct).ConfigureAwait(false);
|
||||
if (!b.Filled)
|
||||
{
|
||||
_log($"[{ctx.Name}] aggiunta su B non eseguita ({Describe(b)}): richiudo l'aggiunta su A (leg_risk_unwind)");
|
||||
TrackedOrder undoTrack = new()
|
||||
{
|
||||
ClientRef = Guid.NewGuid().ToString("D"),
|
||||
Symbol = ctx.A.Symbol,
|
||||
InstrumentId = ctx.A.Instrument.Id,
|
||||
IsBuy = !p.A.IsBuy,
|
||||
RequestedUnits = a.Units > 0 ? a.Units : sizing.UnitsA,
|
||||
RequestedPrice = quoteA,
|
||||
Basket = ctx.Name,
|
||||
BasketId = p.BasketId,
|
||||
Leg = OrderLeg.Unwind,
|
||||
SentUtc = DateTime.UtcNow,
|
||||
Mode = _mode,
|
||||
Motivazione = "leg_risk_unwind dell'aggiunta",
|
||||
};
|
||||
CloseOutcome undo = await CloseLegAsync(a.PositionId, ctx.A.Instrument.Id, undoTrack, ct).ConfigureAwait(false);
|
||||
return new EntryOutcome(false, p, undo.Closed, $"aggiunta su B non eseguita: {Describe(b)}", 0, 0, Environment.TickCount64 - t0);
|
||||
}
|
||||
|
||||
Merge(p.A, a, sizing.UnitsA, quoteA, reqA.ClientRef);
|
||||
Merge(p.B, b, sizing.UnitsB, quoteB, reqB.ClientRef);
|
||||
p.Adds++;
|
||||
p.LastAddZ = decision.Evaluation.Z;
|
||||
return new EntryOutcome(true, p, false, string.Empty, SlipPips(ctx.A, p.A.IsBuy, quoteA, a.FillRate), SlipPips(ctx.B, p.B.IsBuy, quoteB, b.FillRate), Environment.TickCount64 - t0);
|
||||
}
|
||||
|
||||
private static void Merge(BasketLeg leg, OrderOutcome fill, double requestedUnits, double quote, string clientRef)
|
||||
{
|
||||
double units = fill.Units > 0 ? fill.Units : requestedUnits;
|
||||
double price = fill.FillRate > 0 ? fill.FillRate : quote;
|
||||
double before = leg.TotalUnits;
|
||||
leg.Adds.Add((fill.PositionId, units, price, clientRef));
|
||||
leg.EntryPrice = ((leg.EntryPrice * before) + (price * units)) / (before + units);
|
||||
leg.EntryFeesUsd += fill.Fees;
|
||||
}
|
||||
|
||||
/// <summary>Closes both legs, with retries; reports what is still open when a leg refuses to close.</summary>
|
||||
public async Task<ExitOutcome> CloseAsync(BasketContext ctx, BasketPosition p, string reason, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(p);
|
||||
|
||||
double quoteA = p.A.IsBuy ? ctx.A.Quote.Bid : ctx.A.Quote.Ask;
|
||||
double quoteB = p.B.IsBuy ? ctx.B.Quote.Bid : ctx.B.Quote.Ask;
|
||||
List<long> stuck = [];
|
||||
DateTime closedUtc = DateTime.UtcNow;
|
||||
|
||||
(double priceA, double pnlA, bool okA) = await CloseLegAllAsync(p.A, ctx.A.Instrument.Id, ctx.Name, p.BasketId, reason, stuck, ct).ConfigureAwait(false);
|
||||
(double priceB, double pnlB, bool okB) = await CloseLegAllAsync(p.B, ctx.B.Instrument.Id, ctx.Name, p.BasketId, reason, stuck, ct).ConfigureAwait(false);
|
||||
|
||||
double exitA = priceA > 0 ? priceA : quoteA;
|
||||
double exitB = priceB > 0 ? priceB : quoteB;
|
||||
|
||||
// Realised P&L: the venue's number when it reports one, our own otherwise.
|
||||
double own = p.NetPnlUsd(exitA, exitB, _mid);
|
||||
double pnl = okA && okB && (pnlA != 0 || pnlB != 0) ? pnlA + pnlB - p.AccruedFeesUsd : (double.IsNaN(own) ? 0 : own);
|
||||
double pips = p.PipsTotal(exitA, exitB, ctx.A.Instrument.Pip, ctx.B.Instrument.Pip);
|
||||
|
||||
bool ok = okA && okB;
|
||||
return new ExitOutcome(ok, exitA, exitB, pnl, pips,
|
||||
SlipPips(ctx.A, !p.A.IsBuy, quoteA, exitA), SlipPips(ctx.B, !p.B.IsBuy, quoteB, exitB),
|
||||
ok ? string.Empty : $"gambe non chiuse: {string.Join(", ", stuck)}", closedUtc, stuck);
|
||||
}
|
||||
|
||||
private async Task<(double Price, double Pnl, bool Ok)> CloseLegAllAsync(BasketLeg leg, long instrumentId, string basket, string basketId, string reason, List<long> stuck, CancellationToken ct)
|
||||
{
|
||||
double weighted = 0, units = 0, pnl = 0;
|
||||
bool ok = true;
|
||||
foreach (long id in leg.AllPositionIds.ToList())
|
||||
{
|
||||
double expected = id == leg.PositionId ? leg.Units : leg.Adds.FirstOrDefault(a => a.PositionId == id).Units;
|
||||
CloseOutcome c = await CloseLegAsync(id, instrumentId, TrackClose(leg, basket, basketId, OrderLeg.Close, reason, id, expected), ct).ConfigureAwait(false);
|
||||
if (c.Closed)
|
||||
{
|
||||
double u = c.Units > 0 ? c.Units : expected;
|
||||
weighted += c.CloseRate * u;
|
||||
units += u;
|
||||
pnl += c.RealizedPnl;
|
||||
}
|
||||
else
|
||||
{
|
||||
ok = false;
|
||||
stuck.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return (units > 0 ? weighted / units : 0, pnl, ok);
|
||||
}
|
||||
|
||||
/// <summary>Three attempts with backoff; a pending outcome is re-checked against the position list.</summary>
|
||||
private async Task<CloseOutcome> CloseLegAsync(long positionId, long instrumentId, TrackedOrder? track, CancellationToken ct)
|
||||
{
|
||||
if (track is not null)
|
||||
{
|
||||
_tracker?.Register(track);
|
||||
}
|
||||
|
||||
CloseOutcome last = new(false, false, 0, 0, 0, DateTime.UtcNow, 0, "non tentata");
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
last = await _broker.CloseAsync(positionId, instrumentId, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (BrokerException ex)
|
||||
{
|
||||
last = new CloseOutcome(false, false, 0, 0, 0, DateTime.UtcNow, 0, ex.Message);
|
||||
}
|
||||
|
||||
if (last.Closed)
|
||||
{
|
||||
ApplyClose(track, last);
|
||||
return last;
|
||||
}
|
||||
|
||||
// Pending on the venue, or a transient failure: is the position still there?
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500 * (1 << attempt)), ct).ConfigureAwait(false);
|
||||
IReadOnlyList<BrokerPosition> positions;
|
||||
try
|
||||
{
|
||||
positions = await _broker.GetPositionsAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (BrokerException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (positions.All(x => x.PositionId != positionId))
|
||||
{
|
||||
// Gone from the account: closed by the venue (our order, or a native stop).
|
||||
last = new CloseOutcome(true, false, last.OrderId, last.CloseRate, last.Units, DateTime.UtcNow, last.RealizedPnl, string.Empty);
|
||||
ApplyClose(track, last);
|
||||
return last;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyClose(track, last);
|
||||
return last;
|
||||
}
|
||||
|
||||
private void ApplyClose(TrackedOrder? track, CloseOutcome c)
|
||||
{
|
||||
if (track is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OrderOutcome o = new(c.Closed, c.Rejected, c.OrderId, 0, c.CloseRate, c.Units, c.TimeUtc, 0, c.Closed ? "Closed" : c.Rejected ? "Rejected" : "Unknown", c.Error)
|
||||
{
|
||||
RequestedUnits = track.RequestedUnits,
|
||||
Source = "venue",
|
||||
};
|
||||
if (_tracker is not null)
|
||||
{
|
||||
_tracker.Apply(track, o, DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
track.Apply(o, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one leg and waits for its outcome up to the leg timeout. The venue's own
|
||||
/// answer first; then the lookup by <c>orderId</c> (by client reference only when the
|
||||
/// submit's answer was lost). Never resends. Past the timeout the order is returned
|
||||
/// pending: it stays in the register, which keeps asking.
|
||||
/// </summary>
|
||||
private async Task<OrderOutcome> SendAsync(OrderRequest request, TrackedOrder track, CancellationToken ct)
|
||||
{
|
||||
OrderOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await _broker.OpenAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (BrokerException ex)
|
||||
{
|
||||
// The request may or may not have reached the venue: unknown, not rejected.
|
||||
outcome = OrderOutcome.Unknown(0, request.Units, ex.Message);
|
||||
}
|
||||
|
||||
Apply(track, outcome);
|
||||
if (!outcome.Pending)
|
||||
{
|
||||
return track.ToOutcome();
|
||||
}
|
||||
|
||||
DateTime deadline = DateTime.UtcNow.AddSeconds(_cfg.LegTimeoutSec);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(700, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
OrderOutcome? looked = track.OrderId > 0
|
||||
? await _broker.LookupOrderByIdAsync(track.OrderId, ct).ConfigureAwait(false)
|
||||
: await _broker.LookupOrderAsync(request.ClientRef, ct).ConfigureAwait(false);
|
||||
if (looked is not null)
|
||||
{
|
||||
Apply(track, looked);
|
||||
if (!looked.Pending)
|
||||
{
|
||||
return track.ToOutcome();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (BrokerException)
|
||||
{
|
||||
// Try again until the deadline.
|
||||
}
|
||||
}
|
||||
|
||||
if (track.Error.Length == 0)
|
||||
{
|
||||
track.Error = $"esito sconosciuto dopo {_cfg.LegTimeoutSec} s";
|
||||
}
|
||||
|
||||
return track.ToOutcome();
|
||||
}
|
||||
|
||||
private void Apply(TrackedOrder track, OrderOutcome outcome)
|
||||
{
|
||||
if (_tracker is not null)
|
||||
{
|
||||
_tracker.Apply(track, outcome, DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
track.Apply(outcome, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
private TrackedOrder Track(OrderRequest req, string basket, string basketId, OrderLeg leg, double quote, string reason)
|
||||
{
|
||||
TrackedOrder t = new()
|
||||
{
|
||||
ClientRef = req.ClientRef,
|
||||
Symbol = req.Symbol,
|
||||
InstrumentId = req.InstrumentId,
|
||||
IsBuy = req.IsBuy,
|
||||
RequestedUnits = req.Units,
|
||||
RequestedPrice = quote,
|
||||
Basket = basket,
|
||||
BasketId = basketId,
|
||||
Leg = leg,
|
||||
SentUtc = DateTime.UtcNow,
|
||||
Mode = _mode,
|
||||
Motivazione = reason.Length > 160 ? reason[..160] : reason,
|
||||
};
|
||||
_tracker?.Register(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
private TrackedOrder TrackClose(BasketLeg leg, string basket, string basketId, OrderLeg kind, string reason, long positionId = 0, double units = 0) => new()
|
||||
{
|
||||
ClientRef = Guid.NewGuid().ToString("D"),
|
||||
Symbol = leg.Symbol,
|
||||
InstrumentId = leg.InstrumentId,
|
||||
IsBuy = !leg.IsBuy,
|
||||
RequestedUnits = units > 0 ? units : leg.Units,
|
||||
RequestedPrice = _mid(leg.Symbol) ?? 0,
|
||||
Basket = basket,
|
||||
BasketId = basketId,
|
||||
Leg = kind,
|
||||
SentUtc = DateTime.UtcNow,
|
||||
Mode = _mode,
|
||||
Motivazione = (positionId > 0 ? string.Create(CultureInfo.InvariantCulture, $"posizione {positionId}: ") : string.Empty) + (reason.Length > 160 ? reason[..160] : reason),
|
||||
};
|
||||
|
||||
private OrderRequest Request(SymbolSeries s, bool isBuy, double units, double quote, string reason)
|
||||
{
|
||||
// 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, _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);
|
||||
double distance = quote * (pct / 100.0) / Math.Max(1, leverage);
|
||||
double stop = s.Instrument.RoundPrice(isBuy ? quote - distance : quote + distance);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(instrument);
|
||||
if (instrument.AllowedLeverages.Length == 0)
|
||||
{
|
||||
return wanted;
|
||||
}
|
||||
|
||||
int best = instrument.AllowedLeverages[0];
|
||||
foreach (int l in instrument.AllowedLeverages)
|
||||
{
|
||||
if (l <= wanted && l > best)
|
||||
{
|
||||
best = l;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double SlipPips(SymbolSeries s, bool isBuy, double quote, double fill)
|
||||
{
|
||||
if (quote <= 0 || fill <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (isBuy ? fill - quote : quote - fill) / s.Instrument.Pip;
|
||||
}
|
||||
|
||||
private static string Describe(OrderOutcome o) =>
|
||||
o.Error.Length > 0 ? $"{o.Status} — {o.Error}" : o.Status;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using Encelado.Core.Baskets.Data;
|
||||
using Encelado.Core.Statistics;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>
|
||||
/// The indicators of the strategy, every one written here from its definition and
|
||||
/// covered by a test against a reference computation. They take spans so the callers
|
||||
/// (live engine and backtest) hand over exactly the window the specification names.
|
||||
/// </summary>
|
||||
public static class BasketMath
|
||||
{
|
||||
public static double Mean(ReadOnlySpan<double> values)
|
||||
{
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double sum = 0;
|
||||
foreach (double v in values)
|
||||
{
|
||||
sum += v;
|
||||
}
|
||||
|
||||
return sum / values.Length;
|
||||
}
|
||||
|
||||
/// <summary>Sample standard deviation (n − 1).</summary>
|
||||
public static double StdDev(ReadOnlySpan<double> values)
|
||||
{
|
||||
if (values.Length < 2)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double mean = Mean(values);
|
||||
double sum = 0;
|
||||
foreach (double v in values)
|
||||
{
|
||||
double d = v - mean;
|
||||
sum += d * d;
|
||||
}
|
||||
|
||||
return Math.Sqrt(sum / (values.Length - 1));
|
||||
}
|
||||
|
||||
/// <summary>Pearson correlation of two aligned samples. NaN when either has no variance.</summary>
|
||||
public static double Correlation(ReadOnlySpan<double> x, ReadOnlySpan<double> y)
|
||||
{
|
||||
int n = Math.Min(x.Length, y.Length);
|
||||
if (n < 3)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double mx = 0, my = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
mx += x[i];
|
||||
my += y[i];
|
||||
}
|
||||
|
||||
mx /= n;
|
||||
my /= n;
|
||||
|
||||
double sxy = 0, sxx = 0, syy = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double dx = x[i] - mx;
|
||||
double dy = y[i] - my;
|
||||
sxy += dx * dy;
|
||||
sxx += dx * dx;
|
||||
syy += dy * dy;
|
||||
}
|
||||
|
||||
return sxx > 0 && syy > 0 ? sxy / Math.Sqrt(sxx * syy) : double.NaN;
|
||||
}
|
||||
|
||||
/// <summary><c>(last − SMA_W) / StdDev_W</c> over the whole span, which is the window.</summary>
|
||||
public static double ZScore(ReadOnlySpan<double> window)
|
||||
{
|
||||
if (window.Length < 3)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double sd = StdDev(window);
|
||||
return sd > 0 ? (window[^1] - Mean(window)) / sd : double.NaN;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Half-life of mean reversion by OLS on <c>ΔX_t = α + λ·X_{t−1}</c>: <c>−ln 2 / λ</c>.
|
||||
/// NaN when λ ≥ 0 (no reversion) or when the half-life exceeds the sample.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Half-life of mean reversion of a series, in bars: an OLS of Δx on x(t−1) gives
|
||||
/// λ, and the half-life is −ln 2 / λ. NaN when the series is too short (fewer than
|
||||
/// 20 points), when λ ≥ 0 (a deviation grows rather than decays), or when the
|
||||
/// half-life would exceed the sample — none of those is a measurement.
|
||||
/// </summary>
|
||||
public static double HalfLife(ReadOnlySpan<double> series)
|
||||
{
|
||||
int n = series.Length;
|
||||
if (n < 20)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double[] lagged = new double[n - 1];
|
||||
double[] delta = new double[n - 1];
|
||||
for (int i = 1; i < n; i++)
|
||||
{
|
||||
lagged[i - 1] = series[i - 1];
|
||||
delta[i - 1] = series[i] - series[i - 1];
|
||||
}
|
||||
|
||||
if (!Ols.FitLine(lagged, delta, out _, out double lambda) || lambda >= -1e-12)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double halfLife = -Math.Log(2) / lambda;
|
||||
return halfLife >= n ? double.NaN : halfLife;
|
||||
}
|
||||
|
||||
/// <summary>Wilder ATR with the full smoothing over a longer history (used when at least 3×period bars exist).</summary>
|
||||
public static double AtrSmoothed(ReadOnlySpan<BidAskBar> bars, int period)
|
||||
{
|
||||
if (bars.Length < period + 1)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
int first = Math.Max(1, bars.Length - (4 * period));
|
||||
double atr = 0;
|
||||
int seeded = 0;
|
||||
for (int i = first; i < bars.Length; i++)
|
||||
{
|
||||
double tr = TrueRange(bars[i], bars[i - 1]);
|
||||
if (seeded < period)
|
||||
{
|
||||
atr += tr;
|
||||
seeded++;
|
||||
if (seeded == period)
|
||||
{
|
||||
atr /= period;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
atr = ((atr * (period - 1)) + tr) / period;
|
||||
}
|
||||
}
|
||||
|
||||
return seeded < period ? double.NaN : atr;
|
||||
}
|
||||
|
||||
private static double TrueRange(in BidAskBar bar, in BidAskBar previous)
|
||||
{
|
||||
double hl = bar.MidHigh - bar.MidLow;
|
||||
double hc = Math.Abs(bar.MidHigh - previous.MidClose);
|
||||
double lc = Math.Abs(bar.MidLow - previous.MidClose);
|
||||
return Math.Max(hl, Math.Max(hc, lc));
|
||||
}
|
||||
|
||||
/// <summary>Exponentially weighted volatility of returns, span-based (α = 2/(span+1)), over the whole span.</summary>
|
||||
public static double EwmaVolatility(ReadOnlySpan<double> returns, int span)
|
||||
{
|
||||
if (returns.Length < 2)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double alpha = 2.0 / (span + 1);
|
||||
double variance = 0;
|
||||
int seed = Math.Min(returns.Length, Math.Max(2, span / 4));
|
||||
for (int i = 0; i < seed; i++)
|
||||
{
|
||||
variance += returns[i] * returns[i];
|
||||
}
|
||||
|
||||
variance /= seed;
|
||||
for (int i = seed; i < returns.Length; i++)
|
||||
{
|
||||
variance = ((1 - alpha) * variance) + (alpha * returns[i] * returns[i]);
|
||||
}
|
||||
|
||||
return Math.Sqrt(variance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ADX-like trend strength (0-100) over the mid prices: Wilder's +DI/−DI and the
|
||||
/// smoothed DX. Needs about 3×period bars to settle; NaN before that.
|
||||
/// </summary>
|
||||
public static double TrendStrength(ReadOnlySpan<BidAskBar> bars, int period)
|
||||
{
|
||||
if (bars.Length < (2 * period) + 1)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
int first = Math.Max(1, bars.Length - (6 * period));
|
||||
double tr = 0, plus = 0, minus = 0, adx = double.NaN;
|
||||
int count = 0;
|
||||
int dxCount = 0;
|
||||
double dxSum = 0;
|
||||
|
||||
for (int i = first; i < bars.Length; i++)
|
||||
{
|
||||
double upMove = bars[i].MidHigh - bars[i - 1].MidHigh;
|
||||
double downMove = bars[i - 1].MidLow - bars[i].MidLow;
|
||||
double plusDm = upMove > downMove && upMove > 0 ? upMove : 0;
|
||||
double minusDm = downMove > upMove && downMove > 0 ? downMove : 0;
|
||||
double range = TrueRange(bars[i], bars[i - 1]);
|
||||
|
||||
if (count < period)
|
||||
{
|
||||
tr += range;
|
||||
plus += plusDm;
|
||||
minus += minusDm;
|
||||
count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
tr = tr - (tr / period) + range;
|
||||
plus = plus - (plus / period) + plusDm;
|
||||
minus = minus - (minus / period) + minusDm;
|
||||
if (tr <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double plusDi = 100 * plus / tr;
|
||||
double minusDi = 100 * minus / tr;
|
||||
double sum = plusDi + minusDi;
|
||||
double dx = sum > 0 ? 100 * Math.Abs(plusDi - minusDi) / sum : 0;
|
||||
|
||||
if (dxCount < period)
|
||||
{
|
||||
dxSum += dx;
|
||||
dxCount++;
|
||||
if (dxCount == period)
|
||||
{
|
||||
adx = dxSum / period;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
adx = ((adx * (period - 1)) + dx) / period;
|
||||
}
|
||||
}
|
||||
|
||||
return adx;
|
||||
}
|
||||
|
||||
public static double Median(List<double> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double[] copy = [.. values];
|
||||
Array.Sort(copy);
|
||||
int mid = copy.Length / 2;
|
||||
return copy.Length % 2 == 0 ? (copy[mid - 1] + copy[mid]) / 2 : copy[mid];
|
||||
}
|
||||
|
||||
/// <summary>The hour of day on the unit circle, so 23:45 and 00:15 are neighbours.</summary>
|
||||
public static (double Sin, double Cos) HourFeatures(DateTime utc)
|
||||
{
|
||||
double angle = 2 * Math.PI * (utc.Hour + (utc.Minute / 60.0)) / 24.0;
|
||||
return (Math.Sin(angle), Math.Cos(angle));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>The lifecycle of one basket. Transitions are checked by <see cref="BasketLifecycle"/>.</summary>
|
||||
public enum BasketState
|
||||
{
|
||||
Idle = 0,
|
||||
Entering,
|
||||
Open,
|
||||
Adding,
|
||||
Exiting,
|
||||
Closed,
|
||||
Error,
|
||||
|
||||
/// <summary>Leg A was sent and the venue has not said what became of it; nothing else happens on this basket until it does.</summary>
|
||||
PendingA,
|
||||
|
||||
/// <summary>Leg A is filled, leg B was sent and the venue has not said what became of it.</summary>
|
||||
PendingB,
|
||||
}
|
||||
|
||||
public static class BasketLifecycle
|
||||
{
|
||||
public static bool CanTransition(BasketState from, BasketState to) => (from, to) switch
|
||||
{
|
||||
(BasketState.Idle, BasketState.Entering) => true,
|
||||
(BasketState.Entering, BasketState.Open) => true,
|
||||
(BasketState.Entering, BasketState.Idle) => true, // leg-risk unwind, both legs flat again
|
||||
(BasketState.Entering, BasketState.Error) => true,
|
||||
(BasketState.Entering, BasketState.PendingA) => true, // leg A sent, outcome unknown past the leg timeout
|
||||
(BasketState.Entering, BasketState.PendingB) => true, // leg A filled, leg B outcome unknown
|
||||
(BasketState.PendingA, BasketState.Entering) => true, // leg A filled: sending leg B
|
||||
(BasketState.PendingA, BasketState.Idle) => true, // leg A rejected, or filled and unwound because the signal decayed
|
||||
(BasketState.PendingA, BasketState.Error) => true,
|
||||
(BasketState.PendingB, BasketState.Open) => true, // leg B filled
|
||||
(BasketState.PendingB, BasketState.Idle) => true, // leg B rejected, leg A unwound
|
||||
(BasketState.PendingB, BasketState.Error) => true,
|
||||
(BasketState.Open, BasketState.Adding) => true,
|
||||
(BasketState.Adding, BasketState.Open) => true,
|
||||
(BasketState.Adding, BasketState.Error) => true,
|
||||
(BasketState.Open, BasketState.Exiting) => true,
|
||||
(BasketState.Exiting, BasketState.Closed) => true,
|
||||
(BasketState.Exiting, BasketState.Error) => true,
|
||||
(BasketState.Closed, BasketState.Idle) => true,
|
||||
(BasketState.Error, BasketState.Idle) => true, // after a manual/automatic reconciliation
|
||||
(BasketState.Error, BasketState.Exiting) => true,
|
||||
_ => from == to,
|
||||
};
|
||||
|
||||
/// <summary>A basket waiting for the venue: no evaluation, no new order, until the order register resolves it.</summary>
|
||||
public static bool IsPending(this BasketState state) => state is BasketState.PendingA or BasketState.PendingB;
|
||||
}
|
||||
|
||||
/// <summary>One leg of an open basket, as filled.</summary>
|
||||
public sealed class BasketLeg
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
public required long InstrumentId { get; init; }
|
||||
|
||||
public required bool IsBuy { get; init; }
|
||||
|
||||
public double Units { get; set; }
|
||||
|
||||
/// <summary>Volume-weighted entry across the initial fill and the adds.</summary>
|
||||
public double EntryPrice { get; set; }
|
||||
|
||||
public long PositionId { get; set; }
|
||||
|
||||
public string ClientRef { get; set; } = string.Empty;
|
||||
|
||||
public DateTime OpenedUtc { get; set; }
|
||||
|
||||
public double EntryFeesUsd { get; set; }
|
||||
|
||||
public double StopLossRate { get; set; }
|
||||
|
||||
/// <summary>Extra positions opened by adds on the same leg (eToro opens a new position per order).</summary>
|
||||
public List<(long PositionId, double Units, double Price, string ClientRef)> Adds { get; } = [];
|
||||
|
||||
public IEnumerable<long> AllPositionIds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PositionId != 0)
|
||||
{
|
||||
yield return PositionId;
|
||||
}
|
||||
|
||||
foreach ((long id, _, _, _) in Adds)
|
||||
{
|
||||
yield return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double TotalUnits => Units + Adds.Sum(static a => a.Units);
|
||||
|
||||
/// <summary>Signed pips from entry at the exit price of this leg (bid for a long, ask for a short).</summary>
|
||||
public double Pips(double exitPrice, double pip) => (IsBuy ? exitPrice - EntryPrice : EntryPrice - exitPrice) / pip;
|
||||
|
||||
/// <summary>Writes the leg as a named JSON object (the shape of <c>baskets_state.json</c>).</summary>
|
||||
public static void Write(System.Text.Json.Utf8JsonWriter w, string name, BasketLeg leg)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(w);
|
||||
ArgumentNullException.ThrowIfNull(leg);
|
||||
w.WriteStartObject(name);
|
||||
w.WriteString("symbol", leg.Symbol);
|
||||
w.WriteNumber("instrumentId", leg.InstrumentId);
|
||||
w.WriteBoolean("isBuy", leg.IsBuy);
|
||||
w.WriteNumber("units", leg.Units);
|
||||
w.WriteNumber("entryPrice", leg.EntryPrice);
|
||||
w.WriteNumber("positionId", leg.PositionId);
|
||||
w.WriteString("clientRef", leg.ClientRef);
|
||||
w.WriteString("openedUtc", leg.OpenedUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteNumber("entryFeesUsd", leg.EntryFeesUsd);
|
||||
w.WriteNumber("stopLossRate", leg.StopLossRate);
|
||||
w.WriteStartArray("adds");
|
||||
foreach ((long id, double units, double price, string clientRef) in leg.Adds)
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteNumber("positionId", id);
|
||||
w.WriteNumber("units", units);
|
||||
w.WriteNumber("price", price);
|
||||
w.WriteString("clientRef", clientRef);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
public static BasketLeg Read(System.Text.Json.JsonElement e)
|
||||
{
|
||||
BasketLeg leg = new()
|
||||
{
|
||||
Symbol = e.GetProperty("symbol").GetString() ?? string.Empty,
|
||||
InstrumentId = e.GetProperty("instrumentId").GetInt64(),
|
||||
IsBuy = e.GetProperty("isBuy").GetBoolean(),
|
||||
Units = e.GetProperty("units").GetDouble(),
|
||||
EntryPrice = e.GetProperty("entryPrice").GetDouble(),
|
||||
PositionId = e.GetProperty("positionId").GetInt64(),
|
||||
ClientRef = e.GetProperty("clientRef").GetString() ?? string.Empty,
|
||||
OpenedUtc = DateTime.Parse(e.GetProperty("openedUtc").GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
|
||||
EntryFeesUsd = e.GetProperty("entryFeesUsd").GetDouble(),
|
||||
StopLossRate = e.GetProperty("stopLossRate").GetDouble(),
|
||||
};
|
||||
if (e.TryGetProperty("adds", out System.Text.Json.JsonElement adds))
|
||||
{
|
||||
foreach (System.Text.Json.JsonElement a in adds.EnumerateArray())
|
||||
{
|
||||
leg.Adds.Add((a.GetProperty("positionId").GetInt64(), a.GetProperty("units").GetDouble(), a.GetProperty("price").GetDouble(), a.GetProperty("clientRef").GetString() ?? string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
return leg;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An open (or opening/closing) basket: both legs plus what the decision knew at entry.</summary>
|
||||
public sealed class BasketPosition
|
||||
{
|
||||
public required string BasketId { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required bool BuyCross { get; init; }
|
||||
|
||||
public required BasketLeg A { get; init; }
|
||||
|
||||
public required BasketLeg B { get; init; }
|
||||
|
||||
public required DateTime OpenedUtc { get; init; }
|
||||
|
||||
public required double EntryZ { get; init; }
|
||||
|
||||
public double LastAddZ { get; set; }
|
||||
|
||||
public int Adds { get; set; }
|
||||
|
||||
public int BarsHeld { get; set; }
|
||||
|
||||
/// <summary>Cost estimate written at entry, in pip-equivalents of leg A.</summary>
|
||||
public double EntryCostPips { get; init; }
|
||||
|
||||
public double TpPips { get; init; }
|
||||
|
||||
public double MaxLossUsd { get; init; }
|
||||
|
||||
public double EquityAtEntry { get; init; }
|
||||
|
||||
public int BarsWithBrokenCorrelation { get; set; }
|
||||
|
||||
/// <summary>Consecutive bar closes with a spread beyond the anomaly multiple: the forced exit waits for persistence.</summary>
|
||||
public int BarsWithSpreadAnomaly { get; set; }
|
||||
|
||||
/// <summary>Overnight and other fees accrued so far, in USD (positive = cost).</summary>
|
||||
public double AccruedFeesUsd { get; set; }
|
||||
|
||||
public string EntryMotivazione { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Sum of the two legs' pips at the given exit prices — the "Pips" of the Titany screen.</summary>
|
||||
public double PipsTotal(double exitA, double exitB, double pipA, double pipB) => A.Pips(exitA, pipA) + B.Pips(exitB, pipB);
|
||||
|
||||
/// <summary>
|
||||
/// Net P&L in USD at the given exit prices: both legs converted to the account
|
||||
/// currency, minus entry fees and accrued overnight.
|
||||
/// </summary>
|
||||
public double NetPnlUsd(double exitA, double exitB, Func<string, double?> mid)
|
||||
{
|
||||
double pa = PipMath.LegPnlUsd(A.Symbol, A.IsBuy, A.TotalUnits, A.EntryPrice, exitA, mid);
|
||||
double pb = PipMath.LegPnlUsd(B.Symbol, B.IsBuy, B.TotalUnits, B.EntryPrice, exitB, mid);
|
||||
if (double.IsNaN(pa) || double.IsNaN(pb))
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
return pa + pb - A.EntryFeesUsd - B.EntryFeesUsd - AccruedFeesUsd;
|
||||
}
|
||||
|
||||
public string Describe() => string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Name} {(BuyCross ? "compro" : "vendo")} il cross: {(A.IsBuy ? "long" : "short")} {A.TotalUnits:0.##} {A.Symbol} @ {A.EntryPrice}, {(B.IsBuy ? "long" : "short")} {B.TotalUnits:0.##} {B.Symbol} @ {B.EntryPrice}, z entrata {EntryZ:+0.00;-0.00}, {Adds} aggiunte, {BarsHeld} barre");
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
public enum SignalMode { ZScoreSynthetic = 0, PipDivergence }
|
||||
|
||||
public enum ExitMode { First = 0, FixedPips, ZReturn }
|
||||
|
||||
public enum AveragingMode { Off = 0, AddOnce, Grid }
|
||||
|
||||
public enum TpMode { Pips = 0, AtrMultiple }
|
||||
|
||||
/// <summary>What to do when two baskets carry the same synthetic cross (baskets 4 and 5 are both EURCAD).</summary>
|
||||
public enum SameCrossPolicy { Exclusive = 0, Half }
|
||||
|
||||
public enum PresetName { Conservative = 0, Moderate, Aggressive }
|
||||
|
||||
/// <summary>The six numbers a style preset fixes (§5.9 of the specification).</summary>
|
||||
public sealed record BasketPreset(PresetName Name, double ZIn, double RiskPerBasketPct, int MaxBaskets, double TpPips, int MaxAdds, double ZStop)
|
||||
{
|
||||
public string Label => Name.ToString().ToUpperInvariant();
|
||||
}
|
||||
|
||||
public static class BasketPresets
|
||||
{
|
||||
public static readonly BasketPreset Conservative = new(PresetName.Conservative, 2.5, 0.25, 2, 8, 0, 3.0);
|
||||
|
||||
public static readonly BasketPreset Moderate = new(PresetName.Moderate, 2.0, 0.50, 3, 10, 1, 3.5);
|
||||
|
||||
public static readonly BasketPreset Aggressive = new(PresetName.Aggressive, 1.5, 1.00, 5, 12, 2, 4.0);
|
||||
|
||||
public static BasketPreset Get(PresetName name) => name switch
|
||||
{
|
||||
PresetName.Conservative => Conservative,
|
||||
PresetName.Aggressive => Aggressive,
|
||||
_ => Moderate,
|
||||
};
|
||||
|
||||
public static bool TryParse(string? text, out PresetName name)
|
||||
{
|
||||
switch (text?.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "conservative" or "conservativo" or "prudente": name = PresetName.Conservative; return true;
|
||||
case "moderate" or "moderato": name = PresetName.Moderate; return true;
|
||||
case "aggressive" or "aggressivo": name = PresetName.Aggressive; return true;
|
||||
default: name = PresetName.Moderate; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>The recovery after an inactivity (§6 of the 5.0 plan, <c>strategy.json</c> → <c>recovery</c>).</summary>
|
||||
public sealed class RecoveryOptions
|
||||
{
|
||||
/// <summary>More than this without a heartbeat or a tick: the recovery procedure runs before any entry.</summary>
|
||||
public int ThresholdMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>Entries stay blocked this long after a recovery or a reset, while the quotes settle.</summary>
|
||||
public int WarmupMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>How often <c>data/state/heartbeat.json</c> is written.</summary>
|
||||
public int HeartbeatSeconds { get; set; } = 30;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ThresholdMinutes is < 1 or > 1440) { throw new InvalidOperationException("strategy.json: 'recovery.thresholdMinutes' deve essere fra 1 e 1440."); }
|
||||
if (WarmupMinutes is < 0 or > 1440) { throw new InvalidOperationException("strategy.json: 'recovery.warmupMinutes' deve essere fra 0 e 1440."); }
|
||||
if (HeartbeatSeconds is < 5 or > 600) { throw new InvalidOperationException("strategy.json: 'recovery.heartbeatSeconds' deve essere fra 5 e 600."); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What of the learning stack runs at runtime (ADR-0006). Off by default: the shadow
|
||||
/// logistic model keeps scoring and learning (its probability is a ledger column), the
|
||||
/// weekly cycle, the MLP challenger and the bandit as an actuator do not run until the
|
||||
/// operator turns them on after the reactivation criterion of <c>docs/ML_AND_LEARNING.md</c>.
|
||||
/// </summary>
|
||||
public sealed class LearningOptions
|
||||
{
|
||||
/// <summary>Master switch: false = shadow only, no weekly cycle, no challenger, the gate can never activate.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Whether the weekly cycle runs inside the bot (Sunday after 10 UTC) when learning is enabled.</summary>
|
||||
public bool WeeklyCycle { get; set; }
|
||||
|
||||
/// <summary>Whether the MLP challenger is trained by the cycle when learning is enabled.</summary>
|
||||
public bool Challenger { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One basket: two pairs. The synthetic cross and the leg signs are derived, never configured.</summary>
|
||||
public sealed class BasketDefinition
|
||||
{
|
||||
public string A { get; set; } = string.Empty;
|
||||
|
||||
public string B { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string Note { get; set; } = string.Empty;
|
||||
|
||||
public string Name => $"{A}/{B}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every parameter of the strategy, from <c>strategy.json</c>. Values not in the file take
|
||||
/// the defaults written in the specification; the active preset supplies six of them and
|
||||
/// can be swapped at runtime without touching open baskets.
|
||||
/// </summary>
|
||||
public sealed class BasketStrategyConfig
|
||||
{
|
||||
public PresetName Preset { get; set; } = PresetName.Moderate;
|
||||
|
||||
public SignalMode SignalMode { get; set; } = SignalMode.ZScoreSynthetic;
|
||||
|
||||
public ExitMode ExitMode { get; set; } = ExitMode.First;
|
||||
|
||||
public AveragingMode AveragingMode { get; set; } = AveragingMode.Off;
|
||||
|
||||
public TpMode TpMode { get; set; } = TpMode.Pips;
|
||||
|
||||
public SameCrossPolicy SameCrossPolicy { get; set; } = SameCrossPolicy.Exclusive;
|
||||
|
||||
/// <summary>Trade the direct cross (EURCHF, EURCAD…) instead of two legs when the venue quotes it. Off: the basket stays two legs like Titany.</summary>
|
||||
public bool PreferDirectCross { get; set; }
|
||||
|
||||
/// <summary>Research only (falsification test 5): trade against the signal. Never set in strategy.json.</summary>
|
||||
public bool InvertSignal { get; set; }
|
||||
|
||||
// ---- indicators ----
|
||||
public int Window { get; set; } = 100;
|
||||
|
||||
public int WindowShort { get; set; } = 20;
|
||||
|
||||
public double RhoMin { get; set; } = 0.60;
|
||||
|
||||
public double RhoShortMin { get; set; } = 0.40;
|
||||
|
||||
public double HalfLifeMinBars { get; set; } = 4;
|
||||
|
||||
public double HalfLifeMaxBars { get; set; } = 96;
|
||||
|
||||
public int HalfLifeRecalcHours { get; set; } = 4;
|
||||
|
||||
public int AtrPeriod { get; set; } = 14;
|
||||
|
||||
public int EwmaSpan { get; set; } = 100;
|
||||
|
||||
public int TrendPeriod { get; set; } = 14;
|
||||
|
||||
// ---- signal ----
|
||||
public double ZOut { get; set; } = 0.25;
|
||||
|
||||
/// <summary>PipDivergence: divergence in pips from the anchor that opens a basket.</summary>
|
||||
public double DIn { get; set; } = 15;
|
||||
|
||||
/// <summary>PipDivergence: bars between two anchor resets.</summary>
|
||||
public int AnchorBars { get; set; } = 32;
|
||||
|
||||
public double GridStepZ { get; set; } = 0.75;
|
||||
|
||||
/// <summary>Lot multiplier of each add. 1.0 always outside the backtest falsification test.</summary>
|
||||
public double LotMultiplier { get; set; } = 1.0;
|
||||
|
||||
// ---- exits ----
|
||||
public double MaxLossPerBasketPct { get; set; } = 1.5;
|
||||
|
||||
public double RhoBreak { get; set; } = 0.20;
|
||||
|
||||
public int RhoBreakBars { get; set; } = 8;
|
||||
|
||||
public int MaxHoldingBars { get; set; } = 96;
|
||||
|
||||
public double TpAtrMultiple { get; set; } = 1.0;
|
||||
|
||||
// ---- cost gate ----
|
||||
public double CostMultiple { get; set; } = 3;
|
||||
|
||||
public double SpreadMedianMultiple { get; set; } = 2;
|
||||
|
||||
public double SpreadAnomalyMultiple { get; set; } = 3;
|
||||
|
||||
public double SlippagePipsPerLeg { get; set; } = 0.3;
|
||||
|
||||
public double OvernightPipsPerDay { get; set; } = 0.3;
|
||||
|
||||
// ---- calendar and time ----
|
||||
public int BlackoutBeforeMin { get; set; } = 45;
|
||||
|
||||
public int BlackoutAfterMin { get; set; } = 30;
|
||||
|
||||
public int FridayCutoffUtcHour { get; set; } = 20;
|
||||
|
||||
public int OpenDelayMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>Allowed entry hours in UTC, inclusive start, exclusive end. Empty = always.</summary>
|
||||
public List<(int From, int To)> Sessions { get; set; } = [];
|
||||
|
||||
// ---- sizing and risk ----
|
||||
public double MaxEffectiveLeverage { get; set; } = 10;
|
||||
|
||||
public int OrderLeverage { get; set; } = 10;
|
||||
|
||||
public double VolScaleMin { get; set; } = 0.8;
|
||||
|
||||
public double VolScaleMax { get; set; } = 1.5;
|
||||
|
||||
public int VolAverageDays { get; set; } = 30;
|
||||
|
||||
public double MlMinProbability { get; set; } = 0.55;
|
||||
|
||||
public double EquityStopPct { get; set; } = 9;
|
||||
|
||||
public double DailyLossPct { get; set; } = 3;
|
||||
|
||||
public int LegTimeoutSec { 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();
|
||||
|
||||
/// <summary>The recovery after an inactivity (§6 of the 5.0 plan).</summary>
|
||||
public RecoveryOptions Recovery { get; set; } = new();
|
||||
|
||||
/// <summary>What of the learning stack runs at runtime (ADR-0006).</summary>
|
||||
public LearningOptions Learning { get; set; } = new();
|
||||
|
||||
// ---- overrides of the preset (NaN / 0 = take the preset's value) ----
|
||||
public double ZInOverride { get; set; } = double.NaN;
|
||||
|
||||
public double RiskPerBasketPctOverride { get; set; } = double.NaN;
|
||||
|
||||
public int MaxBasketsOverride { get; set; }
|
||||
|
||||
public double TpPipsOverride { get; set; } = double.NaN;
|
||||
|
||||
public int MaxAddsOverride { get; set; } = -1;
|
||||
|
||||
public double ZStopOverride { get; set; } = double.NaN;
|
||||
|
||||
public List<BasketDefinition> Baskets { get; set; } = DefaultBaskets();
|
||||
|
||||
/// <summary>The preset in force, with overrides applied.</summary>
|
||||
public BasketPreset Effective(PresetName? preset = null)
|
||||
{
|
||||
BasketPreset p = BasketPresets.Get(preset ?? Preset);
|
||||
return p with
|
||||
{
|
||||
ZIn = double.IsNaN(ZInOverride) ? p.ZIn : ZInOverride,
|
||||
RiskPerBasketPct = double.IsNaN(RiskPerBasketPctOverride) ? p.RiskPerBasketPct : RiskPerBasketPctOverride,
|
||||
MaxBaskets = MaxBasketsOverride > 0 ? MaxBasketsOverride : p.MaxBaskets,
|
||||
TpPips = double.IsNaN(TpPipsOverride) ? p.TpPips : TpPipsOverride,
|
||||
MaxAdds = MaxAddsOverride >= 0 ? MaxAddsOverride : p.MaxAdds,
|
||||
ZStop = double.IsNaN(ZStopOverride) ? p.ZStop : ZStopOverride,
|
||||
};
|
||||
}
|
||||
|
||||
public static List<BasketDefinition> DefaultBaskets() =>
|
||||
[
|
||||
new() { A = "EURUSD", B = "USDCHF", Note = "cross sintetico EURCHF" },
|
||||
new() { A = "AUDUSD", B = "USDCAD", Note = "cross sintetico AUDCAD" },
|
||||
new() { A = "NZDUSD", B = "EURNZD", Note = "cross sintetico EURUSD: replica EURUSD pagando due spread" },
|
||||
new() { A = "USDCAD", B = "EURUSD", Note = "cross sintetico EURCAD (stessa esposizione del basket 5)" },
|
||||
new() { A = "EURAUD", B = "AUDCAD", Note = "cross sintetico EURCAD (stessa esposizione del basket 4)" },
|
||||
];
|
||||
|
||||
/// <summary>Every symbol the baskets need, in first-seen order, plus the direct crosses when asked.</summary>
|
||||
public List<string> Symbols(bool includeDirectCrosses)
|
||||
{
|
||||
List<string> list = [];
|
||||
foreach (BasketDefinition b in Baskets)
|
||||
{
|
||||
if (!list.Contains(b.A, StringComparer.OrdinalIgnoreCase)) { list.Add(b.A.ToUpperInvariant()); }
|
||||
if (!list.Contains(b.B, StringComparer.OrdinalIgnoreCase)) { list.Add(b.B.ToUpperInvariant()); }
|
||||
}
|
||||
|
||||
if (includeDirectCrosses)
|
||||
{
|
||||
foreach (BasketDefinition b in Baskets)
|
||||
{
|
||||
if (SyntheticCross.TryDerive(b.A, b.B, out SyntheticCross? cross) && cross is not null && !list.Contains(cross.Symbol, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
list.Add(cross.Symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Window is < 20 or > 2000) { throw Bad("window", "fra 20 e 2000 barre"); }
|
||||
if (WindowShort is < 5 || WindowShort >= Window) { throw Bad("windowShort", "almeno 5 e minore di window"); }
|
||||
if (RhoMin is < 0 or > 1 || RhoShortMin is < 0 or > 1) { throw Bad("rhoMin/rhoShortMin", "fra 0 e 1"); }
|
||||
if (HalfLifeMinBars < 1 || HalfLifeMaxBars <= HalfLifeMinBars) { throw Bad("halfLife", "min ≥ 1 e max > min"); }
|
||||
if (ZOut < 0) { throw Bad("zOut", "non negativo"); }
|
||||
if (MaxLossPerBasketPct is <= 0 or > 20) { throw Bad("maxLossPerBasketPct", "fra 0 e 20"); }
|
||||
if (MaxHoldingBars < 1) { throw Bad("maxHoldingBars", "almeno 1"); }
|
||||
if (CostMultiple < 1) { throw Bad("costMultiple", "almeno 1"); }
|
||||
if (MaxEffectiveLeverage is <= 0 or > 30) { throw Bad("maxEffectiveLeverage", "fra 0 e 30"); }
|
||||
if (OrderLeverage is < 1 or > 30) { throw Bad("orderLeverage", "fra 1 e 30"); }
|
||||
if (EquityStopPct is <= 0 or > 50) { throw Bad("equityStopPct", "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 (Baskets.Count == 0) { throw Bad("baskets", "almeno un basket"); }
|
||||
Risk.Validate();
|
||||
Recovery.Validate();
|
||||
|
||||
foreach (BasketDefinition b in Baskets)
|
||||
{
|
||||
b.A = b.A.Trim().ToUpperInvariant();
|
||||
b.B = b.B.Trim().ToUpperInvariant();
|
||||
if (b.A.Length != 6 || b.B.Length != 6)
|
||||
{
|
||||
throw Bad("baskets", $"'{b.A}/{b.B}' non è una coppia di simboli a sei lettere");
|
||||
}
|
||||
|
||||
if (!SyntheticCross.TryDerive(b.A, b.B, out _))
|
||||
{
|
||||
throw Bad("baskets", $"'{b.A}/{b.B}' non hanno una valuta in comune: nessun cross sintetico");
|
||||
}
|
||||
}
|
||||
|
||||
BasketPreset e = Effective();
|
||||
if (e.ZIn <= e.ZOutOrZero(ZOut)) { throw Bad("zIn/zOut", "zIn deve superare zOut"); }
|
||||
if (e.ZStop <= e.ZIn) { throw Bad("zStop", "deve superare zIn"); }
|
||||
if (e.RiskPerBasketPct is <= 0 or > 5) { throw Bad("riskPerBasketPct", "fra 0 e 5"); }
|
||||
if (e.MaxBaskets is < 1 or > 10) { throw Bad("maxBaskets", "fra 1 e 10"); }
|
||||
if (e.TpPips <= 0) { throw Bad("tpPips", "positivo"); }
|
||||
}
|
||||
|
||||
private static InvalidOperationException Bad(string key, string rule) =>
|
||||
new($"strategy.json: '{key}' deve essere {rule}.");
|
||||
|
||||
/// <summary>SHA-256 of the canonical text of the configuration, for the ledger and the pre-registration.</summary>
|
||||
public string Hash()
|
||||
{
|
||||
byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(Canonical()));
|
||||
return Convert.ToHexString(bytes)[..16].ToLowerInvariant();
|
||||
}
|
||||
|
||||
public string Canonical()
|
||||
{
|
||||
BasketPreset e = Effective();
|
||||
StringBuilder sb = new();
|
||||
sb.Append(CultureInfo.InvariantCulture, $"preset={Preset};zIn={e.ZIn};risk={e.RiskPerBasketPct};maxBaskets={e.MaxBaskets};tp={e.TpPips};maxAdds={e.MaxAdds};zStop={e.ZStop};");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"signal={SignalMode};exit={ExitMode};avg={AveragingMode};tpMode={TpMode};same={SameCrossPolicy};direct={PreferDirectCross};");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"W={Window};Ws={WindowShort};rho={RhoMin};rhoS={RhoShortMin};hl={HalfLifeMinBars}-{HalfLifeMaxBars};atr={AtrPeriod};ewma={EwmaSpan};");
|
||||
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};");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"recovery={Recovery.ThresholdMinutes}/{Recovery.WarmupMinutes};learning={(Learning.Enabled ? 1 : 0)}/{(Learning.WeeklyCycle ? 1 : 0)}/{(Learning.Challenger ? 1 : 0)};");
|
||||
foreach (BasketDefinition b in Baskets)
|
||||
{
|
||||
sb.Append(CultureInfo.InvariantCulture, $"{b.A}/{b.B}={(b.Enabled ? 1 : 0)};");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// JSON
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly JsonDocumentOptions ParseOptions = new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true };
|
||||
|
||||
public static BasketStrategyConfig Load(string path, out List<string> warnings)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
using FileStream stream = File.OpenRead(path);
|
||||
using JsonDocument doc = JsonDocument.Parse(stream, ParseOptions);
|
||||
return Parse(doc.RootElement, out warnings);
|
||||
}
|
||||
|
||||
public static BasketStrategyConfig ParseText(string json, out List<string> warnings)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json, ParseOptions);
|
||||
return Parse(doc.RootElement, out warnings);
|
||||
}
|
||||
|
||||
private static BasketStrategyConfig Parse(JsonElement root, out List<string> warnings)
|
||||
{
|
||||
warnings = [];
|
||||
BasketStrategyConfig c = new();
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new InvalidOperationException("strategy.json deve contenere un oggetto.");
|
||||
}
|
||||
|
||||
foreach (JsonProperty p in root.EnumerateObject())
|
||||
{
|
||||
if (p.Name.StartsWith('_'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
case "preset": c.Preset = BasketPresets.TryParse(Str(p), out PresetName pn) ? pn : Warn(warnings, p, PresetName.Moderate); break;
|
||||
case "signalmode": c.SignalMode = Enum.TryParse(Str(p), true, out SignalMode sm) ? sm : Warn(warnings, p, SignalMode.ZScoreSynthetic); break;
|
||||
case "exitmode": c.ExitMode = Enum.TryParse(Str(p), true, out ExitMode em) ? em : Warn(warnings, p, ExitMode.First); break;
|
||||
case "averagingmode": c.AveragingMode = Enum.TryParse(Str(p), true, out AveragingMode am) ? am : Warn(warnings, p, AveragingMode.Off); break;
|
||||
case "tpmode": c.TpMode = Enum.TryParse(Str(p), true, out TpMode tm) ? tm : Warn(warnings, p, TpMode.Pips); break;
|
||||
case "samecrosspolicy": c.SameCrossPolicy = Str(p).Contains("half", StringComparison.OrdinalIgnoreCase) ? SameCrossPolicy.Half : SameCrossPolicy.Exclusive; break;
|
||||
case "preferdirectcross": c.PreferDirectCross = Bool(p); break;
|
||||
case "invertsignal": c.InvertSignal = Bool(p); warnings.Add("invertSignal è solo per la ricerca: il bot lo ignora"); break;
|
||||
case "window": c.Window = Int(p); break;
|
||||
case "windowshort": c.WindowShort = Int(p); break;
|
||||
case "rhomin": c.RhoMin = Num(p); break;
|
||||
case "rhoshortmin": c.RhoShortMin = Num(p); break;
|
||||
case "halflifeminbars": c.HalfLifeMinBars = Num(p); break;
|
||||
case "halflifemaxbars": c.HalfLifeMaxBars = Num(p); break;
|
||||
case "halfliferecalchours": c.HalfLifeRecalcHours = Int(p); break;
|
||||
case "atrperiod": c.AtrPeriod = Int(p); break;
|
||||
case "ewmaspan": c.EwmaSpan = Int(p); break;
|
||||
case "trendperiod": c.TrendPeriod = Int(p); break;
|
||||
case "zout": c.ZOut = Num(p); break;
|
||||
case "din": c.DIn = Num(p); break;
|
||||
case "anchorbars": c.AnchorBars = Int(p); break;
|
||||
case "gridstepz": c.GridStepZ = Num(p); break;
|
||||
case "lotmultiplier": c.LotMultiplier = Num(p); break;
|
||||
case "maxlossperbasketpct": c.MaxLossPerBasketPct = Num(p); break;
|
||||
case "rhobreak": c.RhoBreak = Num(p); break;
|
||||
case "rhobreakbars": c.RhoBreakBars = Int(p); break;
|
||||
case "maxholdingbars": c.MaxHoldingBars = Int(p); break;
|
||||
case "tpatrmultiple": c.TpAtrMultiple = Num(p); break;
|
||||
case "costmultiple": c.CostMultiple = Num(p); break;
|
||||
case "spreadmedianmultiple": c.SpreadMedianMultiple = Num(p); break;
|
||||
case "spreadanomalymultiple": c.SpreadAnomalyMultiple = Num(p); break;
|
||||
case "slippagepipsperleg": c.SlippagePipsPerLeg = Num(p); break;
|
||||
case "overnightpipsperday": c.OvernightPipsPerDay = Num(p); break;
|
||||
case "blackoutbeforemin": c.BlackoutBeforeMin = Int(p); break;
|
||||
case "blackoutaftermin": c.BlackoutAfterMin = Int(p); break;
|
||||
case "fridaycutoffutchour": c.FridayCutoffUtcHour = Int(p); break;
|
||||
case "opendelayminutes": c.OpenDelayMinutes = Int(p); break;
|
||||
case "sessions": c.Sessions = ReadSessions(p.Value, warnings); break;
|
||||
case "maxeffectiveleverage": c.MaxEffectiveLeverage = Num(p); break;
|
||||
case "orderleverage": c.OrderLeverage = Int(p); break;
|
||||
case "volscalemin": c.VolScaleMin = Num(p); break;
|
||||
case "volscalemax": c.VolScaleMax = Num(p); break;
|
||||
case "volaveragedays": c.VolAverageDays = Int(p); break;
|
||||
case "mlminprobability": c.MlMinProbability = Num(p); break;
|
||||
case "equitystoppct": c.EquityStopPct = Num(p); break;
|
||||
case "dailylosspct": c.DailyLossPct = Num(p); break;
|
||||
case "legtimeoutsec": c.LegTimeoutSec = Int(p); break;
|
||||
case "clockskewmaxseconds": c.ClockSkewMaxSeconds = Int(p); break;
|
||||
case "zin": c.ZInOverride = Num(p); break;
|
||||
case "riskperbasketpct": c.RiskPerBasketPctOverride = Num(p); break;
|
||||
case "maxbaskets": c.MaxBasketsOverride = Int(p); break;
|
||||
case "tppips": c.TpPipsOverride = Num(p); break;
|
||||
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;
|
||||
case "recovery": c.Recovery = ReadRecovery(p.Value, warnings); break;
|
||||
case "learning": c.Learning = ReadLearning(p.Value, warnings); break;
|
||||
default: warnings.Add($"chiave sconosciuta '{p.Name}' in strategy.json"); break;
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
private static List<BasketDefinition> ReadBaskets(JsonElement e, List<string> warnings)
|
||||
{
|
||||
List<BasketDefinition> list = [];
|
||||
if (e.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add("'baskets' deve essere un array: uso i cinque basket predefiniti");
|
||||
return DefaultBaskets();
|
||||
}
|
||||
|
||||
foreach (JsonElement item in e.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
string[] parts = (item.GetString() ?? string.Empty).Split('/');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
list.Add(new BasketDefinition { A = parts[0], B = parts[1] });
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BasketDefinition b = new();
|
||||
foreach (JsonProperty p in item.EnumerateObject())
|
||||
{
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
case "a": b.A = Str(p); break;
|
||||
case "b": b.B = Str(p); break;
|
||||
case "enabled": b.Enabled = Bool(p); break;
|
||||
case "note": b.Note = Str(p); break;
|
||||
}
|
||||
}
|
||||
|
||||
list.Add(b);
|
||||
}
|
||||
|
||||
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 LearningOptions ReadLearning(JsonElement e, List<string> warnings)
|
||||
{
|
||||
LearningOptions l = new();
|
||||
if (e.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("'learning' deve essere un oggetto: uso i valori di fabbrica (tutto spento)");
|
||||
return l;
|
||||
}
|
||||
|
||||
foreach (JsonProperty p in e.EnumerateObject())
|
||||
{
|
||||
if (p.Name.StartsWith('_'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (p.Name.ToLowerInvariant())
|
||||
{
|
||||
case "enabled": l.Enabled = Bool(p); break;
|
||||
case "weeklycycle": l.WeeklyCycle = Bool(p); break;
|
||||
case "challenger": l.Challenger = Bool(p); break;
|
||||
default: warnings.Add($"chiave sconosciuta 'learning.{p.Name}' in strategy.json"); break;
|
||||
}
|
||||
}
|
||||
|
||||
return l;
|
||||
}
|
||||
|
||||
private static RecoveryOptions ReadRecovery(JsonElement e, List<string> warnings)
|
||||
{
|
||||
RecoveryOptions r = new();
|
||||
if (e.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("'recovery' 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 "thresholdminutes": r.ThresholdMinutes = Int(p); break;
|
||||
case "warmupminutes": r.WarmupMinutes = Int(p); break;
|
||||
case "heartbeatseconds": r.HeartbeatSeconds = Int(p); break;
|
||||
default: warnings.Add($"chiave sconosciuta 'recovery.{p.Name}' in strategy.json"); break;
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
private static List<(int, int)> ReadSessions(JsonElement e, List<string> warnings)
|
||||
{
|
||||
List<(int, int)> list = [];
|
||||
if (e.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
warnings.Add("'sessions' deve essere un array di \"HH-HH\" in UTC");
|
||||
return list;
|
||||
}
|
||||
|
||||
foreach (JsonElement item in e.EnumerateArray())
|
||||
{
|
||||
string[] parts = (item.GetString() ?? string.Empty).Split('-');
|
||||
if (parts.Length == 2 && int.TryParse(parts[0], out int from) && int.TryParse(parts[1], out int to))
|
||||
{
|
||||
list.Add((from, to));
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static T Warn<T>(List<string> warnings, JsonProperty p, T fallback)
|
||||
{
|
||||
warnings.Add($"valore non riconosciuto per '{p.Name}': uso {fallback}");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static string Str(JsonProperty p) => p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() ?? string.Empty : p.Value.GetRawText();
|
||||
|
||||
private static double Num(JsonProperty p) => p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => p.Value.GetDouble(),
|
||||
JsonValueKind.String when double.TryParse(p.Value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double d) => d,
|
||||
_ => throw new InvalidOperationException($"strategy.json: '{p.Name}' deve essere un numero."),
|
||||
};
|
||||
|
||||
private static int Int(JsonProperty p) => (int)Math.Round(Num(p));
|
||||
|
||||
private static bool Bool(JsonProperty p) => p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String => (p.Value.GetString() ?? string.Empty).Trim().ToLowerInvariant() is "true" or "1" or "yes" or "sì" or "si",
|
||||
_ => throw new InvalidOperationException($"strategy.json: '{p.Name}' deve essere vero o falso."),
|
||||
};
|
||||
|
||||
/// <summary>The factory <c>strategy.json</c>, verbatim (a test keeps it equal to the shipped file).</summary>
|
||||
public const string DefaultJson = """
|
||||
{
|
||||
"_comment": "Encelado — strategia Correlation Baskets su eToro. Cinque basket di due coppie forex correlate: si entra quando il cross sintetico diverge (z-score), si esce quando converge o al take-profit di basket in pip; lo stop di basket è obbligatorio. Ogni chiave con '_' davanti è documentazione.",
|
||||
|
||||
"_preset": "Conservative | Moderate | Aggressive. Fissa zIn, riskPerBasketPct, maxBaskets, tpPips, maxAdds, zStop; si cambia a caldo dalla finestra senza toccare i basket aperti. Le chiavi omonime qui sotto, se presenti, sovrascrivono il preset.",
|
||||
"preset": "Moderate",
|
||||
|
||||
"_signalMode": "ZScoreSynthetic (default, |z| >= zIn sul cross sintetico) oppure PipDivergence (fedele all'interfaccia Titany: divergenza in pip dall'ancora, dIn).",
|
||||
"signalMode": "ZScoreSynthetic",
|
||||
"_exitMode": "First = la prima fra TP in pip e rientro dello z; FixedPips = solo TP in pip lordi; ZReturn = solo |z| <= zOut.",
|
||||
"exitMode": "First",
|
||||
"_averagingMode": "Off | AddOnce | Grid. Off in live; AddOnce in paper. Moltiplicatore di lotto sempre 1,0 (niente martingala).",
|
||||
"averagingMode": "Off",
|
||||
"tpMode": "Pips",
|
||||
"_sameCrossPolicy": "I basket 4 e 5 sono entrambi EURCAD: Exclusive = uno solo aperto per volta; Half = entrambi a metà size.",
|
||||
"sameCrossPolicy": "Exclusive",
|
||||
"preferDirectCross": false,
|
||||
|
||||
"_indicatori": "Correlazione di Pearson rolling dei rendimenti M15 su window (ρ_W) e windowShort (ρ_20); z-score del cross sintetico su window; half-life OLS ricalcolata ogni halfLifeRecalcHours.",
|
||||
"window": 100,
|
||||
"windowShort": 20,
|
||||
"rhoMin": 0.60,
|
||||
"rhoShortMin": 0.40,
|
||||
"halfLifeMinBars": 4,
|
||||
"halfLifeMaxBars": 96,
|
||||
"halfLifeRecalcHours": 4,
|
||||
"atrPeriod": 14,
|
||||
"ewmaSpan": 100,
|
||||
"trendPeriod": 14,
|
||||
|
||||
"zOut": 0.25,
|
||||
"dIn": 15,
|
||||
"anchorBars": 32,
|
||||
"gridStepZ": 0.75,
|
||||
"lotMultiplier": 1.0,
|
||||
|
||||
"_uscite": "Stop di basket: |z| >= zStop, oppure perdita netta >= maxLossPerBasketPct dell'equity, oppure |ρ_20| < rhoBreak per rhoBreakBars barre, oppure maxHoldingBars barre (96 = 24 h).",
|
||||
"maxLossPerBasketPct": 1.5,
|
||||
"rhoBreak": 0.20,
|
||||
"rhoBreakBars": 8,
|
||||
"maxHoldingBars": 96,
|
||||
"tpAtrMultiple": 1.0,
|
||||
|
||||
"_costGate": "Costo = spread_A + spread_B (in pip-equivalenti di A) + markup e commissioni dell'API + overnight stimato per maxHoldingBars. Entrata solo se TP >= costMultiple × costo e ogni spread <= spreadMedianMultiple × la sua mediana delle ultime 24 h; spread oltre spreadAnomalyMultiple × mediana = chiusura forzata.",
|
||||
"costMultiple": 3,
|
||||
"spreadMedianMultiple": 2,
|
||||
"spreadAnomalyMultiple": 3,
|
||||
"slippagePipsPerLeg": 0.3,
|
||||
"overnightPipsPerDay": 0.3,
|
||||
|
||||
"_calendario": "Nessuna entrata nei blackoutBeforeMin minuti prima e blackoutAfterMin dopo un evento ad alto impatto sulle valute del basket; niente entrate dal venerdì fridayCutoffUtcHour UTC alla riapertura né nei primi openDelayMinutes dopo l'apertura settimanale; sessions = fasce orarie UTC ammesse (vuoto = sempre).",
|
||||
"blackoutBeforeMin": 45,
|
||||
"blackoutAfterMin": 30,
|
||||
"fridayCutoffUtcHour": 20,
|
||||
"openDelayMinutes": 30,
|
||||
"sessions": [],
|
||||
|
||||
"_sizing": "Lotto B = lotto A × (ATR_A × pipValue_A) / (ATR_B × pipValue_B); lotto A tale che la perdita allo stop valga riskPerBasketPct dell'equity; leva effettiva <= maxEffectiveLeverage sul nozionale complessivo; orderLeverage è la leva dichiarata a eToro per ogni gamba (1, 2, 5, 10, 20, 30).",
|
||||
"maxEffectiveLeverage": 10,
|
||||
"orderLeverage": 10,
|
||||
"_volScale": "zIn effettivo = zIn × clamp(σ_prevista / σ_media_30g, volScaleMin, volScaleMax).",
|
||||
"volScaleMin": 0.8,
|
||||
"volScaleMax": 1.5,
|
||||
"volAverageDays": 30,
|
||||
"mlMinProbability": 0.55,
|
||||
|
||||
"_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
|
||||
},
|
||||
|
||||
"_recovery": "Recupero dopo inattività (5.0, §6). Il bot scrive data/state/heartbeat.json ogni heartbeatSeconds; se all'avvio o fra due cicli passano più di thresholdMinutes (riavvio, sospensione del PC, aggiornamento, container fermo) blocca le entrate, risolve gli ordini senza esito, riconcilia, riscalda le serie con le barre perse e rivaluta ogni basket aperto come a una chiusura di barra ordinaria (chiudi o tieni; le orfane si chiudono, le esterne si riportano); scrive reports/recupero_<run_id>.csv e riapre le entrate dopo warmupMinutes di quotazioni. A mercato chiuso aspetta la riapertura.",
|
||||
"recovery": {
|
||||
"thresholdMinutes": 10,
|
||||
"warmupMinutes": 15,
|
||||
"heartbeatSeconds": 30
|
||||
},
|
||||
|
||||
"_learning": "Apprendimento (ADR-0006). Di fabbrica tutto spento: restano il ledger, le tabelle di calibrazione, la previsione di volatilità e la logistica in ombra (p_ML nel ledger, mai un cancello). enabled = true riaccende il ciclo settimanale (weeklyCycle) e il challenger MLP (challenger) solo dopo il criterio di riattivazione di docs/ML_AND_LEARNING.md: almeno 300 basket chiusi in Demo e P&L netto forward >= 0 sulla pre-registrazione. Il bandit propone soltanto, non applica mai il preset.",
|
||||
"learning": {
|
||||
"enabled": false,
|
||||
"weeklyCycle": false,
|
||||
"challenger": false
|
||||
},
|
||||
|
||||
"_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" },
|
||||
{ "a": "AUDUSD", "b": "USDCAD", "enabled": true, "note": "cross sintetico AUDCAD" },
|
||||
{ "a": "NZDUSD", "b": "EURNZD", "enabled": true, "note": "cross sintetico EURUSD: replica EURUSD pagando due spread" },
|
||||
{ "a": "USDCAD", "b": "EURUSD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 5)" },
|
||||
{ "a": "EURAUD", "b": "AUDCAD", "enabled": true, "note": "cross sintetico EURCAD (stessa esposizione del basket 4)" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
internal static class PresetExtensions
|
||||
{
|
||||
public static double ZOutOrZero(this BasketPreset preset, double zOut) => Math.Max(0, zOut);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>What the cost gate looked at and what it decided.</summary>
|
||||
public sealed record CostGateResult(
|
||||
bool Passed,
|
||||
double CostPips,
|
||||
double SpreadPipsA,
|
||||
double SpreadPipsB,
|
||||
double MarkupPipsA,
|
||||
double MarkupPipsB,
|
||||
double OvernightPips,
|
||||
double BreakEvenWinRate,
|
||||
string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// The one rule that stands between the signal and the order: is the take-profit large
|
||||
/// enough to pay for the round trip? Cost is expressed in pip-equivalents of leg A so
|
||||
/// that it is comparable with the basket's TP in pips.
|
||||
/// <para>
|
||||
/// <c>cost = spread_A + spread_B·(pipValue_B/pipValue_A) + markup + commissioni +
|
||||
/// overnight × giorni di detenzione massimi</c>. Entry is allowed only when
|
||||
/// <c>TP ≥ CostMultiple × cost</c> and when neither spread is more than
|
||||
/// <c>SpreadMedianMultiple</c> times its own 24-hour median — a widened book is not a
|
||||
/// temporarily expensive opportunity, it is a different market.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The break-even win rate reported alongside is the honest number of §0: with a net
|
||||
/// win of <c>TP − cost</c> and a net loss of <c>stopDistance + cost</c>, the strategy
|
||||
/// breaks even at <c>loss / (win + loss)</c> wins.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CostGate
|
||||
{
|
||||
public static CostGateResult Evaluate(
|
||||
double spreadPipsA,
|
||||
double spreadPipsB,
|
||||
double pipValueA,
|
||||
double pipValueB,
|
||||
double markupPipsA,
|
||||
double markupPipsB,
|
||||
double commissionPipsA,
|
||||
double overnightPipsPerDayPerLeg,
|
||||
double maxHoldingDays,
|
||||
double tpPips,
|
||||
double stopDistancePips,
|
||||
double costMultiple,
|
||||
double medianSpreadPipsA,
|
||||
double medianSpreadPipsB,
|
||||
double medianMultiple)
|
||||
{
|
||||
if (!double.IsFinite(spreadPipsA) || !double.IsFinite(spreadPipsB) || spreadPipsA < 0 || spreadPipsB < 0)
|
||||
{
|
||||
return new CostGateResult(false, double.NaN, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, 0, double.NaN, "spread non disponibile");
|
||||
}
|
||||
|
||||
// Leg B's pips are converted into leg A's pip-equivalents by the ratio of pip values,
|
||||
// so one number can be compared with the basket TP.
|
||||
double ratio = pipValueA > 0 && pipValueB > 0 ? pipValueB / pipValueA : 1;
|
||||
double overnight = overnightPipsPerDayPerLeg * maxHoldingDays * (1 + ratio);
|
||||
double cost = spreadPipsA + (spreadPipsB * ratio) + markupPipsA + (markupPipsB * ratio) + commissionPipsA + overnight;
|
||||
|
||||
double win = tpPips - cost;
|
||||
double loss = stopDistancePips + cost;
|
||||
double breakEven = win > 0 ? loss / (win + loss) : 1;
|
||||
|
||||
if (double.IsFinite(medianSpreadPipsA) && medianSpreadPipsA > 0 && spreadPipsA > medianMultiple * medianSpreadPipsA)
|
||||
{
|
||||
return new CostGateResult(false, cost, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, overnight, breakEven,
|
||||
F($"spread di A {spreadPipsA:F1} pip oltre {medianMultiple:F1}× la mediana 24 h ({medianSpreadPipsA:F1})"));
|
||||
}
|
||||
|
||||
if (double.IsFinite(medianSpreadPipsB) && medianSpreadPipsB > 0 && spreadPipsB > medianMultiple * medianSpreadPipsB)
|
||||
{
|
||||
return new CostGateResult(false, cost, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, overnight, breakEven,
|
||||
F($"spread di B {spreadPipsB:F1} pip oltre {medianMultiple:F1}× la mediana 24 h ({medianSpreadPipsB:F1})"));
|
||||
}
|
||||
|
||||
if (tpPips < costMultiple * cost)
|
||||
{
|
||||
return new CostGateResult(false, cost, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, overnight, breakEven,
|
||||
F($"TP {tpPips:F1} pip sotto {costMultiple:F1}× il costo {cost:F1} pip (break-even {breakEven:P0})"));
|
||||
}
|
||||
|
||||
return new CostGateResult(true, cost, spreadPipsA, spreadPipsB, markupPipsA, markupPipsB, overnight, breakEven,
|
||||
F($"costo {cost:F1} pip, TP {tpPips:F1} = {tpPips / Math.Max(1e-9, cost):F1}× il costo, break-even {breakEven:P0}"));
|
||||
}
|
||||
|
||||
private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Encelado.Core.Baskets.Data;
|
||||
|
||||
/// <summary>
|
||||
/// One M15 bar with both sides of the book. Forex CFDs are traded on bid/ask, not on a
|
||||
/// last price: the strategy enters at the ask and exits at the bid (or the reverse), so
|
||||
/// a bar that only carried a mid would hide the one cost the cost gate exists to measure.
|
||||
/// <para>
|
||||
/// <see cref="SpreadMean"/> is the average ask−bid over the ticks of the bar, in price
|
||||
/// units; <see cref="Ticks"/> says how many quotes built it (zero for a bar that came
|
||||
/// from the broker's candle endpoint, which has no ticks).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct BidAskBar(
|
||||
DateTime TimeUtc,
|
||||
double BidOpen,
|
||||
double BidHigh,
|
||||
double BidLow,
|
||||
double BidClose,
|
||||
double AskOpen,
|
||||
double AskHigh,
|
||||
double AskLow,
|
||||
double AskClose,
|
||||
double SpreadMean,
|
||||
int Ticks,
|
||||
string Source)
|
||||
{
|
||||
public double MidOpen => (BidOpen + AskOpen) * 0.5;
|
||||
|
||||
public double MidHigh => (BidHigh + AskHigh) * 0.5;
|
||||
|
||||
public double MidLow => (BidLow + AskLow) * 0.5;
|
||||
|
||||
public double MidClose => (BidClose + AskClose) * 0.5;
|
||||
|
||||
/// <summary>Spread at the close, in price units.</summary>
|
||||
public double SpreadClose => AskClose - BidClose;
|
||||
|
||||
public bool IsValid =>
|
||||
BidOpen > 0 && BidHigh > 0 && BidLow > 0 && BidClose > 0 &&
|
||||
AskOpen > 0 && AskHigh > 0 && AskLow > 0 && AskClose > 0 &&
|
||||
BidHigh >= BidLow && AskHigh >= AskLow && AskClose >= BidClose * 0.99;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The on-disk form of <see cref="BidAskBar"/>: <c>data/market/candles_<SYMBOL>_M15.csv</c>,
|
||||
/// a <c>;</c>-separated table whose last column says where each bar came from.
|
||||
/// </summary>
|
||||
public static class BidAskBarCsv
|
||||
{
|
||||
public const string Header =
|
||||
"timeUtc;bidOpen;bidHigh;bidLow;bidClose;askOpen;askHigh;askLow;askClose;spreadMean;ticks;motivazione";
|
||||
|
||||
private const string TimeFormat = "yyyy-MM-ddTHH:mm:ssZ";
|
||||
|
||||
public static string FileName(string symbol, string timeFrame = "M15") =>
|
||||
$"candles_{symbol.ToUpperInvariant()}_{timeFrame}.csv";
|
||||
|
||||
public static string Format(in BidAskBar b)
|
||||
{
|
||||
StringBuilder sb = new(160);
|
||||
sb.Append(b.TimeUtc.ToString(TimeFormat, CultureInfo.InvariantCulture)).Append(';');
|
||||
Append(sb, b.BidOpen).Append(';');
|
||||
Append(sb, b.BidHigh).Append(';');
|
||||
Append(sb, b.BidLow).Append(';');
|
||||
Append(sb, b.BidClose).Append(';');
|
||||
Append(sb, b.AskOpen).Append(';');
|
||||
Append(sb, b.AskHigh).Append(';');
|
||||
Append(sb, b.AskLow).Append(';');
|
||||
Append(sb, b.AskClose).Append(';');
|
||||
Append(sb, b.SpreadMean).Append(';');
|
||||
sb.Append(b.Ticks.ToString(CultureInfo.InvariantCulture)).Append(';');
|
||||
sb.Append(b.Source.Replace(';', ',').Replace('\n', ' ').Replace('\r', ' '));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static StringBuilder Append(StringBuilder sb, double v) =>
|
||||
sb.Append(v.ToString("0.#######", CultureInfo.InvariantCulture));
|
||||
|
||||
public static bool TryParse(string line, out BidAskBar bar)
|
||||
{
|
||||
bar = default;
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("timeUtc", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] f = line.Split(';');
|
||||
if (f.Length < 12)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParseExact(f[0], TimeFormat, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double[] v = new double[9];
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
if (!double.TryParse(f[i + 1], NumberStyles.Float, CultureInfo.InvariantCulture, out v[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!int.TryParse(f[10], NumberStyles.Integer, CultureInfo.InvariantCulture, out int ticks))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bar = new BidAskBar(DateTime.SpecifyKind(t, DateTimeKind.Utc), v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], ticks, f[11]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Writes the whole table atomically: to a temporary file, then moved into place.</summary>
|
||||
public static void Write(string path, IEnumerable<BidAskBar> bars)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
ArgumentNullException.ThrowIfNull(bars);
|
||||
|
||||
string full = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
string temporary = full + ".tmp";
|
||||
|
||||
using (StreamWriter w = new(temporary, false, new UTF8Encoding(false), 1 << 16))
|
||||
{
|
||||
w.WriteLine(Header);
|
||||
foreach (BidAskBar b in bars)
|
||||
{
|
||||
w.WriteLine(Format(b));
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(temporary, full, overwrite: true);
|
||||
}
|
||||
|
||||
/// <summary>Appends bars newer than the last one on disk. Returns how many were written.</summary>
|
||||
public static int AppendNewer(string path, IEnumerable<BidAskBar> bars)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
ArgumentNullException.ThrowIfNull(bars);
|
||||
|
||||
DateTime last = LastTime(path) ?? DateTime.MinValue;
|
||||
List<BidAskBar> fresh = [.. bars.Where(b => b.TimeUtc > last).OrderBy(static b => b.TimeUtc)];
|
||||
if (fresh.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
string full = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
bool isNew = !File.Exists(full) || new FileInfo(full).Length == 0;
|
||||
|
||||
using StreamWriter w = new(full, append: true, new UTF8Encoding(false));
|
||||
if (isNew)
|
||||
{
|
||||
w.WriteLine(Header);
|
||||
}
|
||||
|
||||
foreach (BidAskBar b in fresh)
|
||||
{
|
||||
w.WriteLine(Format(b));
|
||||
}
|
||||
|
||||
return fresh.Count;
|
||||
}
|
||||
|
||||
/// <summary>Time of the last bar in the file, or null when the file is missing or empty.</summary>
|
||||
public static DateTime? LastTime(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read the tail rather than the whole file: the candle files span years.
|
||||
using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
long length = fs.Length;
|
||||
int take = (int)Math.Min(length, 4096);
|
||||
fs.Seek(length - take, SeekOrigin.Begin);
|
||||
byte[] buffer = new byte[take];
|
||||
int read = fs.Read(buffer, 0, take);
|
||||
string tail = Encoding.UTF8.GetString(buffer, 0, read);
|
||||
string[] lines = tail.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
for (int i = lines.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (TryParse(lines[i].TrimEnd('\r'), out BidAskBar b))
|
||||
{
|
||||
return b.TimeUtc;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<BidAskBar> Read(string path, DateTime? fromUtc = null, DateTime? toUtc = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
List<BidAskBar> bars = [];
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return bars;
|
||||
}
|
||||
|
||||
using StreamReader r = new(path, Encoding.UTF8, true, 1 << 16);
|
||||
string? line;
|
||||
while ((line = r.ReadLine()) is not null)
|
||||
{
|
||||
if (!TryParse(line, out BidAskBar b))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fromUtc is { } f && b.TimeUtc < f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toUtc is { } t && b.TimeUtc > t)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
bars.Add(b);
|
||||
}
|
||||
|
||||
return bars;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Encelado.Core.Baskets.Data;
|
||||
|
||||
/// <summary>What the conversion of one tick file produced, for the report.</summary>
|
||||
public sealed record TickConversionSummary(
|
||||
string Symbol,
|
||||
long TicksRead,
|
||||
long TicksSkipped,
|
||||
int Bars,
|
||||
DateTime FirstBarUtc,
|
||||
DateTime LastBarUtc,
|
||||
int WeekdayGapsOverOneHour,
|
||||
int SpikeBars,
|
||||
double MedianSpread);
|
||||
|
||||
/// <summary>
|
||||
/// Streams a MetaTrader 5 tick export (tab-separated <c>DATE TIME BID ASK LAST VOLUME FLAGS</c>,
|
||||
/// timestamps in UTC as verified on the weekend boundaries) into bid/ask bars, one bar
|
||||
/// per fixed interval, without ever holding the file in memory: the files run to eight
|
||||
/// gigabytes each.
|
||||
/// <para>
|
||||
/// A tick may carry only one side (flag 2 = bid changed, 4 = ask changed, 6 = both). The
|
||||
/// missing side keeps its last value, so the spread is always measured between two real
|
||||
/// quotes and never between a quote and a zero.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TickToBars
|
||||
{
|
||||
/// <summary>Called with each finished bar, in time order.</summary>
|
||||
public delegate void BarSink(in BidAskBar bar);
|
||||
|
||||
public static TickConversionSummary Convert(
|
||||
string symbol,
|
||||
string tickFile,
|
||||
TimeSpan interval,
|
||||
BarSink sink,
|
||||
double pip,
|
||||
Action<long>? progress = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(symbol);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tickFile);
|
||||
ArgumentNullException.ThrowIfNull(sink);
|
||||
|
||||
long intervalTicks = interval.Ticks;
|
||||
long ticksRead = 0;
|
||||
long skipped = 0;
|
||||
int bars = 0;
|
||||
int gaps = 0;
|
||||
int spikes = 0;
|
||||
DateTime first = default;
|
||||
DateTime last = default;
|
||||
List<double> spreadSamples = new(1 << 16);
|
||||
|
||||
double lastBid = 0;
|
||||
double lastAsk = 0;
|
||||
long bucket = long.MinValue;
|
||||
BarBuilder current = default;
|
||||
double previousClose = 0;
|
||||
|
||||
using StreamReader reader = new(tickFile, Encoding.ASCII, false, 1 << 20);
|
||||
string? line = reader.ReadLine(); // header
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
ticksRead++;
|
||||
if ((ticksRead & 0xFFFFF) == 0)
|
||||
{
|
||||
progress?.Invoke(ticksRead);
|
||||
}
|
||||
|
||||
if (!TryParseTick(line, ref lastBid, ref lastAsk, out long time))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastBid <= 0 || lastAsk <= 0)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
long b = time / intervalTicks;
|
||||
if (b != bucket)
|
||||
{
|
||||
if (bucket != long.MinValue)
|
||||
{
|
||||
BidAskBar done = current.Build(new DateTime(bucket * intervalTicks, DateTimeKind.Utc), "tick MT5");
|
||||
Emit(done);
|
||||
}
|
||||
|
||||
bucket = b;
|
||||
current = BarBuilder.Start(lastBid, lastAsk);
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Add(lastBid, lastAsk);
|
||||
}
|
||||
}
|
||||
|
||||
if (bucket != long.MinValue)
|
||||
{
|
||||
Emit(current.Build(new DateTime(bucket * intervalTicks, DateTimeKind.Utc), "tick MT5"));
|
||||
}
|
||||
|
||||
spreadSamples.Sort();
|
||||
double medianSpread = spreadSamples.Count > 0 ? spreadSamples[spreadSamples.Count / 2] / pip : 0;
|
||||
return new TickConversionSummary(symbol, ticksRead, skipped, bars, first, last, gaps, spikes, medianSpread);
|
||||
|
||||
void Emit(in BidAskBar bar)
|
||||
{
|
||||
if (bars == 0)
|
||||
{
|
||||
first = bar.TimeUtc;
|
||||
}
|
||||
else
|
||||
{
|
||||
TimeSpan gap = bar.TimeUtc - last;
|
||||
if (gap > TimeSpan.FromHours(1) && !IsWeekendGap(last, bar.TimeUtc))
|
||||
{
|
||||
gaps++;
|
||||
}
|
||||
|
||||
if (previousClose > 0)
|
||||
{
|
||||
double r = Math.Log(bar.MidClose / previousClose);
|
||||
if (Math.Abs(r) > 0.02)
|
||||
{
|
||||
spikes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last = bar.TimeUtc;
|
||||
previousClose = bar.MidClose;
|
||||
bars++;
|
||||
if (spreadSamples.Count < 2_000_000)
|
||||
{
|
||||
spreadSamples.Add(bar.SpreadMean);
|
||||
}
|
||||
|
||||
sink(bar);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Friday close to Sunday reopen, allowing a few hours of tolerance on either side.</summary>
|
||||
private static bool IsWeekendGap(DateTime from, DateTime to) =>
|
||||
from.DayOfWeek == DayOfWeek.Friday && to.DayOfWeek is DayOfWeek.Sunday or DayOfWeek.Monday && to - from < TimeSpan.FromHours(60);
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>2018.12.12\t08:09:05.503\t0.96454\t0.96486\t\t\t6</c>. Updates only the
|
||||
/// sides present on the line. Returns false for a malformed line.
|
||||
/// </summary>
|
||||
public static bool TryParseTick(ReadOnlySpan<char> line, ref double bid, ref double ask, out long timeTicks)
|
||||
{
|
||||
timeTicks = 0;
|
||||
if (line.Length < 24)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Date and time are fixed-width in the MT5 export.
|
||||
if (!TryInt(line.Slice(0, 4), out int year) || !TryInt(line.Slice(5, 2), out int month) || !TryInt(line.Slice(8, 2), out int day) ||
|
||||
!TryInt(line.Slice(11, 2), out int hour) || !TryInt(line.Slice(14, 2), out int minute) || !TryInt(line.Slice(17, 2), out int second))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int ms = 0;
|
||||
int cursor = 20;
|
||||
if (line.Length > 20 && line[19] == '.')
|
||||
{
|
||||
int end = line.Slice(20).IndexOf('\t');
|
||||
if (end < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryInt(line.Slice(20, end), out ms))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
cursor = 20 + end;
|
||||
}
|
||||
else if (line[19] == '\t')
|
||||
{
|
||||
cursor = 19;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
timeTicks = new DateTime(year, month, day, hour, minute, second, ms, DateTimeKind.Utc).Ticks;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> rest = line.Slice(cursor + 1);
|
||||
int tab = rest.IndexOf('\t');
|
||||
ReadOnlySpan<char> bidText = tab < 0 ? rest : rest.Slice(0, tab);
|
||||
ReadOnlySpan<char> askText = tab < 0 ? default : rest.Slice(tab + 1);
|
||||
int tab2 = askText.IndexOf('\t');
|
||||
if (tab2 >= 0)
|
||||
{
|
||||
askText = askText.Slice(0, tab2);
|
||||
}
|
||||
|
||||
bool any = false;
|
||||
if (bidText.Length > 0 && double.TryParse(bidText, NumberStyles.Float, CultureInfo.InvariantCulture, out double b) && b > 0)
|
||||
{
|
||||
bid = b;
|
||||
any = true;
|
||||
}
|
||||
|
||||
if (askText.Length > 0 && double.TryParse(askText, NumberStyles.Float, CultureInfo.InvariantCulture, out double a) && a > 0)
|
||||
{
|
||||
ask = a;
|
||||
any = true;
|
||||
}
|
||||
|
||||
return any;
|
||||
}
|
||||
|
||||
private static bool TryInt(ReadOnlySpan<char> s, out int value) =>
|
||||
int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||||
|
||||
private struct BarBuilder
|
||||
{
|
||||
private double _bo, _bh, _bl, _bc, _ao, _ah, _al, _ac, _spreadSum;
|
||||
private int _n;
|
||||
|
||||
public static BarBuilder Start(double bid, double ask)
|
||||
{
|
||||
BarBuilder b = default;
|
||||
b._bo = b._bh = b._bl = b._bc = bid;
|
||||
b._ao = b._ah = b._al = b._ac = ask;
|
||||
b._spreadSum = ask - bid;
|
||||
b._n = 1;
|
||||
return b;
|
||||
}
|
||||
|
||||
public void Add(double bid, double ask)
|
||||
{
|
||||
if (bid > _bh) { _bh = bid; }
|
||||
if (bid < _bl) { _bl = bid; }
|
||||
_bc = bid;
|
||||
if (ask > _ah) { _ah = ask; }
|
||||
if (ask < _al) { _al = ask; }
|
||||
_ac = ask;
|
||||
_spreadSum += ask - bid;
|
||||
_n++;
|
||||
}
|
||||
|
||||
public readonly BidAskBar Build(DateTime time, string source) =>
|
||||
new(time, _bo, _bh, _bl, _bc, _ao, _ah, _al, _ac, _n > 0 ? _spreadSum / _n : 0, _n, source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>A deposit, a withdrawal or a virtual credit: cash that moved without a trade.</summary>
|
||||
public sealed record CashMovement(DateTime TimeUtc, double Amount, double BalanceBefore, double BalanceAfter, double ClosedNetInBetween, string Motivazione);
|
||||
|
||||
/// <summary>
|
||||
/// The equity the risk rules look at, kept clean of cash movements (§5.7 of the 5.0
|
||||
/// plan). A deposit raises the balance without any trade explaining it; a withdrawal
|
||||
/// lowers it. Neither is a profit or a loss, so neither may move the peak the equity
|
||||
/// stop is measured from, nor the day's starting point of the daily-loss rule.
|
||||
/// <para>
|
||||
/// Detection: at every account refresh the change of the cash balance is compared with
|
||||
/// the realised result of the positions closed in between. A residual beyond the
|
||||
/// tolerance is a cash movement. The tolerance leaves room for overnight fees the venue
|
||||
/// debits without a close; a 30 000 USD credit is unmistakable.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EquityTracker
|
||||
{
|
||||
/// <summary>Residuals below this are noise (fees, rounding), never a cash movement.</summary>
|
||||
public double MinimumUsd { get; init; } = 10;
|
||||
|
||||
/// <summary>Residuals below this share of the balance are noise.</summary>
|
||||
public double TolerancePct { get; init; } = 0.0025;
|
||||
|
||||
/// <summary>Highest net equity seen since the last reset.</summary>
|
||||
public double PeakNetEquity { get; private set; }
|
||||
|
||||
/// <summary>Sum of every cash movement seen since the tracker was created or restored.</summary>
|
||||
public double CumulativeCashFlow { get; private set; }
|
||||
|
||||
/// <summary>The cash balance at the last observation, NaN before the first.</summary>
|
||||
public double LastBalance { get; private set; } = double.NaN;
|
||||
|
||||
public DateTime LastObservedUtc { get; private set; }
|
||||
|
||||
/// <summary>Equity without the cash that moved in or out: the number the drawdown is measured on.</summary>
|
||||
public double NetEquity(double equity) => equity - CumulativeCashFlow;
|
||||
|
||||
/// <summary>The peak expressed in today's account terms (net peak plus the cash that came in since).</summary>
|
||||
public double PeakEquity => PeakNetEquity + CumulativeCashFlow;
|
||||
|
||||
public double Drawdown(double equity) => PeakNetEquity > 0 ? Math.Max(0, (PeakNetEquity - NetEquity(equity)) / PeakNetEquity) : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Records an account reading. <paramref name="closedNetSinceLast"/> is the realised
|
||||
/// net result (profit minus fees) of the positions closed since the previous reading,
|
||||
/// which is the only legitimate reason for the cash balance to move.
|
||||
/// </summary>
|
||||
public CashMovement? Observe(DateTime now, double balance, double equity, double closedNetSinceLast)
|
||||
{
|
||||
CashMovement? movement = null;
|
||||
if (double.IsFinite(LastBalance))
|
||||
{
|
||||
double residual = balance - LastBalance - closedNetSinceLast;
|
||||
double tolerance = Math.Max(MinimumUsd, Math.Abs(balance) * TolerancePct);
|
||||
if (Math.Abs(residual) > tolerance)
|
||||
{
|
||||
CumulativeCashFlow += residual;
|
||||
movement = new CashMovement(now, residual, LastBalance, balance, closedNetSinceLast, string.Create(CultureInfo.InvariantCulture,
|
||||
$"{(residual > 0 ? "accredito" : "prelievo")} di {Math.Abs(residual):F2} USD: saldo da {LastBalance:F2} a {balance:F2} con {closedNetSinceLast:+0.00;-0.00} USD di chiusure nel frattempo; picco e drawdown non ne tengono conto"));
|
||||
}
|
||||
}
|
||||
|
||||
LastBalance = balance;
|
||||
LastObservedUtc = now;
|
||||
double net = NetEquity(equity);
|
||||
if (net > PeakNetEquity)
|
||||
{
|
||||
PeakNetEquity = net;
|
||||
}
|
||||
|
||||
return movement;
|
||||
}
|
||||
|
||||
/// <summary>After a reset the peak restarts from the current equity.</summary>
|
||||
public void ResetPeak(double equity) => PeakNetEquity = NetEquity(equity);
|
||||
|
||||
/// <summary>Restores the persisted state; a peak saved by a version that knew no cash flows is taken as a net peak.</summary>
|
||||
public void Restore(double peakNetEquity, double cumulativeCashFlow, double lastBalance, DateTime lastObservedUtc)
|
||||
{
|
||||
PeakNetEquity = Math.Max(0, peakNetEquity);
|
||||
CumulativeCashFlow = double.IsFinite(cumulativeCashFlow) ? cumulativeCashFlow : 0;
|
||||
LastBalance = lastBalance;
|
||||
LastObservedUtc = lastObservedUtc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>
|
||||
/// Who fills the orders. The bot runs unattended in every mode: it opens and closes
|
||||
/// baskets by itself. The default is <see cref="Demo"/>: real orders on eToro's demo
|
||||
/// account, virtual money. <see cref="Live"/> needs the configuration flag and the typed
|
||||
/// phrase at start (decision D-20, 2026-09-16: no manual approval of single orders).
|
||||
/// </summary>
|
||||
public enum ExecutionMode
|
||||
{
|
||||
Backtest = 0,
|
||||
Paper,
|
||||
Demo,
|
||||
Live,
|
||||
}
|
||||
|
||||
public static class ExecutionModeExtensions
|
||||
{
|
||||
public static bool IsLive(this ExecutionMode m) => m == ExecutionMode.Live;
|
||||
|
||||
public static bool IsDemo(this ExecutionMode m) => m == ExecutionMode.Demo;
|
||||
|
||||
public static bool UsesVenue(this ExecutionMode m) => m is ExecutionMode.Demo or ExecutionMode.Live;
|
||||
|
||||
/// <summary>The label shown in the badge: PAPER, DEMO, LIVE or BACKTEST.</summary>
|
||||
public static string Badge(this ExecutionMode m) => m switch
|
||||
{
|
||||
ExecutionMode.Paper => "PAPER",
|
||||
ExecutionMode.Demo => "DEMO",
|
||||
ExecutionMode.Live => "LIVE",
|
||||
_ => "BACKTEST",
|
||||
};
|
||||
|
||||
/// <summary><c>paper</c>, <c>demo</c>, <c>live</c> or <c>backtest</c>, for the badge colour.</summary>
|
||||
public static string Kind(this ExecutionMode m) => m.Badge().ToLowerInvariant();
|
||||
|
||||
/// <summary>Whether <paramref name="text"/> is a mode name of a previous version (the approve/auto pairs), which still parse.</summary>
|
||||
public static bool IsLegacyName(string? text) => Normalise(text) is "demoapprove" or "demoauto" or "liveapprove" or "liveauto";
|
||||
|
||||
public static bool TryParse(string? text, out ExecutionMode mode)
|
||||
{
|
||||
switch (Normalise(text))
|
||||
{
|
||||
case "backtest": mode = ExecutionMode.Backtest; return true;
|
||||
case "paper": mode = ExecutionMode.Paper; return true;
|
||||
case "demo" or "demoapprove" or "demoauto": mode = ExecutionMode.Demo; return true;
|
||||
case "live" or "real" or "liveapprove" or "liveauto": mode = ExecutionMode.Live; return true;
|
||||
default: mode = ExecutionMode.Demo; return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Normalise(string? text) =>
|
||||
(text ?? string.Empty).Trim().Replace("-", string.Empty, StringComparison.Ordinal).Replace("_", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Broker;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>A position the kill-switch must close, with what it is for the report.</summary>
|
||||
public sealed record FlattenTarget(long PositionId, long InstrumentId, string Symbol, bool IsBuy, double Units, string Basket, string Kind);
|
||||
|
||||
/// <summary>What the kill-switch did and what is left.</summary>
|
||||
public sealed record FlattenReport(
|
||||
IReadOnlyList<long> CancelledOrders,
|
||||
IReadOnlyList<long> OrdersFilledMeanwhile,
|
||||
IReadOnlyList<long> Closed,
|
||||
IReadOnlyList<long> Residue,
|
||||
double RealizedPnlUsd,
|
||||
bool Flat,
|
||||
string Summary)
|
||||
{
|
||||
/// <summary>The engine's state after the procedure: <c>Halted</c> when flat, <c>Halted-Residuo</c> otherwise.</summary>
|
||||
public string State => Flat ? "Halted" : "Halted-Residuo";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The kill-switch of §9 of the 5.0 plan, as three steps any engine can compose and a
|
||||
/// test can drive over a fake venue:
|
||||
/// <list type="number">
|
||||
/// <item><see cref="CancelPendingAsync"/>: every order in the register without an outcome
|
||||
/// is cancelled (when the venue has a cancel route) and then followed until it resolves or
|
||||
/// the wait runs out; one that filled meanwhile becomes a position to close.</item>
|
||||
/// <item><see cref="CloseTargetsAsync"/>: every target is closed through the caller's closer
|
||||
/// (three attempts and a check on the position list live in the executor).</item>
|
||||
/// <item><see cref="VerifyFlatAsync"/>: the position list is re-read until none of the ids
|
||||
/// that must be gone is there, or the timeout passes. Nothing is declared closed without
|
||||
/// this reading; what remains is the residue and the state is <c>Halted-Residuo</c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class FlattenProcedure(IBroker broker, OrderTracker tracker, Action<string> log)
|
||||
{
|
||||
private readonly IBroker _broker = broker ?? throw new ArgumentNullException(nameof(broker));
|
||||
private readonly OrderTracker _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker));
|
||||
private readonly Action<string> _log = log ?? (static _ => { });
|
||||
|
||||
/// <summary>How long to wait for a cancelled order to resolve.</summary>
|
||||
public TimeSpan CancelWait { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>How long the position list may still show a target before it is a residue.</summary>
|
||||
public TimeSpan FlatnessTimeout { get; init; } = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>How often the position list is re-read while waiting.</summary>
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Step 1. Returns the orders cancelled and the positions of the orders that filled anyway.</summary>
|
||||
public async Task<(List<long> Cancelled, List<BrokerPosition> FilledMeanwhile)> CancelPendingAsync(CancellationToken ct)
|
||||
{
|
||||
List<long> cancelled = [];
|
||||
List<BrokerPosition> filled = [];
|
||||
IReadOnlyList<TrackedOrder> pending = _tracker.Pending;
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return (cancelled, filled);
|
||||
}
|
||||
|
||||
foreach (TrackedOrder o in pending)
|
||||
{
|
||||
if (o.OrderId <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (await _broker.CancelOrderAsync(o.OrderId, ct).ConfigureAwait(false))
|
||||
{
|
||||
_log($"kill-switch: richiesto l'annullamento dell'ordine {o.OrderId} ({o.Describe()})");
|
||||
}
|
||||
}
|
||||
catch (BrokerException ex)
|
||||
{
|
||||
_log($"kill-switch: annullamento dell'ordine {o.OrderId} non riuscito ({ex.Message}): attendo la risoluzione");
|
||||
}
|
||||
}
|
||||
|
||||
DateTime deadline = DateTime.UtcNow + CancelWait;
|
||||
while (_tracker.PendingCount > 0 && DateTime.UtcNow < deadline)
|
||||
{
|
||||
foreach (TrackedOrder o in _tracker.Pending)
|
||||
{
|
||||
o.LastCheckUtc = default;
|
||||
}
|
||||
|
||||
List<(TrackedOrder Order, OrderOutcome Outcome)> resolved = await _tracker.ResolveAsync(_broker, DateTime.UtcNow, null, ct).ConfigureAwait(false);
|
||||
foreach ((TrackedOrder order, OrderOutcome outcome) in resolved)
|
||||
{
|
||||
if (outcome.Filled && outcome.PositionId > 0)
|
||||
{
|
||||
_log($"kill-switch: l'ordine {order.OrderId} è stato eseguito nel frattempo (posizione {outcome.PositionId}): la chiudo");
|
||||
filled.Add(new BrokerPosition(outcome.PositionId, order.InstrumentId, order.IsBuy, outcome.Units, outcome.FillRate, outcome.TimeUtc, 0, 0, 1, 0, 0, 0, outcome.FillRate));
|
||||
}
|
||||
else
|
||||
{
|
||||
cancelled.Add(order.OrderId);
|
||||
}
|
||||
}
|
||||
|
||||
if (_tracker.PendingCount > 0)
|
||||
{
|
||||
await Task.Delay(PollInterval, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TrackedOrder o in _tracker.Pending)
|
||||
{
|
||||
_log($"kill-switch: {o.Describe()} ancora senza esito dopo {CancelWait.TotalSeconds:0} s: resta nel registro e verrà chiuso quando comparirà");
|
||||
}
|
||||
|
||||
return (cancelled, filled);
|
||||
}
|
||||
|
||||
/// <summary>Step 2. Closes every target through <paramref name="closer"/>; returns the ids closed, the ids that refused, and the realised result.</summary>
|
||||
public static async Task<(List<long> Closed, List<long> Failed, double Realized)> CloseTargetsAsync(
|
||||
IReadOnlyList<FlattenTarget> targets, Func<FlattenTarget, CancellationToken, Task<CloseOutcome>> closer, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targets);
|
||||
ArgumentNullException.ThrowIfNull(closer);
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
List<long> closed = [];
|
||||
List<long> failed = [];
|
||||
double realized = 0;
|
||||
foreach (FlattenTarget t in targets)
|
||||
{
|
||||
CloseOutcome c;
|
||||
try
|
||||
{
|
||||
c = await closer(t, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (BrokerException ex)
|
||||
{
|
||||
c = new CloseOutcome(false, false, 0, 0, 0, DateTime.UtcNow, 0, ex.Message);
|
||||
}
|
||||
|
||||
if (c.Closed)
|
||||
{
|
||||
closed.Add(t.PositionId);
|
||||
realized += c.RealizedPnl;
|
||||
log(string.Create(CultureInfo.InvariantCulture, $"kill-switch: chiusa {t.Kind} {t.PositionId} {t.Symbol} {(t.IsBuy ? "long" : "short")} {t.Units:0.##} ({c.RealizedPnl:+0.00;-0.00} USD)"));
|
||||
}
|
||||
else
|
||||
{
|
||||
failed.Add(t.PositionId);
|
||||
log($"kill-switch: {t.Kind} {t.PositionId} {t.Symbol} NON chiusa: {c.Error}");
|
||||
}
|
||||
}
|
||||
|
||||
return (closed, failed, realized);
|
||||
}
|
||||
|
||||
/// <summary>Step 3. Re-reads the positions until none of <paramref name="mustBeGone"/> is there, or the timeout passes. Returns what is left.</summary>
|
||||
public async Task<List<long>> VerifyFlatAsync(IReadOnlyCollection<long> mustBeGone, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mustBeGone);
|
||||
DateTime deadline = DateTime.UtcNow + FlatnessTimeout;
|
||||
List<long> residue = [.. mustBeGone];
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
IReadOnlyList<BrokerPosition> positions = await _broker.GetPositionsAsync(ct).ConfigureAwait(false);
|
||||
HashSet<long> onVenue = [.. positions.Select(static p => p.PositionId)];
|
||||
residue = [.. mustBeGone.Where(onVenue.Contains)];
|
||||
}
|
||||
catch (BrokerException ex)
|
||||
{
|
||||
_log($"kill-switch: verifica di piattezza non riuscita ({ex.Message}): riprovo");
|
||||
}
|
||||
|
||||
if (residue.Count == 0 || DateTime.UtcNow >= deadline)
|
||||
{
|
||||
return residue;
|
||||
}
|
||||
|
||||
await Task.Delay(PollInterval, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The whole procedure over a set of targets: cancel, close, verify.</summary>
|
||||
public async Task<FlattenReport> RunAsync(IReadOnlyList<FlattenTarget> targets, Func<FlattenTarget, CancellationToken, Task<CloseOutcome>> closer, Func<long, string>? symbolOf, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targets);
|
||||
ArgumentNullException.ThrowIfNull(closer);
|
||||
(List<long> cancelled, List<BrokerPosition> filledMeanwhile) = await CancelPendingAsync(ct).ConfigureAwait(false);
|
||||
List<FlattenTarget> all = [.. targets];
|
||||
foreach (BrokerPosition p in filledMeanwhile)
|
||||
{
|
||||
if (all.All(t => t.PositionId != p.PositionId))
|
||||
{
|
||||
all.Add(new FlattenTarget(p.PositionId, p.InstrumentId, symbolOf?.Invoke(p.InstrumentId) ?? p.InstrumentId.ToString(CultureInfo.InvariantCulture), p.IsBuy, p.Units, string.Empty, "orfana-bot"));
|
||||
}
|
||||
}
|
||||
|
||||
(List<long> closed, List<long> failed, double realized) = await CloseTargetsAsync(all, closer, _log, ct).ConfigureAwait(false);
|
||||
List<long> residue = await VerifyFlatAsync([.. all.Select(static t => t.PositionId)], ct).ConfigureAwait(false);
|
||||
bool flat = residue.Count == 0;
|
||||
string summary = string.Create(CultureInfo.InvariantCulture,
|
||||
$"{closed.Count} posizioni chiuse su {all.Count} ({realized:+0.00;-0.00} USD), {cancelled.Count} ordini annullati, {filledMeanwhile.Count} eseguiti nel frattempo, {_tracker.PendingCount} ancora senza esito; {(flat ? "conto piatto per le posizioni del bot" : $"RESIDUO: {string.Join(", ", residue)}")}");
|
||||
return new FlattenReport(cancelled, [.. filledMeanwhile.Select(static p => p.PositionId)], closed, residue, realized, flat, summary);
|
||||
}
|
||||
|
||||
/// <summary>The reset's first gate: a written reason of at least ten characters.</summary>
|
||||
public static bool IsValidResetReason(string? reason) => (reason ?? string.Empty).Trim().Length >= 10;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>What the bot writes every few seconds to say it is alive (§6 of the 5.0 plan).</summary>
|
||||
public sealed record Heartbeat(DateTime Utc, string RunId, string Mode, int OpenBaskets, int PendingBaskets, int Pid, string Note = "")
|
||||
{
|
||||
/// <summary>The idle time since this heartbeat: what a restart, a suspend or a frozen container left uncovered.</summary>
|
||||
public TimeSpan Inactivity(DateTime nowUtc) => nowUtc - Utc;
|
||||
}
|
||||
|
||||
/// <summary><c>data/state/heartbeat.json</c>: written atomically, read once at startup.</summary>
|
||||
public static class HeartbeatFile
|
||||
{
|
||||
public static void Write(string path, Heartbeat hb)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
ArgumentNullException.ThrowIfNull(hb);
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("utc", hb.Utc.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("run_id", hb.RunId);
|
||||
w.WriteString("mode", hb.Mode);
|
||||
w.WriteNumber("openBaskets", hb.OpenBaskets);
|
||||
w.WriteNumber("pendingBaskets", hb.PendingBaskets);
|
||||
w.WriteNumber("pid", hb.Pid);
|
||||
w.WriteString("note", hb.Note);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
string? dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.WriteAllBytes(path + ".tmp", ms.ToArray());
|
||||
File.Move(path + ".tmp", path, overwrite: true);
|
||||
}
|
||||
|
||||
public static Heartbeat? Read(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(File.ReadAllBytes(path));
|
||||
JsonElement r = doc.RootElement;
|
||||
if (!r.TryGetProperty("utc", out JsonElement u) || !DateTime.TryParse(u.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime utc))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Heartbeat(utc,
|
||||
r.TryGetProperty("run_id", out JsonElement id) ? id.GetString() ?? string.Empty : string.Empty,
|
||||
r.TryGetProperty("mode", out JsonElement m) ? m.GetString() ?? string.Empty : string.Empty,
|
||||
r.TryGetProperty("openBaskets", out JsonElement ob) ? ob.GetInt32() : 0,
|
||||
r.TryGetProperty("pendingBaskets", out JsonElement pb) ? pb.GetInt32() : 0,
|
||||
r.TryGetProperty("pid", out JsonElement pid) ? pid.GetInt32() : 0,
|
||||
r.TryGetProperty("note", out JsonElement n) ? n.GetString() ?? string.Empty : string.Empty);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Encelado.Core.Baskets.History;
|
||||
|
||||
/// <summary>One closed basket as <c>baskets.csv</c> records it.</summary>
|
||||
public sealed record BasketOutcomeRow(
|
||||
string BasketId,
|
||||
string RunId,
|
||||
string Basket,
|
||||
string Mode,
|
||||
string Preset,
|
||||
DateTime OpenedUtc,
|
||||
DateTime ClosedUtc,
|
||||
bool BuyCross,
|
||||
double EntryZ,
|
||||
double ExitZ,
|
||||
double PnlGrossUsd,
|
||||
double PnlNetUsd,
|
||||
double PipsGross,
|
||||
double CostPips,
|
||||
double CostUsd,
|
||||
double SlippagePips,
|
||||
int Adds,
|
||||
int BarsHeld,
|
||||
string ExitReason,
|
||||
double EquityAtEntry,
|
||||
double PMlAtEntry,
|
||||
string Motivazione)
|
||||
{
|
||||
public int Label => PnlNetUsd > 0 ? 1 : 0;
|
||||
|
||||
public const string Header =
|
||||
"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";
|
||||
|
||||
public string ToCsv() => string.Join(';',
|
||||
[
|
||||
BasketId, RunId, Basket, Mode, Preset,
|
||||
OpenedUtc.ToString("O", CultureInfo.InvariantCulture), ClosedUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
BuyCross ? "1" : "0", N(EntryZ), N(ExitZ), N(PnlGrossUsd), N(PnlNetUsd), N(PipsGross), N(CostPips), N(CostUsd), N(SlippagePips),
|
||||
Adds.ToString(CultureInfo.InvariantCulture), BarsHeld.ToString(CultureInfo.InvariantCulture), ExitReason, N(EquityAtEntry), N(PMlAtEntry),
|
||||
Label.ToString(CultureInfo.InvariantCulture), ((ClosedUtc - OpenedUtc).TotalMinutes).ToString("0", CultureInfo.InvariantCulture),
|
||||
Motivazione.Replace(';', ',').Replace('\n', ' ').Replace('\r', ' '),
|
||||
]);
|
||||
|
||||
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.######", CultureInfo.InvariantCulture) : string.Empty;
|
||||
|
||||
public static BasketOutcomeRow? Parse(string line)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(line);
|
||||
string[] f = line.Split(';');
|
||||
if (f.Length < 24 || f[0] == "basket_id")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new BasketOutcomeRow(f[0], f[1], f[2], f[3], f[4], T(f[5]), T(f[6]), f[7] == "1", D(f[8]), D(f[9]), D(f[10]), D(f[11]), D(f[12]), D(f[13]), D(f[14]), D(f[15]),
|
||||
int.Parse(f[16], CultureInfo.InvariantCulture), int.Parse(f[17], CultureInfo.InvariantCulture), f[18], D(f[19]), D(f[20]), f[23]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
static DateTime T(string s) => DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
||||
static double D(string s) => s.Length == 0 ? double.NaN : double.Parse(s, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Encelado.Core.Broker;
|
||||
|
||||
namespace Encelado.Core.Baskets.History;
|
||||
|
||||
/// <summary>One position (open or closed) or one cash movement, as the Storico page shows it.</summary>
|
||||
public sealed record PositionRecord(
|
||||
long PositionId,
|
||||
string Symbol,
|
||||
bool IsBuy,
|
||||
double Units,
|
||||
double OpenRate,
|
||||
double CloseRate,
|
||||
DateTime OpenedUtc,
|
||||
DateTime? ClosedUtc,
|
||||
double PnlGrossUsd,
|
||||
double FeesUsd,
|
||||
double PnlNetUsd,
|
||||
double Pips,
|
||||
string Origin,
|
||||
string BasketId,
|
||||
string Basket,
|
||||
string ExitReason,
|
||||
bool IsOpen,
|
||||
string Motivazione)
|
||||
{
|
||||
public const string Header = "position_id;strumento;verso;unita;prezzo_apertura;prezzo_chiusura;aperta_utc;chiusa_utc;pnl_lordo_usd;fee_usd;pnl_netto_usd;pip;origine;basket_id;basket;motivo_uscita;aperta;durata_min;motivazione";
|
||||
|
||||
public double DurationMinutes => ((ClosedUtc ?? DateTime.UtcNow) - OpenedUtc).TotalMinutes;
|
||||
|
||||
public string ToCsv() => string.Join(';',
|
||||
[
|
||||
PositionId.ToString(CultureInfo.InvariantCulture), Symbol, IsBuy ? "long" : "short", N(Units), N(OpenRate), N(CloseRate),
|
||||
OpenedUtc.ToString("O", CultureInfo.InvariantCulture), ClosedUtc?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
N(PnlGrossUsd), N(FeesUsd), N(PnlNetUsd), N(Pips), Origin, BasketId, Basket, ExitReason, IsOpen ? "1" : "0",
|
||||
DurationMinutes.ToString("0", CultureInfo.InvariantCulture), Motivazione.Replace(';', ',').Replace('\n', ' '),
|
||||
]);
|
||||
|
||||
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.######", CultureInfo.InvariantCulture) : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>The realised result of a period, the way the Storico page tabulates it.</summary>
|
||||
public sealed record PeriodStats(
|
||||
string Periodo,
|
||||
DateTime FromUtc,
|
||||
DateTime ToUtc,
|
||||
int NBasket,
|
||||
int NPosizioni,
|
||||
int Vinti,
|
||||
int Persi,
|
||||
double WinRate,
|
||||
double PnlLordo,
|
||||
double Fee,
|
||||
double PnlNetto,
|
||||
double MediaPerBasket,
|
||||
double MaxDrawdownUsd,
|
||||
double MovimentiDiCassa,
|
||||
string Motivazione)
|
||||
{
|
||||
public const string Header = "periodo;da_utc;a_utc;n_basket;n_posizioni;vinti;persi;win_rate;pnl_lordo;fee;pnl_netto;media_per_basket;max_dd;movimenti_di_cassa;motivazione";
|
||||
|
||||
public string ToCsv() => string.Join(';',
|
||||
[
|
||||
Periodo, FromUtc.ToString("O", CultureInfo.InvariantCulture), ToUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
NBasket.ToString(CultureInfo.InvariantCulture), NPosizioni.ToString(CultureInfo.InvariantCulture), Vinti.ToString(CultureInfo.InvariantCulture), Persi.ToString(CultureInfo.InvariantCulture),
|
||||
N(WinRate), N(PnlLordo), N(Fee), N(PnlNetto), N(MediaPerBasket), N(MaxDrawdownUsd), N(MovimentiDiCassa), Motivazione.Replace(';', ','),
|
||||
]);
|
||||
|
||||
private static string N(double v) => double.IsFinite(v) ? v.ToString("0.####", CultureInfo.InvariantCulture) : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>A cash movement as the ledger recorded it (deposit, withdrawal, virtual credit).</summary>
|
||||
public sealed record CashMovementRecord(DateTime TimeUtc, double Amount, string Motivazione);
|
||||
|
||||
/// <summary>One point of the realised equity curve (cumulative net P&L of the closed positions, cash movements excluded).</summary>
|
||||
public readonly record struct EquityPoint(DateTime TimeUtc, double CumulativeNetUsd);
|
||||
|
||||
/// <summary>
|
||||
/// Turns the raw sources — the venue's closed trades, the open positions, the bot's own
|
||||
/// <c>baskets.csv</c> and <c>orders.jsonl</c>, the cash movements of the ledger — into
|
||||
/// the three views of the Storico page (§1 of the 5.0 plan). Pure and testable: no I/O,
|
||||
/// no clock other than the one passed in. The venue's history is the truth for the
|
||||
/// realised result; the bot's files say whose each position was.
|
||||
/// </summary>
|
||||
public static class HistoryBuilder
|
||||
{
|
||||
/// <summary>Every position, closed and open, plus one row per cash movement; newest first.</summary>
|
||||
public static List<PositionRecord> Positions(
|
||||
IReadOnlyList<ClosedTrade> closed,
|
||||
IReadOnlyList<BrokerPosition> open,
|
||||
IReadOnlyList<BasketOutcomeRow> baskets,
|
||||
IReadOnlyList<OrderRecord> orders,
|
||||
IReadOnlyList<CashMovementRecord> cash,
|
||||
Func<long, string> symbolOf,
|
||||
Func<string, double> pipOf,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(closed);
|
||||
ArgumentNullException.ThrowIfNull(open);
|
||||
ArgumentNullException.ThrowIfNull(baskets);
|
||||
ArgumentNullException.ThrowIfNull(orders);
|
||||
ArgumentNullException.ThrowIfNull(cash);
|
||||
ArgumentNullException.ThrowIfNull(symbolOf);
|
||||
ArgumentNullException.ThrowIfNull(pipOf);
|
||||
|
||||
// Whose is each position id: the last order line that produced it.
|
||||
Dictionary<long, OrderRecord> byPosition = [];
|
||||
foreach (OrderRecord o in orders)
|
||||
{
|
||||
if (o.PositionId > 0 && o.Leg is not (OrderLeg.Close or OrderLeg.Unwind))
|
||||
{
|
||||
byPosition[o.PositionId] = o;
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<string, BasketOutcomeRow> byBasketId = new(StringComparer.Ordinal);
|
||||
foreach (BasketOutcomeRow b in baskets)
|
||||
{
|
||||
if (b.BasketId.Length > 0)
|
||||
{
|
||||
byBasketId[b.BasketId] = b;
|
||||
}
|
||||
}
|
||||
|
||||
List<PositionRecord> rows = [];
|
||||
foreach (ClosedTrade t in closed)
|
||||
{
|
||||
string symbol = symbolOf(t.InstrumentId);
|
||||
(string origin, string basketId, string basket, string exit) = Classify(t.PositionId, t.OpenedUtc, byPosition, byBasketId, baskets);
|
||||
double pip = pipOf(symbol);
|
||||
double pips = pip > 0 ? (t.IsBuy ? t.CloseRate - t.OpenRate : t.OpenRate - t.CloseRate) / pip : double.NaN;
|
||||
rows.Add(new PositionRecord(t.PositionId, symbol, t.IsBuy, t.Units, t.OpenRate, t.CloseRate, t.OpenedUtc, t.ClosedUtc,
|
||||
t.NetProfit, t.Fees, t.NetProfit - t.Fees, pips, origin, basketId, basket, exit, false, origin == "esterna" ? "posizione non aperta dal bot" : string.Empty));
|
||||
}
|
||||
|
||||
foreach (BrokerPosition p in open)
|
||||
{
|
||||
string symbol = symbolOf(p.InstrumentId);
|
||||
(string origin, string basketId, string basket, _) = Classify(p.PositionId, p.OpenedUtc, byPosition, byBasketId, baskets);
|
||||
double pip = pipOf(symbol);
|
||||
double pips = pip > 0 && p.CurrentRate > 0 ? (p.IsBuy ? p.CurrentRate - p.OpenRate : p.OpenRate - p.CurrentRate) / pip : double.NaN;
|
||||
rows.Add(new PositionRecord(p.PositionId, symbol, p.IsBuy, p.Units, p.OpenRate, p.CurrentRate, p.OpenedUtc, null,
|
||||
p.UnrealizedPnl + p.Fees, p.Fees, p.UnrealizedPnl, pips, origin, basketId, basket, string.Empty, true, "aperta: P&L corrente"));
|
||||
}
|
||||
|
||||
foreach (CashMovementRecord c in cash)
|
||||
{
|
||||
rows.Add(new PositionRecord(0, "USD", c.Amount >= 0, 0, 0, 0, c.TimeUtc, c.TimeUtc, c.Amount, 0, c.Amount, double.NaN, "movimento di cassa", string.Empty, string.Empty,
|
||||
c.Amount >= 0 ? "deposito" : "prelievo", false, c.Motivazione));
|
||||
}
|
||||
|
||||
rows.Sort(static (a, b) => (b.ClosedUtc ?? DateTime.MaxValue).CompareTo(a.ClosedUtc ?? DateTime.MaxValue));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static (string Origin, string BasketId, string Basket, string Exit) Classify(long positionId, DateTime openedUtc, Dictionary<long, OrderRecord> byPosition, Dictionary<string, BasketOutcomeRow> byBasketId, IReadOnlyList<BasketOutcomeRow> baskets)
|
||||
{
|
||||
if (byPosition.TryGetValue(positionId, out OrderRecord? o))
|
||||
{
|
||||
if (o.BasketId.Length > 0 && byBasketId.TryGetValue(o.BasketId, out BasketOutcomeRow? b))
|
||||
{
|
||||
bool lone = b.ExitReason is "leg_risk_unwind" or "orphan_closed" or "bonifica_orfana" || !double.IsFinite(b.EntryZ);
|
||||
return (lone ? "orfana-bot" : "basket", o.BasketId, o.Basket, b.ExitReason);
|
||||
}
|
||||
|
||||
return ("orfana-bot", o.BasketId, o.Basket, string.Empty);
|
||||
}
|
||||
|
||||
// Before orders.jsonl existed (4.0.0) the only trace is a lone-leg row in baskets.csv whose motivation carries the position id.
|
||||
foreach (BasketOutcomeRow b in baskets)
|
||||
{
|
||||
if (b.Motivazione.Contains(positionId.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
|
||||
{
|
||||
return ("orfana-bot", b.BasketId, b.Basket, b.ExitReason);
|
||||
}
|
||||
}
|
||||
|
||||
return ("esterna", string.Empty, string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>The standard periods of the Storico page, computed on the closed positions of the bot (cash movements apart).</summary>
|
||||
public static List<PeriodStats> Periods(IReadOnlyList<PositionRecord> positions, DateTime nowUtc, (DateTime From, DateTime To)? custom = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(positions);
|
||||
DateTime today = nowUtc.Date;
|
||||
DateTime monthStart = new(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime previousMonthStart = monthStart.AddMonths(-1);
|
||||
DateTime yearStart = new(nowUtc.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime first = positions.Count > 0 ? positions.Min(static p => p.OpenedUtc) : today;
|
||||
List<(string, DateTime, DateTime)> periods =
|
||||
[
|
||||
("oggi", today, today.AddDays(1)),
|
||||
("ieri", today.AddDays(-1), today),
|
||||
("7 giorni", nowUtc.AddDays(-7), nowUtc.AddSeconds(1)),
|
||||
("30 giorni", nowUtc.AddDays(-30), nowUtc.AddSeconds(1)),
|
||||
("mese corrente", monthStart, monthStart.AddMonths(1)),
|
||||
("mese precedente", previousMonthStart, monthStart),
|
||||
("anno", yearStart, yearStart.AddYears(1)),
|
||||
("tutto", first < today ? first.Date : today.AddDays(-1), nowUtc.AddSeconds(1)),
|
||||
];
|
||||
if (custom is { } c)
|
||||
{
|
||||
periods.Add(("personalizzato", c.From, c.To));
|
||||
}
|
||||
|
||||
return [.. periods.Select(p => Period(p.Item1, p.Item2, p.Item3, positions))];
|
||||
}
|
||||
|
||||
public static PeriodStats Period(string label, DateTime fromUtc, DateTime toUtc, IReadOnlyList<PositionRecord> positions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(positions);
|
||||
List<PositionRecord> closed = [.. positions.Where(p => !p.IsOpen && p.Origin != "movimento di cassa" && p.Origin != "esterna" && p.ClosedUtc is { } t && t >= fromUtc && t < toUtc).OrderBy(static p => p.ClosedUtc)];
|
||||
double cash = positions.Where(p => p.Origin == "movimento di cassa" && p.OpenedUtc >= fromUtc && p.OpenedUtc < toUtc).Sum(static p => p.PnlNetUsd);
|
||||
HashSet<string> basketIds = [.. closed.Where(static p => p.Origin == "basket" && p.BasketId.Length > 0).Select(static p => p.BasketId)];
|
||||
int nBasket = basketIds.Count + closed.Count(static p => p.Origin != "basket" || p.BasketId.Length == 0);
|
||||
|
||||
// Wins and losses per basket (both legs together), per lone leg otherwise.
|
||||
Dictionary<string, double> perUnit = new(StringComparer.Ordinal);
|
||||
foreach (PositionRecord p in closed)
|
||||
{
|
||||
string key = p.Origin == "basket" && p.BasketId.Length > 0 ? p.BasketId : "#" + p.PositionId.ToString(CultureInfo.InvariantCulture);
|
||||
perUnit[key] = perUnit.GetValueOrDefault(key) + p.PnlNetUsd;
|
||||
}
|
||||
|
||||
int won = perUnit.Values.Count(static v => v > 0);
|
||||
int lost = perUnit.Values.Count(static v => v <= 0);
|
||||
double gross = closed.Sum(static p => p.PnlGrossUsd);
|
||||
double fees = closed.Sum(static p => p.FeesUsd);
|
||||
double net = closed.Sum(static p => p.PnlNetUsd);
|
||||
double maxDd = MaxDrawdown(EquityCurve(closed));
|
||||
string why = closed.Count == 0
|
||||
? "nessuna posizione chiusa nel periodo"
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{closed.Count} posizioni in {perUnit.Count} unità, realizzato dallo storico eToro, fee incluse nel netto{(cash != 0 ? $"; movimenti di cassa {cash:+0.00;-0.00} esclusi dal P&L" : string.Empty)}");
|
||||
return new PeriodStats(label, fromUtc, toUtc, nBasket, closed.Count, won, lost, perUnit.Count > 0 ? (double)won / perUnit.Count : double.NaN,
|
||||
gross, fees, net, perUnit.Count > 0 ? net / perUnit.Count : double.NaN, maxDd, cash, why);
|
||||
}
|
||||
|
||||
/// <summary>The cumulative realised net P&L of the closed positions, in closing order.</summary>
|
||||
public static List<EquityPoint> EquityCurve(IEnumerable<PositionRecord> closed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(closed);
|
||||
double sum = 0;
|
||||
List<EquityPoint> points = [];
|
||||
foreach (PositionRecord p in closed.Where(static p => !p.IsOpen && p.Origin != "movimento di cassa" && p.ClosedUtc is not null).OrderBy(static p => p.ClosedUtc))
|
||||
{
|
||||
sum += p.PnlNetUsd;
|
||||
points.Add(new EquityPoint(p.ClosedUtc!.Value, sum));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
public static double MaxDrawdown(IReadOnlyList<EquityPoint> curve)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(curve);
|
||||
double peak = 0, dd = 0;
|
||||
foreach (EquityPoint p in curve)
|
||||
{
|
||||
peak = Math.Max(peak, p.CumulativeNetUsd);
|
||||
dd = Math.Max(dd, peak - p.CumulativeNetUsd);
|
||||
}
|
||||
|
||||
return dd;
|
||||
}
|
||||
|
||||
/// <summary>A small SVG of the equity curve, no library: a polyline in a viewBox, with the zero line.</summary>
|
||||
public static string EquitySvg(IReadOnlyList<EquityPoint> curve, int width = 640, int height = 160)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(curve);
|
||||
StringBuilder sb = new();
|
||||
sb.Append(CultureInfo.InvariantCulture, $"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" role=\"img\" aria-label=\"curva dell'equity realizzata\">");
|
||||
if (curve.Count >= 2)
|
||||
{
|
||||
double min = Math.Min(0, curve.Min(static p => p.CumulativeNetUsd));
|
||||
double max = Math.Max(0, curve.Max(static p => p.CumulativeNetUsd));
|
||||
double span = max - min < 1e-9 ? 1 : max - min;
|
||||
long t0 = curve[0].TimeUtc.Ticks, t1 = curve[^1].TimeUtc.Ticks;
|
||||
double tspan = t1 - t0 <= 0 ? 1 : t1 - t0;
|
||||
double zeroY = height - 8 - ((0 - min) / span * (height - 16));
|
||||
sb.Append(CultureInfo.InvariantCulture, $"<line x1=\"0\" y1=\"{zeroY:0.#}\" x2=\"{width}\" y2=\"{zeroY:0.#}\" stroke=\"currentColor\" stroke-opacity=\"0.25\" stroke-dasharray=\"4 4\"/>");
|
||||
sb.Append("<polyline fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" points=\"");
|
||||
foreach (EquityPoint p in curve)
|
||||
{
|
||||
double x = (p.TimeUtc.Ticks - t0) / tspan * (width - 4) + 2;
|
||||
double y = height - 8 - ((p.CumulativeNetUsd - min) / span * (height - 16));
|
||||
sb.Append(CultureInfo.InvariantCulture, $"{x:0.#},{y:0.#} ");
|
||||
}
|
||||
|
||||
sb.Append("\"/>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(CultureInfo.InvariantCulture, $"<text x=\"{width / 2}\" y=\"{height / 2}\" text-anchor=\"middle\" fill=\"currentColor\" fill-opacity=\"0.6\" font-size=\"13\">nessuna posizione chiusa</text>");
|
||||
}
|
||||
|
||||
sb.Append("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static string PositionsCsv(IEnumerable<PositionRecord> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine(PositionRecord.Header);
|
||||
foreach (PositionRecord r in rows)
|
||||
{
|
||||
sb.AppendLine(r.ToCsv());
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static string PeriodsCsv(IEnumerable<PeriodStats> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine(PeriodStats.Header);
|
||||
foreach (PeriodStats r in rows)
|
||||
{
|
||||
sb.AppendLine(r.ToCsv());
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>The order lines as the Storico page lists them: the last state of every order, newest first.</summary>
|
||||
public static List<OrderRecord> LatestOrders(IReadOnlyList<OrderRecord> lines)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lines);
|
||||
Dictionary<string, OrderRecord> last = new(StringComparer.Ordinal);
|
||||
Dictionary<string, DateTime> sent = new(StringComparer.Ordinal);
|
||||
foreach (OrderRecord o in lines)
|
||||
{
|
||||
if (!sent.ContainsKey(o.ClientRef))
|
||||
{
|
||||
sent[o.ClientRef] = o.Ts;
|
||||
}
|
||||
|
||||
last[o.ClientRef] = o;
|
||||
}
|
||||
|
||||
return [.. last.Values.OrderByDescending(o => sent[o.ClientRef])];
|
||||
}
|
||||
|
||||
public const string OrdersHeader = "ts;basket_id;basket;strumento;verso;leg;unita_richieste;unita_eseguite;prezzo_richiesto;prezzo_eseguito;slippage_pip;stato;esito;order_id;position_id;fee;motivazione";
|
||||
|
||||
public static string OrdersCsv(IEnumerable<OrderRecord> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine(OrdersHeader);
|
||||
foreach (OrderRecord o in rows)
|
||||
{
|
||||
sb.AppendLine(string.Join(';',
|
||||
[
|
||||
o.Ts.ToString("O", CultureInfo.InvariantCulture), o.BasketId, o.Basket, o.Symbol, o.IsBuy ? "long" : "short", o.Leg.ToString(),
|
||||
N(o.RequestedUnits), N(o.ExecutedUnits), N(o.RequestedPrice), N(o.FillRate), N(o.SlippagePips), o.Status, o.Resolution.ToString(),
|
||||
o.OrderId.ToString(CultureInfo.InvariantCulture), o.PositionId.ToString(CultureInfo.InvariantCulture), N(o.Fees), o.Motivazione.Replace(';', ',').Replace('\n', ' '),
|
||||
]));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
|
||||
static string N(double v) => double.IsFinite(v) ? v.ToString("0.######", CultureInfo.InvariantCulture) : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Core.Baskets.History;
|
||||
|
||||
/// <summary>
|
||||
/// One line of <c>data/ledger/orders.jsonl</c>: an order as sent, and every change of
|
||||
/// its state afterwards (one line per change, append-only). The last line for a
|
||||
/// <c>client_ref</c> is the order's current state; the first is what was asked.
|
||||
/// </summary>
|
||||
public sealed record OrderRecord(
|
||||
DateTime Ts,
|
||||
string RunId,
|
||||
string Mode,
|
||||
string Basket,
|
||||
string BasketId,
|
||||
string Symbol,
|
||||
long InstrumentId,
|
||||
bool IsBuy,
|
||||
OrderLeg Leg,
|
||||
double RequestedUnits,
|
||||
double ExecutedUnits,
|
||||
double RequestedPrice,
|
||||
double FillRate,
|
||||
double SlippagePips,
|
||||
string Status,
|
||||
int StatusId,
|
||||
OrderResolution Resolution,
|
||||
long OrderId,
|
||||
long PositionId,
|
||||
string ClientRef,
|
||||
double Fees,
|
||||
string Evento,
|
||||
string Motivazione)
|
||||
{
|
||||
/// <summary>The line for an order's current state. <paramref name="evento"/>: <c>inviato</c>, <c>stato</c>, <c>risolto</c>.</summary>
|
||||
public static OrderRecord From(TrackedOrder o, string runId, string evento, DateTime? ts = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(o);
|
||||
double pip = o.Symbol.Length >= 6 ? PipMath.Pip(o.Symbol) : 0.0001;
|
||||
double slippage = o.RequestedPrice > 0 && o.FillRate > 0 ? (o.IsBuy ? o.FillRate - o.RequestedPrice : o.RequestedPrice - o.FillRate) / pip : double.NaN;
|
||||
return new OrderRecord(ts ?? DateTime.UtcNow, runId, o.Mode, o.Basket, o.BasketId, o.Symbol, o.InstrumentId, o.IsBuy, o.Leg,
|
||||
o.RequestedUnits, o.ExecutedUnits, o.RequestedPrice, o.FillRate, slippage, o.LastStatus, o.StatusId, o.Resolution, o.OrderId, o.PositionId,
|
||||
o.ClientRef, o.Fees, evento, o.Motivazione.Length > 0 ? o.Motivazione : o.Error);
|
||||
}
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("ts", Ts.ToString("O", CultureInfo.InvariantCulture));
|
||||
w.WriteString("run_id", RunId);
|
||||
w.WriteString("mode", Mode);
|
||||
w.WriteString("basket", Basket);
|
||||
w.WriteString("basket_id", BasketId);
|
||||
w.WriteString("strumento", Symbol);
|
||||
w.WriteNumber("instrument_id", InstrumentId);
|
||||
w.WriteString("verso", IsBuy ? "long" : "short");
|
||||
w.WriteString("leg", Leg.ToString());
|
||||
w.WriteNumber("unita_richieste", Math.Round(RequestedUnits, 6));
|
||||
w.WriteNumber("unita_eseguite", Math.Round(ExecutedUnits, 6));
|
||||
Num(w, "prezzo_richiesto", RequestedPrice);
|
||||
Num(w, "prezzo_eseguito", FillRate);
|
||||
Num(w, "slippage_pip", SlippagePips);
|
||||
w.WriteString("stato", Status);
|
||||
w.WriteNumber("stato_id", StatusId);
|
||||
w.WriteString("esito", Resolution.ToString());
|
||||
w.WriteNumber("order_id", OrderId);
|
||||
w.WriteNumber("position_id", PositionId);
|
||||
w.WriteString("client_ref", ClientRef);
|
||||
Num(w, "fee", Fees);
|
||||
w.WriteString("evento", Evento);
|
||||
w.WriteString("motivazione", Motivazione);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
static void Num(Utf8JsonWriter w, string name, double v)
|
||||
{
|
||||
if (double.IsFinite(v) && v != 0)
|
||||
{
|
||||
w.WriteNumber(name, Math.Round(v, 8));
|
||||
}
|
||||
else if (double.IsFinite(v))
|
||||
{
|
||||
w.WriteNumber(name, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
w.WriteNull(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static OrderRecord? Parse(string line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(line);
|
||||
JsonElement r = doc.RootElement;
|
||||
return new OrderRecord(
|
||||
Time(r, "ts"), S(r, "run_id"), S(r, "mode"), S(r, "basket"), S(r, "basket_id"), S(r, "strumento"), L(r, "instrument_id"),
|
||||
S(r, "verso") == "long", Enum.TryParse(S(r, "leg"), out OrderLeg leg) ? leg : OrderLeg.A,
|
||||
D(r, "unita_richieste"), D(r, "unita_eseguite"), D(r, "prezzo_richiesto"), D(r, "prezzo_eseguito"), D(r, "slippage_pip"),
|
||||
S(r, "stato"), (int)L(r, "stato_id"), Enum.TryParse(S(r, "esito"), out OrderResolution res) ? res : OrderResolution.Pending,
|
||||
L(r, "order_id"), L(r, "position_id"), S(r, "client_ref"), D(r, "fee"), S(r, "evento"), S(r, "motivazione"));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
static string S(JsonElement e, string n) => e.TryGetProperty(n, out JsonElement v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty;
|
||||
static double D(JsonElement e, string n) => e.TryGetProperty(n, out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : double.NaN;
|
||||
static long L(JsonElement e, string n) => e.TryGetProperty(n, out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64() : 0;
|
||||
static DateTime Time(JsonElement e, string n) => DateTime.TryParse(S(e, n), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime t) ? t : default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Encelado.Core.Baskets;
|
||||
|
||||
/// <summary>
|
||||
/// One bot per data folder (§12 of the 5.0 plan, and a known issue since 4.0.0). The
|
||||
/// lock is the file itself, held open with no sharing for the life of the engine: a
|
||||
/// second instance cannot open it and stops with the first one's pid and start time; a
|
||||
/// crash releases it with the process, so there is never a stale lock to delete by hand.
|
||||
/// </summary>
|
||||
public sealed class InstanceLock : IDisposable
|
||||
{
|
||||
private readonly FileStream _stream;
|
||||
|
||||
private InstanceLock(FileStream stream, string path)
|
||||
{
|
||||
_stream = stream;
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>Takes the lock or throws <see cref="InvalidOperationException"/> naming the holder.</summary>
|
||||
public static InstanceLock Acquire(string path, string runId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
string? dir = System.IO.Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
FileStream stream;
|
||||
try
|
||||
{
|
||||
stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
throw new InvalidOperationException($"un'altra istanza di Encelado sta usando questa cartella dati ({Describe(path)}): fermala prima di avviarne un'altra", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
stream.SetLength(0);
|
||||
byte[] body = Encoding.UTF8.GetBytes(string.Create(CultureInfo.InvariantCulture,
|
||||
$"{{\"pid\":{Environment.ProcessId},\"runId\":\"{runId}\",\"sinceUtc\":\"{DateTime.UtcNow:O}\",\"machine\":\"{Environment.MachineName}\"}}"));
|
||||
stream.Write(body, 0, body.Length);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
stream.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new InstanceLock(stream, path);
|
||||
}
|
||||
|
||||
/// <summary>What the lock file says about its holder, for the message of a refused start.</summary>
|
||||
private static string Describe(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using FileStream s = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using StreamReader r = new(s, Encoding.UTF8);
|
||||
string text = r.ReadToEnd();
|
||||
return text.Length > 0 ? text : "contenuto non leggibile";
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return "file bloccato";
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_stream.Dispose();
|
||||
File.Delete(Path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The next start overwrites it.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Core.Baskets.Learning;
|
||||
|
||||
/// <summary>
|
||||
/// The feature vector of the meta-labeler, in a fixed order shared by training,
|
||||
/// prediction, drift monitoring and the docs. Every value comes from the ledger row
|
||||
/// written at the decision (anti look-ahead); missing values get the neutral value
|
||||
/// named here, never a value computed later.
|
||||
/// </summary>
|
||||
public static class LearningFeatures
|
||||
{
|
||||
public static readonly string[] Names =
|
||||
[
|
||||
"z", "abs_z", "rho_W", "rho_20", "halfLife", "atrA", "atrB", "sigmaX", "ewmaVolX", "regimeTrend",
|
||||
"costPips", "spreadA", "spreadB", "hourSin", "hourCos", "dow",
|
||||
"minutesToNextHigh", "minutesSinceLastHigh", "surpriseLast",
|
||||
"netSentDiff_1h", "netSentDiff_4h", "netSentDiff_24h", "hawkishDiff", "riskOff", "newsCount",
|
||||
"volRatio", "lastNOutcomes", "buy_cross",
|
||||
];
|
||||
|
||||
public static int Count => Names.Length;
|
||||
|
||||
public static double[] From(BasketContext ctx, BasketEvaluation e, bool buyCross)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(e);
|
||||
double volRatio = double.IsFinite(ctx.SigmaForecast) && double.IsFinite(ctx.SigmaAverage30d) && ctx.SigmaAverage30d > 0 ? ctx.SigmaForecast / ctx.SigmaAverage30d : 1;
|
||||
return
|
||||
[
|
||||
Or(e.Z, 0), Math.Abs(Or(e.Z, 0)), Or(e.RhoW, 0), Or(e.RhoShort, 0), Or(e.HalfLife, 96), Or(e.AtrPipsA, 0), Or(e.AtrPipsB, 0), Or(e.SigmaX, 0), Or(e.EwmaVolX, 0), Or(e.TrendStrength, 20),
|
||||
Or(e.CostPips, 0), Or(e.SpreadPipsA, 0), Or(e.SpreadPipsB, 0), e.HourSin, e.HourCos, e.DayOfWeek,
|
||||
Minutes(ctx.MinutesToNextHigh), Minutes(ctx.MinutesSinceLastHigh), Or(ctx.SurpriseLast, 0),
|
||||
Or(ctx.NetSentimentDiff1h, 0), Or(ctx.NetSentimentDiff4h, 0), Or(ctx.NetSentimentDiff24h, 0), Or(ctx.HawkishDiff, 0), Or(ctx.RiskOff, 0), ctx.NewsCount,
|
||||
volRatio, Or(ctx.LastOutcomes, 0.5), buyCross ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds the vector from a ledger line (an <c>ingresso</c> row of decisions.jsonl).</summary>
|
||||
public static double[]? FromLedgerLine(string jsonLine, out string basketId, out DateTime ts)
|
||||
{
|
||||
basketId = string.Empty;
|
||||
ts = default;
|
||||
using JsonDocument doc = JsonDocument.Parse(jsonLine);
|
||||
JsonElement r = doc.RootElement;
|
||||
if (!r.TryGetProperty("evento", out JsonElement ev) || ev.GetString() != "ingresso")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
basketId = r.TryGetProperty("basket_id", out JsonElement b) ? b.GetString() ?? string.Empty : string.Empty;
|
||||
if (r.TryGetProperty("ts", out JsonElement t) && DateTime.TryParse(t.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime parsed))
|
||||
{
|
||||
ts = parsed;
|
||||
}
|
||||
|
||||
double z = Num(r, "z", 0);
|
||||
double sf = Num(r, "sigmaForecast", double.NaN);
|
||||
double sa = Num(r, "sigmaAverage30d", double.NaN);
|
||||
double volRatio = double.IsFinite(sf) && double.IsFinite(sa) && sa > 0 ? sf / sa : 1;
|
||||
bool buy = r.TryGetProperty("buy_cross", out JsonElement bc) && bc.ValueKind == JsonValueKind.True;
|
||||
return
|
||||
[
|
||||
z, Math.Abs(z), Num(r, "rho_W", 0), Num(r, "rho_20", 0), Num(r, "halfLife", 96), Num(r, "atrA", 0), Num(r, "atrB", 0), Num(r, "sigmaX", 0), Num(r, "ewmaVolX", 0), Num(r, "regimeTrend", 20),
|
||||
Num(r, "costPips", 0), Num(r, "spreadA", 0), Num(r, "spreadB", 0), Num(r, "hourSin", 0), Num(r, "hourCos", 0), Num(r, "dow", 0),
|
||||
Minutes(Int(r, "minutesToNextHigh")), Minutes(Int(r, "minutesSinceLastHigh")), Num(r, "surpriseLast", 0),
|
||||
Num(r, "netSentDiff_1h", 0), Num(r, "netSentDiff_4h", 0), Num(r, "netSentDiff_24h", 0), Num(r, "hawkishDiff", 0), Num(r, "riskOff", 0), Num(r, "newsCount", 0),
|
||||
volRatio, Num(r, "lastNOutcomes", 0.5), buy ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
private static double Or(double v, double fallback) => double.IsFinite(v) ? v : fallback;
|
||||
|
||||
private static double Minutes(int v) => v == int.MaxValue || v < 0 ? 1440 : Math.Min(1440, v);
|
||||
|
||||
private static double Num(JsonElement r, string name, double fallback) =>
|
||||
r.TryGetProperty(name, out JsonElement p) && p.ValueKind == JsonValueKind.Number ? p.GetDouble() : fallback;
|
||||
|
||||
private static int Int(JsonElement r, string name) =>
|
||||
r.TryGetProperty(name, out JsonElement p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : int.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>One labelled basket for the learning stack.</summary>
|
||||
public sealed record LabelledBasket(string BasketId, DateTime OpenedUtc, DateTime ClosedUtc, double[] Features, int Label, double PnlNetUsd, string Basket, string Preset, double VolRatio);
|
||||
@@ -0,0 +1,429 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Encelado.Core.Ml;
|
||||
using Encelado.Core.Statistics;
|
||||
|
||||
namespace Encelado.Core.Baskets.Learning;
|
||||
|
||||
/// <summary>Everything the activation rule of §8.3 looks at, plus the calibration curve.</summary>
|
||||
public sealed record ModelReport(
|
||||
string Model,
|
||||
int Rows,
|
||||
double Auc,
|
||||
double AucLow,
|
||||
double AucHigh,
|
||||
double Brier,
|
||||
double LogLoss,
|
||||
IReadOnlyList<CalibrationBin> Calibration,
|
||||
double PnlAll,
|
||||
double PnlFiltered,
|
||||
int FilteredCount,
|
||||
double SharpeAll,
|
||||
double SharpeFiltered,
|
||||
double DsrFiltered,
|
||||
bool PassesActivation,
|
||||
string Motivazione)
|
||||
{
|
||||
public string Summary => string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Model}: {Rows} basket, AUC {Auc:F3} [{AucLow:F3}; {AucHigh:F3}], Brier {Brier:F3}, log-loss {LogLoss:F3}; P&L tutti {PnlAll:F0} USD, filtrati ({FilteredCount}) {PnlFiltered:F0} USD, DSR {DsrFiltered:F2} → {(PassesActivation ? "ATTIVABILE" : "resta in ombra")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk-forward evaluation of the meta-labelers on the ledger's labelled baskets.
|
||||
/// Level 1 is online, so its walk-forward is exact by construction: each prediction
|
||||
/// uses only the baskets closed before that basket opened. Level 2 is trained in
|
||||
/// chronological folds with a purge/embargo of 24 h around the test fold.
|
||||
/// </summary>
|
||||
public static class ModelEvaluator
|
||||
{
|
||||
public const int MinRowsForActivation = 300;
|
||||
public const double MinAuc = 0.55;
|
||||
public const double MinDsr = 0.95;
|
||||
public const double DeactivationAuc = 0.52;
|
||||
public const int BurnIn = 30;
|
||||
|
||||
/// <summary>Level 1: sequential predict-then-update over the baskets, ordered by open time; the update happens only when the basket has closed.</summary>
|
||||
public static (ModelReport Report, OnlineLogistic Model) EvaluateLogistic(IReadOnlyList<LabelledBasket> rows, double pMin, int seed = 42)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
List<LabelledBasket> ordered = [.. rows.OrderBy(static r => r.OpenedUtc)];
|
||||
OnlineLogistic model = new(LearningFeatures.Count);
|
||||
double[] p = new double[ordered.Count];
|
||||
|
||||
// A basket's outcome becomes available at its close: updates are applied in close order,
|
||||
// and a prediction for basket i uses only baskets closed before i opened.
|
||||
List<(DateTime ClosedUtc, int Index)> pending = [];
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
DateTime open = ordered[i].OpenedUtc;
|
||||
foreach ((DateTime closed, int idx) in pending.Where(x => x.ClosedUtc <= open).OrderBy(static x => x.ClosedUtc).ToList())
|
||||
{
|
||||
model.Update(ordered[idx].Features, ordered[idx].Label);
|
||||
pending.RemoveAll(x => x.Index == idx);
|
||||
}
|
||||
|
||||
p[i] = model.Predict(ordered[i].Features);
|
||||
pending.Add((ordered[i].ClosedUtc, i));
|
||||
}
|
||||
|
||||
foreach ((_, int idx) in pending.OrderBy(static x => x.ClosedUtc))
|
||||
{
|
||||
model.Update(ordered[idx].Features, ordered[idx].Label);
|
||||
}
|
||||
|
||||
return (Report("logistica", ordered, p, pMin, seed), model);
|
||||
}
|
||||
|
||||
/// <summary>Level 2: five chronological folds, purge/embargo of 24 h, early stopping on the tail of the training rows, five seeds averaged.</summary>
|
||||
public static (ModelReport Report, SmallMlp? Model) EvaluateMlp(IReadOnlyList<LabelledBasket> rows, double pMin, int folds = 5, int[]? seeds = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
seeds ??= [42, 43, 44, 45, 46];
|
||||
List<LabelledBasket> ordered = [.. rows.OrderBy(static r => r.OpenedUtc)];
|
||||
int n = ordered.Count;
|
||||
if (n < 60)
|
||||
{
|
||||
return (new ModelReport("mlp16", n, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, [], 0, 0, 0, 0, 0, 0, false, "meno di 60 basket: il challenger non è valutabile"), null);
|
||||
}
|
||||
|
||||
double[] p = new double[n];
|
||||
bool[] predicted = new bool[n];
|
||||
TimeSpan embargo = TimeSpan.FromHours(24);
|
||||
for (int k = 0; k < folds; k++)
|
||||
{
|
||||
int from = k * n / folds;
|
||||
int to = (k + 1) * n / folds;
|
||||
DateTime testStart = ordered[from].OpenedUtc;
|
||||
DateTime testEnd = ordered[to - 1].ClosedUtc;
|
||||
List<int> train = [];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (i >= from && i < to)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Purged: a training basket whose life overlaps the test window (± embargo) is dropped.
|
||||
if (ordered[i].ClosedUtc >= testStart - embargo && ordered[i].OpenedUtc <= testEnd + embargo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
train.Add(i);
|
||||
}
|
||||
|
||||
if (train.Count < 40)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double[][] preds = new double[seeds.Length][];
|
||||
for (int s = 0; s < seeds.Length; s++)
|
||||
{
|
||||
SmallMlp m = Train(ordered, train, seeds[s]);
|
||||
preds[s] = new double[to - from];
|
||||
for (int i = from; i < to; i++)
|
||||
{
|
||||
preds[s][i - from] = m.Predict(ordered[i].Features);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = from; i < to; i++)
|
||||
{
|
||||
double sum = 0;
|
||||
for (int s = 0; s < seeds.Length; s++)
|
||||
{
|
||||
sum += preds[s][i - from];
|
||||
}
|
||||
|
||||
p[i] = sum / seeds.Length;
|
||||
predicted[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
List<LabelledBasket> evaluated = [];
|
||||
List<double> scores = [];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (predicted[i])
|
||||
{
|
||||
evaluated.Add(ordered[i]);
|
||||
scores.Add(p[i]);
|
||||
}
|
||||
}
|
||||
|
||||
SmallMlp final = Train(ordered, [.. Enumerable.Range(0, n)], seeds[0]);
|
||||
return (Report("mlp16", evaluated, [.. scores], pMin, seeds[0], burnIn: 0), final);
|
||||
}
|
||||
|
||||
/// <summary>Mini-batch Adam with early stopping on the last 20 % of the training rows (chronological, purged by 24 h).</summary>
|
||||
public static SmallMlp Train(IReadOnlyList<LabelledBasket> ordered, IReadOnlyList<int> train, int seed, int maxEpochs = 300, int patience = 20)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ordered);
|
||||
ArgumentNullException.ThrowIfNull(train);
|
||||
int split = (int)(train.Count * 0.8);
|
||||
DateTime watchStart = ordered[train[Math.Min(split, train.Count - 1)]].OpenedUtc;
|
||||
List<int> fit = [.. train.Take(split).Where(i => ordered[i].ClosedUtc < watchStart - TimeSpan.FromHours(24))];
|
||||
List<int> watch = [.. train.Skip(split)];
|
||||
if (fit.Count < 20)
|
||||
{
|
||||
fit = [.. train];
|
||||
watch = [];
|
||||
}
|
||||
|
||||
SmallMlp model = new(LearningFeatures.Count, 16, seed);
|
||||
model.FitStandardizer([.. fit.Select(i => ordered[i].Features)]);
|
||||
double[][] zFit = new double[fit.Count][];
|
||||
int[] yFit = new int[fit.Count];
|
||||
for (int i = 0; i < fit.Count; i++)
|
||||
{
|
||||
zFit[i] = model.Standardize(ordered[fit[i]].Features, update: false);
|
||||
yFit[i] = ordered[fit[i]].Label;
|
||||
}
|
||||
|
||||
double[][] zWatch = [.. watch.Select(i => model.Standardize(ordered[i].Features, update: false))];
|
||||
int[] yWatch = [.. watch.Select(i => ordered[i].Label)];
|
||||
|
||||
Random rng = new(seed);
|
||||
SmallMlp best = model.Clone();
|
||||
double bestLoss = double.PositiveInfinity;
|
||||
int since = 0;
|
||||
int batch = Math.Clamp(fit.Count / 8, 8, 64);
|
||||
int[] order = [.. Enumerable.Range(0, fit.Count)];
|
||||
|
||||
for (int epoch = 0; epoch < maxEpochs; epoch++)
|
||||
{
|
||||
rng.Shuffle(order);
|
||||
for (int start = 0; start < order.Length; start += batch)
|
||||
{
|
||||
int len = Math.Min(batch, order.Length - start);
|
||||
double[][] zb = new double[len][];
|
||||
int[] yb = new int[len];
|
||||
for (int j = 0; j < len; j++)
|
||||
{
|
||||
zb[j] = zFit[order[start + j]];
|
||||
yb[j] = yFit[order[start + j]];
|
||||
}
|
||||
|
||||
model.TrainBatchRaw(zb, yb, 0.003);
|
||||
}
|
||||
|
||||
if (zWatch.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double loss = 0;
|
||||
for (int i = 0; i < zWatch.Length; i++)
|
||||
{
|
||||
double pr = Math.Clamp(model.ForwardRaw(zWatch[i]), 1e-7, 1 - 1e-7);
|
||||
loss -= (yWatch[i] * Math.Log(pr)) + ((1 - yWatch[i]) * Math.Log(1 - pr));
|
||||
}
|
||||
|
||||
loss /= zWatch.Length;
|
||||
if (loss < bestLoss - 1e-6)
|
||||
{
|
||||
bestLoss = loss;
|
||||
best.CopyWeightsFrom(model);
|
||||
since = 0;
|
||||
}
|
||||
else if (++since >= patience)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (zWatch.Length > 0)
|
||||
{
|
||||
model.CopyWeightsFrom(best);
|
||||
}
|
||||
|
||||
model.MarkSeen(fit.Count);
|
||||
return model;
|
||||
}
|
||||
|
||||
private static ModelReport Report(string name, IReadOnlyList<LabelledBasket> rows, double[] p, double pMin, int seed, int burnIn = BurnIn)
|
||||
{
|
||||
int n = rows.Count;
|
||||
List<double> scores = [];
|
||||
List<int> labels = [];
|
||||
List<double> pnlAll = [];
|
||||
List<double> pnlFiltered = [];
|
||||
for (int i = burnIn; i < n; i++)
|
||||
{
|
||||
scores.Add(p[i]);
|
||||
labels.Add(rows[i].Label);
|
||||
pnlAll.Add(rows[i].PnlNetUsd);
|
||||
pnlFiltered.Add(p[i] >= pMin ? rows[i].PnlNetUsd : 0);
|
||||
}
|
||||
|
||||
if (scores.Count < 10)
|
||||
{
|
||||
return new ModelReport(name, n, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, [], pnlAll.Sum(), pnlFiltered.Sum(), 0, 0, 0, 0, false, $"solo {n} basket: servono almeno {MinRowsForActivation} per valutare l'attivazione");
|
||||
}
|
||||
|
||||
double auc = Classification.Auc(scores, labels);
|
||||
(double lo, double hi) = BootstrapAuc(scores, labels, 1000, seed);
|
||||
double brier = Classification.Brier(scores, labels);
|
||||
double logLoss = Classification.LogLoss(scores, labels);
|
||||
List<CalibrationBin> calibration = Classification.Calibration(scores, labels, 10);
|
||||
|
||||
double sharpeAll = Performance.Sharpe(pnlAll);
|
||||
double sharpeFiltered = Performance.Sharpe(pnlFiltered);
|
||||
int filteredCount = pnlFiltered.Count(static v => v != 0);
|
||||
|
||||
// The filter is one choice among the thresholds that could have been tried: deflate accordingly.
|
||||
double variance = 0.05;
|
||||
double dsr = pnlFiltered.Count > 3
|
||||
? Performance.DeflatedSharpe(sharpeFiltered, pnlFiltered.Count, Performance.Skewness(pnlFiltered), Performance.Kurtosis(pnlFiltered), 3, variance)
|
||||
: double.NaN;
|
||||
|
||||
bool passes = n >= MinRowsForActivation && double.IsFinite(auc) && auc >= MinAuc && lo > 0.5 && pnlFiltered.Sum() > pnlAll.Sum() && dsr >= MinDsr;
|
||||
string why = passes
|
||||
? $"tutte le condizioni di §8.3 soddisfatte su {n} basket"
|
||||
: string.Join("; ", new[]
|
||||
{
|
||||
n < MinRowsForActivation ? $"{n} basket su {MinRowsForActivation} richiesti" : null,
|
||||
!(auc >= MinAuc) ? F($"AUC {auc:F3} sotto {MinAuc:F2}") : null,
|
||||
!(lo > 0.5) ? F($"intervallo bootstrap dell'AUC [{lo:F3}; {hi:F3}] include 0,50") : null,
|
||||
!(pnlFiltered.Sum() > pnlAll.Sum()) ? F($"il filtro non migliora il P&L ({pnlFiltered.Sum():F0} contro {pnlAll.Sum():F0} USD)") : null,
|
||||
!(dsr >= MinDsr) ? F($"DSR {dsr:F2} sotto {MinDsr:F2}") : null,
|
||||
}.Where(static s => s is not null));
|
||||
|
||||
return new ModelReport(name, n, auc, lo, hi, brier, logLoss, calibration, pnlAll.Sum(), pnlFiltered.Sum(), filteredCount, sharpeAll, sharpeFiltered, dsr, passes, why);
|
||||
}
|
||||
|
||||
/// <summary>Percentile bootstrap (2.5 %, 97.5 %) of the AUC.</summary>
|
||||
public static (double Low, double High) BootstrapAuc(IReadOnlyList<double> scores, IReadOnlyList<int> labels, int resamples, int seed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scores);
|
||||
ArgumentNullException.ThrowIfNull(labels);
|
||||
int n = scores.Count;
|
||||
if (n < 10)
|
||||
{
|
||||
return (double.NaN, double.NaN);
|
||||
}
|
||||
|
||||
Random rng = new(seed);
|
||||
List<double> aucs = new(resamples);
|
||||
double[] s = new double[n];
|
||||
int[] l = new int[n];
|
||||
for (int b = 0; b < resamples; b++)
|
||||
{
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int j = rng.Next(n);
|
||||
s[i] = scores[j];
|
||||
l[i] = labels[j];
|
||||
}
|
||||
|
||||
double a = Classification.Auc(s, l);
|
||||
if (double.IsFinite(a))
|
||||
{
|
||||
aucs.Add(a);
|
||||
}
|
||||
}
|
||||
|
||||
if (aucs.Count < 10)
|
||||
{
|
||||
return (double.NaN, double.NaN);
|
||||
}
|
||||
|
||||
aucs.Sort();
|
||||
return (aucs[(int)(0.025 * (aucs.Count - 1))], aucs[(int)(0.975 * (aucs.Count - 1))]);
|
||||
}
|
||||
|
||||
/// <summary>AUC over the last <paramref name="window"/> predictions of a running model: the deactivation guard.</summary>
|
||||
public static double RollingAuc(IReadOnlyList<(double P, int Label)> recent, int window = 100)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(recent);
|
||||
if (recent.Count < 20)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
var tail = recent.Skip(Math.Max(0, recent.Count - window)).ToList();
|
||||
return Classification.Auc([.. tail.Select(static t => t.P)], [.. tail.Select(static t => t.Label)]);
|
||||
}
|
||||
|
||||
private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>The calibration curve as text, for the insights file.</summary>
|
||||
public static string DescribeCalibration(IReadOnlyList<CalibrationBin> bins)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bins);
|
||||
StringBuilder sb = new();
|
||||
foreach (CalibrationBin b in bins)
|
||||
{
|
||||
if (b.Count > 0)
|
||||
{
|
||||
sb.Append(CultureInfo.InvariantCulture, $"[{b.Lower:0.0}-{b.Upper:0.0}] n={b.Count} previsto {b.MeanPredicted:0.00} osservato {b.ObservedRate:0.00}; ");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd(' ', ';');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Level 0 (§8.2): win rate and mean net P&L per bucket of the features that matter, straight from the ledger.</summary>
|
||||
public static class CalibrationTables
|
||||
{
|
||||
public sealed record Row(string Dimension, string Bucket, int Count, double WinRate, double MeanPnl, double TotalPnl)
|
||||
{
|
||||
public string ToCsv() => string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Dimension};{Bucket};{Count};{WinRate:0.000};{MeanPnl:0.00};{TotalPnl:0.00};{(Count < 20 ? "campione piccolo: indicativo" : WinRate >= 0.5 && MeanPnl > 0 ? "bucket in utile" : "bucket in perdita")}");
|
||||
}
|
||||
|
||||
public const string Header = "dimensione;bucket;n;win_rate;pnl_medio;pnl_totale;motivazione";
|
||||
|
||||
public static List<Row> Build(IReadOnlyList<LabelledBasket> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
List<Row> result = [];
|
||||
Add("abs_z", r => Bucket(Math.Abs(r.Features[0]), [1.5, 2.0, 2.5, 3.0]));
|
||||
Add("rho_W", r => Bucket(r.Features[2], [-0.9, -0.8, -0.7, -0.6, 0]));
|
||||
Add("ora_utc", r => $"{HourOf(r.Features[13], r.Features[14]):00}h");
|
||||
Add("giorno", r => ((DayOfWeek)(int)r.Features[15]).ToString());
|
||||
Add("minuti_evento", r => Bucket(r.Features[16], [60, 180, 720]));
|
||||
Add("sentiment_4h", r => Bucket(r.Features[20], [-0.2, -0.05, 0.05, 0.2]));
|
||||
Add("preset", r => r.Preset);
|
||||
Add("basket", r => r.Basket);
|
||||
Add("verso", r => r.Features[27] > 0.5 ? "compra il cross" : "vende il cross");
|
||||
return result;
|
||||
|
||||
void Add(string dimension, Func<LabelledBasket, string> bucket)
|
||||
{
|
||||
foreach (IGrouping<string, LabelledBasket> g in rows.GroupBy(bucket).OrderBy(static g => g.Key, StringComparer.Ordinal))
|
||||
{
|
||||
int n = g.Count();
|
||||
result.Add(new Row(dimension, g.Key, n, g.Average(static r => r.Label), g.Average(static r => r.PnlNetUsd), g.Sum(static r => r.PnlNetUsd)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string Bucket(double v, double[] edges)
|
||||
{
|
||||
for (int i = 0; i < edges.Length; i++)
|
||||
{
|
||||
if (v < edges[i])
|
||||
{
|
||||
return i == 0 ? F($"< {edges[0]}") : F($"{edges[i - 1]} – {edges[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return F($"≥ {edges[^1]}");
|
||||
}
|
||||
|
||||
private static int HourOf(double sin, double cos)
|
||||
{
|
||||
double angle = Math.Atan2(sin, cos);
|
||||
if (angle < 0)
|
||||
{
|
||||
angle += 2 * Math.PI;
|
||||
}
|
||||
|
||||
return (int)Math.Round(angle / (2 * Math.PI) * 24) % 24;
|
||||
}
|
||||
|
||||
private static string F(FormattableString s) => s.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Core.Baskets.Learning;
|
||||
|
||||
/// <summary>Anything that turns a feature row into a probability and learns from an outcome.</summary>
|
||||
public interface IModel
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
int InputCount { get; }
|
||||
|
||||
/// <summary>How many labelled rows the model has seen.</summary>
|
||||
int Seen { get; }
|
||||
|
||||
double Predict(double[] features);
|
||||
|
||||
void Update(double[] features, int label);
|
||||
|
||||
string ToJson();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Running mean and variance per feature with exponential forgetting, so the
|
||||
/// standardisation of a live row uses the recent distribution, never a global one
|
||||
/// computed on rows that came later (which would be a leak).
|
||||
/// </summary>
|
||||
public sealed class RollingStandardizer
|
||||
{
|
||||
private readonly double[] _mean;
|
||||
private readonly double[] _var;
|
||||
private readonly double _alpha;
|
||||
private long _n;
|
||||
|
||||
public RollingStandardizer(int size, double halfLifeRows = 200)
|
||||
{
|
||||
_mean = new double[size];
|
||||
_var = new double[size];
|
||||
_alpha = 1 - Math.Pow(0.5, 1.0 / Math.Max(1, halfLifeRows));
|
||||
}
|
||||
|
||||
public long Count => _n;
|
||||
|
||||
/// <summary>Standardises a row with the statistics seen so far, then updates them. NaN inputs become 0 (the mean).</summary>
|
||||
public double[] Transform(double[] x, bool update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(x);
|
||||
double[] z = new double[_mean.Length];
|
||||
for (int i = 0; i < _mean.Length; i++)
|
||||
{
|
||||
double v = i < x.Length && double.IsFinite(x[i]) ? x[i] : _mean[i];
|
||||
double sd = _n > 5 && _var[i] > 1e-12 ? Math.Sqrt(_var[i]) : 1;
|
||||
z[i] = Math.Clamp((v - _mean[i]) / sd, -5, 5);
|
||||
if (update)
|
||||
{
|
||||
if (_n == 0)
|
||||
{
|
||||
_mean[i] = v;
|
||||
_var[i] = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
double delta = v - _mean[i];
|
||||
_mean[i] += _alpha * delta;
|
||||
_var[i] = ((1 - _alpha) * _var[i]) + (_alpha * delta * delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (update)
|
||||
{
|
||||
_n++;
|
||||
}
|
||||
|
||||
return z;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the statistics from a whole batch at once, for a model trained offline: the
|
||||
/// rolling estimate needs a few hundred rows to settle, and standardising the first
|
||||
/// rows of a training set with statistics that are still zero clips them to ±5 and
|
||||
/// hands the network a distorted picture of the very rows it learns from. After the
|
||||
/// fit the rolling update continues from these values.
|
||||
/// </summary>
|
||||
public void Fit(IReadOnlyList<double[]> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < _mean.Length; i++)
|
||||
{
|
||||
double sum = 0;
|
||||
int count = 0;
|
||||
foreach (double[] r in rows)
|
||||
{
|
||||
if (i < r.Length && double.IsFinite(r[i]))
|
||||
{
|
||||
sum += r[i];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
double mean = count > 0 ? sum / count : 0;
|
||||
double sq = 0;
|
||||
foreach (double[] r in rows)
|
||||
{
|
||||
if (i < r.Length && double.IsFinite(r[i]))
|
||||
{
|
||||
sq += (r[i] - mean) * (r[i] - mean);
|
||||
}
|
||||
}
|
||||
|
||||
_mean[i] = mean;
|
||||
_var[i] = count > 1 ? sq / (count - 1) : 0;
|
||||
}
|
||||
|
||||
_n = rows.Count;
|
||||
}
|
||||
|
||||
public void Write(Utf8JsonWriter w)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(w);
|
||||
w.WriteStartObject("standardizer");
|
||||
w.WriteNumber("n", _n);
|
||||
w.WriteNumber("alpha", _alpha);
|
||||
Arr(w, "mean", _mean);
|
||||
Arr(w, "var", _var);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
public static RollingStandardizer Read(JsonElement e)
|
||||
{
|
||||
double[] mean = Nums(e.GetProperty("mean"));
|
||||
RollingStandardizer s = new(mean.Length);
|
||||
Array.Copy(mean, s._mean, mean.Length);
|
||||
Array.Copy(Nums(e.GetProperty("var")), s._var, mean.Length);
|
||||
s._n = e.GetProperty("n").GetInt64();
|
||||
return s;
|
||||
}
|
||||
|
||||
internal static void Arr(Utf8JsonWriter w, string name, double[] values)
|
||||
{
|
||||
w.WriteStartArray(name);
|
||||
foreach (double v in values)
|
||||
{
|
||||
w.WriteNumberValue(double.IsFinite(v) ? v : 0);
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
}
|
||||
|
||||
internal static double[] Nums(JsonElement e) => [.. e.EnumerateArray().Select(static x => x.GetDouble())];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Level 1 of the learning stack (§8.3): logistic regression trained online by SGD with
|
||||
/// L2, on rolling-standardised features. Starts in shadow mode: it predicts, the engine
|
||||
/// records the prediction, nobody acts on it until the walk-forward gates of §8.3 pass.
|
||||
/// </summary>
|
||||
public sealed class OnlineLogistic : IModel
|
||||
{
|
||||
private readonly double[] _w;
|
||||
private double _b;
|
||||
private readonly double _l2;
|
||||
private readonly double _lr0;
|
||||
private int _seen;
|
||||
private readonly RollingStandardizer _std;
|
||||
|
||||
public OnlineLogistic(int inputs, double learningRate = 0.01, double l2 = 1e-3, RollingStandardizer? standardizer = null)
|
||||
{
|
||||
_w = new double[inputs];
|
||||
_lr0 = learningRate;
|
||||
_l2 = l2;
|
||||
_std = standardizer ?? new RollingStandardizer(inputs);
|
||||
}
|
||||
|
||||
public string Name => "logistic";
|
||||
|
||||
public int InputCount => _w.Length;
|
||||
|
||||
public int Seen => _seen;
|
||||
|
||||
/// <summary>Learning rate with a slow decay: <c>lr₀ / (1 + n/1000)</c>.</summary>
|
||||
private double LearningRate => _lr0 / (1 + (_seen / 1000.0));
|
||||
|
||||
public double Predict(double[] features)
|
||||
{
|
||||
double[] z = _std.Transform(features, update: false);
|
||||
return Sigmoid(Dot(z) + _b);
|
||||
}
|
||||
|
||||
/// <summary>One SGD step on the logistic loss with L2; standardises with the statistics before this row.</summary>
|
||||
public void Update(double[] features, int label)
|
||||
{
|
||||
double[] z = _std.Transform(features, update: true);
|
||||
double p = Sigmoid(Dot(z) + _b);
|
||||
double g = p - label;
|
||||
double lr = LearningRate;
|
||||
for (int i = 0; i < _w.Length; i++)
|
||||
{
|
||||
_w[i] -= lr * ((g * z[i]) + (_l2 * _w[i]));
|
||||
}
|
||||
|
||||
_b -= lr * g;
|
||||
_seen++;
|
||||
}
|
||||
|
||||
private double Dot(double[] z)
|
||||
{
|
||||
double s = 0;
|
||||
for (int i = 0; i < _w.Length; i++)
|
||||
{
|
||||
s += _w[i] * z[i];
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
public static double Sigmoid(double x) => 1.0 / (1.0 + Math.Exp(-Math.Clamp(x, -40, 40)));
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("type", Name);
|
||||
w.WriteNumber("inputs", _w.Length);
|
||||
w.WriteNumber("seen", _seen);
|
||||
w.WriteNumber("lr0", _lr0);
|
||||
w.WriteNumber("l2", _l2);
|
||||
w.WriteNumber("bias", _b);
|
||||
RollingStandardizer.Arr(w, "weights", _w);
|
||||
_std.Write(w);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
public static OnlineLogistic FromJson(string json)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
JsonElement r = doc.RootElement;
|
||||
double[] w = RollingStandardizer.Nums(r.GetProperty("weights"));
|
||||
OnlineLogistic m = new(w.Length, r.GetProperty("lr0").GetDouble(), r.GetProperty("l2").GetDouble(), RollingStandardizer.Read(r.GetProperty("standardizer")));
|
||||
Array.Copy(w, m._w, w.Length);
|
||||
m._b = r.GetProperty("bias").GetDouble();
|
||||
m._seen = r.GetProperty("seen").GetInt32();
|
||||
return m;
|
||||
}
|
||||
|
||||
public string Describe() => string.Create(CultureInfo.InvariantCulture, $"logistica: {_seen} righe, |w| {Math.Sqrt(_w.Sum(static x => x * x)):0.000}, b {_b:+0.000;-0.000}");
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Core.Baskets.Learning;
|
||||
|
||||
/// <summary>
|
||||
/// Level 2 of the learning stack (§8.4): one hidden layer of 16 ReLU units, a sigmoid
|
||||
/// output, backprop and Adam written here, trained by mini-batch on a purged split
|
||||
/// with early stopping. Deliberately small: with a few hundred labelled baskets, more
|
||||
/// capacity is more ways to memorise them.
|
||||
/// </summary>
|
||||
public sealed class SmallMlp : IModel
|
||||
{
|
||||
private readonly int _inputs;
|
||||
private readonly int _hidden;
|
||||
private readonly double[] _w1;
|
||||
private readonly double[] _b1;
|
||||
private readonly double[] _w2;
|
||||
private double _b2;
|
||||
private readonly double[] _m1, _v1, _mb1, _vb1, _m2, _v2;
|
||||
private double _mb2, _vb2;
|
||||
private int _step;
|
||||
private int _seen;
|
||||
private readonly int _seed;
|
||||
private readonly RollingStandardizer _std;
|
||||
|
||||
public SmallMlp(int inputs, int hidden = 16, int seed = 42, RollingStandardizer? standardizer = null)
|
||||
{
|
||||
_inputs = inputs;
|
||||
_hidden = hidden;
|
||||
_seed = seed;
|
||||
Random rng = new(seed);
|
||||
_w1 = Init(hidden * inputs, inputs, rng);
|
||||
_b1 = new double[hidden];
|
||||
_w2 = Init(hidden, hidden, rng);
|
||||
_m1 = new double[_w1.Length]; _v1 = new double[_w1.Length]; _mb1 = new double[hidden]; _vb1 = new double[hidden];
|
||||
_m2 = new double[hidden]; _v2 = new double[hidden];
|
||||
_std = standardizer ?? new RollingStandardizer(inputs);
|
||||
}
|
||||
|
||||
public string Name => "mlp16";
|
||||
|
||||
public int InputCount => _inputs;
|
||||
|
||||
public int Seen => _seen;
|
||||
|
||||
public int Seed => _seed;
|
||||
|
||||
private static double[] Init(int size, int fanIn, Random rng)
|
||||
{
|
||||
double limit = Math.Sqrt(6.0 / fanIn);
|
||||
double[] w = new double[size];
|
||||
for (int i = 0; i < size; i++)
|
||||
{
|
||||
w[i] = ((rng.NextDouble() * 2) - 1) * limit;
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
public double Predict(double[] features) => Forward(_std.Transform(features, update: false), new double[_hidden]);
|
||||
|
||||
/// <summary>Raw forward pass on already standardised inputs (used by the gradient check).</summary>
|
||||
public double ForwardRaw(double[] z) => Forward(z, new double[_hidden]);
|
||||
|
||||
private double Forward(double[] z, double[] h)
|
||||
{
|
||||
for (int j = 0; j < _hidden; j++)
|
||||
{
|
||||
double s = _b1[j];
|
||||
int row = j * _inputs;
|
||||
for (int i = 0; i < _inputs; i++)
|
||||
{
|
||||
s += _w1[row + i] * z[i];
|
||||
}
|
||||
|
||||
h[j] = s > 0 ? s : 0;
|
||||
}
|
||||
|
||||
double o = _b2;
|
||||
for (int j = 0; j < _hidden; j++)
|
||||
{
|
||||
o += _w2[j] * h[j];
|
||||
}
|
||||
|
||||
return OnlineLogistic.Sigmoid(o);
|
||||
}
|
||||
|
||||
/// <summary>Online update: one Adam step on this row (used for the shadow model between retrains).</summary>
|
||||
public void Update(double[] features, int label)
|
||||
{
|
||||
double[] z = _std.Transform(features, update: true);
|
||||
TrainBatchRaw([z], [label], 0.001);
|
||||
_seen++;
|
||||
}
|
||||
|
||||
/// <summary>Standardises with the current statistics (updating them) — for the batch trainer.</summary>
|
||||
public double[] Standardize(double[] features, bool update) => _std.Transform(features, update);
|
||||
|
||||
/// <summary>Sets the standardiser from the whole training set before a batch fit.</summary>
|
||||
public void FitStandardizer(IReadOnlyList<double[]> rows) => _std.Fit(rows);
|
||||
|
||||
/// <summary>One Adam step on the mean logistic loss of the batch (inputs already standardised). Returns the loss.</summary>
|
||||
public double TrainBatchRaw(double[][] z, int[] labels, double learningRate, double l2 = 1e-4)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(z);
|
||||
ArgumentNullException.ThrowIfNull(labels);
|
||||
double[] g1 = new double[_w1.Length], gb1 = new double[_hidden], g2 = new double[_hidden];
|
||||
double gb2 = 0;
|
||||
double[] h = new double[_hidden];
|
||||
double loss = 0;
|
||||
|
||||
for (int n = 0; n < z.Length; n++)
|
||||
{
|
||||
double p = Forward(z[n], h);
|
||||
double y = labels[n];
|
||||
double pc = Math.Clamp(p, 1e-7, 1 - 1e-7);
|
||||
loss -= (y * Math.Log(pc)) + ((1 - y) * Math.Log(1 - pc));
|
||||
double dOut = p - y; // dL/dlogit for the logistic loss
|
||||
gb2 += dOut;
|
||||
for (int j = 0; j < _hidden; j++)
|
||||
{
|
||||
g2[j] += dOut * h[j];
|
||||
if (h[j] <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double dh = dOut * _w2[j];
|
||||
gb1[j] += dh;
|
||||
int row = j * _inputs;
|
||||
double[] zn = z[n];
|
||||
for (int i = 0; i < _inputs; i++)
|
||||
{
|
||||
g1[row + i] += dh * zn[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double scale = 1.0 / Math.Max(1, z.Length);
|
||||
for (int i = 0; i < _w1.Length; i++) { g1[i] = (g1[i] * scale) + (l2 * _w1[i]); }
|
||||
for (int j = 0; j < _hidden; j++) { gb1[j] *= scale; g2[j] = (g2[j] * scale) + (l2 * _w2[j]); }
|
||||
gb2 *= scale;
|
||||
|
||||
_step++;
|
||||
Adam(_w1, g1, _m1, _v1, learningRate);
|
||||
Adam(_b1, gb1, _mb1, _vb1, learningRate);
|
||||
Adam(_w2, g2, _m2, _v2, learningRate);
|
||||
AdamScalar(ref _b2, gb2, ref _mb2, ref _vb2, learningRate);
|
||||
return loss * scale;
|
||||
}
|
||||
|
||||
/// <summary>Numerical gradient of the loss for one row with respect to one weight of layer 1 (for the gradient-check test).</summary>
|
||||
public (double Analytic, double Numeric) GradientCheck(double[] z, int label, int weightIndex, double eps = 1e-5)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(z);
|
||||
double[] h = new double[_hidden];
|
||||
|
||||
// Analytic, straight from the backprop formulas.
|
||||
double p = Forward(z, h);
|
||||
double dOut = p - label;
|
||||
int j = weightIndex / _inputs;
|
||||
int i = weightIndex % _inputs;
|
||||
double analytic = h[j] > 0 ? dOut * _w2[j] * z[i] : 0;
|
||||
|
||||
// Numeric, by central difference on the loss.
|
||||
double original = _w1[weightIndex];
|
||||
_w1[weightIndex] = original + eps;
|
||||
double lp = Loss(Forward(z, h), label);
|
||||
_w1[weightIndex] = original - eps;
|
||||
double lm = Loss(Forward(z, h), label);
|
||||
_w1[weightIndex] = original;
|
||||
return (analytic, (lp - lm) / (2 * eps));
|
||||
|
||||
static double Loss(double p, int y)
|
||||
{
|
||||
double pc = Math.Clamp(p, 1e-9, 1 - 1e-9);
|
||||
return -((y * Math.Log(pc)) + ((1 - y) * Math.Log(1 - pc)));
|
||||
}
|
||||
}
|
||||
|
||||
private void Adam(double[] w, double[] g, double[] m, double[] v, double lr)
|
||||
{
|
||||
const double beta1 = 0.9, beta2 = 0.999, eps = 1e-8;
|
||||
double c1 = 1 - Math.Pow(beta1, _step);
|
||||
double c2 = 1 - Math.Pow(beta2, _step);
|
||||
for (int i = 0; i < w.Length; i++)
|
||||
{
|
||||
m[i] = (beta1 * m[i]) + ((1 - beta1) * g[i]);
|
||||
v[i] = (beta2 * v[i]) + ((1 - beta2) * g[i] * g[i]);
|
||||
w[i] -= lr * (m[i] / c1) / (Math.Sqrt(v[i] / c2) + eps);
|
||||
}
|
||||
}
|
||||
|
||||
private void AdamScalar(ref double w, double g, ref double m, ref double v, double lr)
|
||||
{
|
||||
const double beta1 = 0.9, beta2 = 0.999, eps = 1e-8;
|
||||
double c1 = 1 - Math.Pow(beta1, _step);
|
||||
double c2 = 1 - Math.Pow(beta2, _step);
|
||||
m = (beta1 * m) + ((1 - beta1) * g);
|
||||
v = (beta2 * v) + ((1 - beta2) * g * g);
|
||||
w -= lr * (m / c1) / (Math.Sqrt(v / c2) + eps);
|
||||
}
|
||||
|
||||
public SmallMlp Clone()
|
||||
{
|
||||
SmallMlp c = new(_inputs, _hidden, _seed, _std);
|
||||
Array.Copy(_w1, c._w1, _w1.Length);
|
||||
Array.Copy(_b1, c._b1, _b1.Length);
|
||||
Array.Copy(_w2, c._w2, _w2.Length);
|
||||
c._b2 = _b2;
|
||||
c._seen = _seen;
|
||||
return c;
|
||||
}
|
||||
|
||||
public void CopyWeightsFrom(SmallMlp other)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(other);
|
||||
Array.Copy(other._w1, _w1, _w1.Length);
|
||||
Array.Copy(other._b1, _b1, _b1.Length);
|
||||
Array.Copy(other._w2, _w2, _w2.Length);
|
||||
_b2 = other._b2;
|
||||
}
|
||||
|
||||
public void MarkSeen(int rows) => _seen = rows;
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("type", Name);
|
||||
w.WriteNumber("inputs", _inputs);
|
||||
w.WriteNumber("hidden", _hidden);
|
||||
w.WriteNumber("seed", _seed);
|
||||
w.WriteNumber("seen", _seen);
|
||||
w.WriteNumber("b2", _b2);
|
||||
RollingStandardizer.Arr(w, "w1", _w1);
|
||||
RollingStandardizer.Arr(w, "b1", _b1);
|
||||
RollingStandardizer.Arr(w, "w2", _w2);
|
||||
_std.Write(w);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
public static SmallMlp FromJson(string json)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
JsonElement r = doc.RootElement;
|
||||
SmallMlp m = new(r.GetProperty("inputs").GetInt32(), r.GetProperty("hidden").GetInt32(), r.GetProperty("seed").GetInt32(), RollingStandardizer.Read(r.GetProperty("standardizer")));
|
||||
Array.Copy(RollingStandardizer.Nums(r.GetProperty("w1")), m._w1, m._w1.Length);
|
||||
Array.Copy(RollingStandardizer.Nums(r.GetProperty("b1")), m._b1, m._b1.Length);
|
||||
Array.Copy(RollingStandardizer.Nums(r.GetProperty("w2")), m._w2, m._w2.Length);
|
||||
m._b2 = r.GetProperty("b2").GetDouble();
|
||||
m._seen = r.GetProperty("seen").GetInt32();
|
||||
return m;
|
||||
}
|
||||
|
||||
public string Describe() => string.Create(CultureInfo.InvariantCulture, $"MLP {_inputs}→{_hidden}→1, seme {_seed}, {_seen} righe");
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Encelado.Core.Baskets.Learning;
|
||||
|
||||
/// <summary>
|
||||
/// Level 3 (§8.6): Thompson sampling with Beta posteriors, one arm per (preset ×
|
||||
/// volatility tercile). Reward 1 when a basket closes with a net profit, 0 otherwise.
|
||||
/// It proposes; the engine applies the proposal only in paper/demo, and never in live
|
||||
/// until the forward test says so. Exploration is capped at 10 % of the choices.
|
||||
/// <para>
|
||||
/// Why a bandit and not deep RL: with a few hundred episodes a year, three arms per
|
||||
/// context and a binary reward are what the data can tell apart; a value network would
|
||||
/// fit the noise long before it saw a regime twice. See docs/ML_AND_LEARNING.md.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ThompsonBandit
|
||||
{
|
||||
private readonly double[,] _alpha;
|
||||
private readonly double[,] _beta;
|
||||
private readonly int _contexts;
|
||||
private readonly Random _rng;
|
||||
private int _choices;
|
||||
private int _explorations;
|
||||
|
||||
public ThompsonBandit(int contexts = 3, int seed = 42)
|
||||
{
|
||||
_contexts = contexts;
|
||||
_alpha = new double[contexts, 3];
|
||||
_beta = new double[contexts, 3];
|
||||
for (int c = 0; c < contexts; c++)
|
||||
{
|
||||
for (int a = 0; a < 3; a++)
|
||||
{
|
||||
_alpha[c, a] = 1;
|
||||
_beta[c, a] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
_rng = new Random(seed);
|
||||
}
|
||||
|
||||
public double ExplorationCap { get; init; } = 0.10;
|
||||
|
||||
public int Choices => _choices;
|
||||
|
||||
/// <summary>Proposes a preset for the context. Greedy on the posterior mean, except for a capped share of sampled (exploratory) choices.</summary>
|
||||
public (PresetName Preset, bool Explored, double[] Means) Propose(int context)
|
||||
{
|
||||
context = Math.Clamp(context, 0, _contexts - 1);
|
||||
double[] means = new double[3];
|
||||
for (int a = 0; a < 3; a++)
|
||||
{
|
||||
means[a] = _alpha[context, a] / (_alpha[context, a] + _beta[context, a]);
|
||||
}
|
||||
|
||||
bool explore = _choices == 0 || (_explorations + 1.0) / (_choices + 1.0) <= ExplorationCap;
|
||||
int best = 0;
|
||||
if (explore)
|
||||
{
|
||||
double[] samples = new double[3];
|
||||
for (int a = 0; a < 3; a++)
|
||||
{
|
||||
samples[a] = SampleBeta(_alpha[context, a], _beta[context, a]);
|
||||
}
|
||||
|
||||
best = ArgMax(samples);
|
||||
if (best != ArgMax(means))
|
||||
{
|
||||
_explorations++;
|
||||
}
|
||||
else
|
||||
{
|
||||
explore = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
best = ArgMax(means);
|
||||
}
|
||||
|
||||
_choices++;
|
||||
return ((PresetName)best, explore, means);
|
||||
}
|
||||
|
||||
public void Reward(int context, PresetName preset, bool success)
|
||||
{
|
||||
context = Math.Clamp(context, 0, _contexts - 1);
|
||||
int a = (int)preset;
|
||||
if (success)
|
||||
{
|
||||
_alpha[context, a] += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_beta[context, a] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Volatility tercile of the current forecast against the recent history: 0 low, 1 mid, 2 high.</summary>
|
||||
public static int VolatilityContext(double sigmaForecast, IReadOnlyList<double> history)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(history);
|
||||
if (!double.IsFinite(sigmaForecast) || history.Count < 9)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
double[] sorted = [.. history.Where(double.IsFinite).Order()];
|
||||
if (sorted.Length < 9)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
double t1 = sorted[sorted.Length / 3];
|
||||
double t2 = sorted[2 * sorted.Length / 3];
|
||||
return sigmaForecast < t1 ? 0 : sigmaForecast < t2 ? 1 : 2;
|
||||
}
|
||||
|
||||
private static int ArgMax(double[] v)
|
||||
{
|
||||
int best = 0;
|
||||
for (int i = 1; i < v.Length; i++)
|
||||
{
|
||||
if (v[i] > v[best])
|
||||
{
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Beta(a, b) by two Gamma draws (Marsaglia–Tsang).</summary>
|
||||
private double SampleBeta(double a, double b)
|
||||
{
|
||||
double x = SampleGamma(a);
|
||||
double y = SampleGamma(b);
|
||||
return x + y > 0 ? x / (x + y) : 0.5;
|
||||
}
|
||||
|
||||
private double SampleGamma(double shape)
|
||||
{
|
||||
if (shape < 1)
|
||||
{
|
||||
return SampleGamma(shape + 1) * Math.Pow(_rng.NextDouble(), 1 / shape);
|
||||
}
|
||||
|
||||
double d = shape - (1.0 / 3);
|
||||
double c = 1 / Math.Sqrt(9 * d);
|
||||
while (true)
|
||||
{
|
||||
double x = Normal();
|
||||
double v = 1 + (c * x);
|
||||
if (v <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
v = v * v * v;
|
||||
double u = _rng.NextDouble();
|
||||
if (u < 1 - (0.0331 * x * x * x * x) || Math.Log(u) < (0.5 * x * x) + (d * (1 - v + Math.Log(v))))
|
||||
{
|
||||
return d * v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double Normal()
|
||||
{
|
||||
double u1 = 1 - _rng.NextDouble();
|
||||
double u2 = _rng.NextDouble();
|
||||
return Math.Sqrt(-2 * Math.Log(u1)) * Math.Cos(2 * Math.PI * u2);
|
||||
}
|
||||
|
||||
public string Describe(int context)
|
||||
{
|
||||
context = Math.Clamp(context, 0, _contexts - 1);
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"contesto vol {context}: CONS {_alpha[context, 0] - 1:0}/{_alpha[context, 0] + _beta[context, 0] - 2:0}, MOD {_alpha[context, 1] - 1:0}/{_alpha[context, 1] + _beta[context, 1] - 2:0}, AGG {_alpha[context, 2] - 1:0}/{_alpha[context, 2] + _beta[context, 2] - 2:0}; {_choices} scelte, {_explorations} esplorative");
|
||||
}
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
using (Utf8JsonWriter w = new(ms, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteNumber("contexts", _contexts);
|
||||
w.WriteNumber("choices", _choices);
|
||||
w.WriteNumber("explorations", _explorations);
|
||||
w.WriteStartArray("arms");
|
||||
for (int c = 0; c < _contexts; c++)
|
||||
{
|
||||
for (int a = 0; a < 3; a++)
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteNumber("context", c);
|
||||
w.WriteNumber("arm", a);
|
||||
w.WriteNumber("alpha", _alpha[c, a]);
|
||||
w.WriteNumber("beta", _beta[c, a]);
|
||||
w.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteEndArray();
|
||||
w.WriteEndObject();
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
public static ThompsonBandit FromJson(string json, int seed = 42)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
JsonElement r = doc.RootElement;
|
||||
ThompsonBandit b = new(r.GetProperty("contexts").GetInt32(), seed)
|
||||
{
|
||||
_choices = r.GetProperty("choices").GetInt32(),
|
||||
_explorations = r.GetProperty("explorations").GetInt32(),
|
||||
};
|
||||
foreach (JsonElement arm in r.GetProperty("arms").EnumerateArray())
|
||||
{
|
||||
int c = arm.GetProperty("context").GetInt32();
|
||||
int a = arm.GetProperty("arm").GetInt32();
|
||||
b._alpha[c, a] = arm.GetProperty("alpha").GetDouble();
|
||||
b._beta[c, a] = arm.GetProperty("beta").GetDouble();
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Globalization;
|
||||
using Encelado.Core.Statistics;
|
||||
|
||||
namespace Encelado.Core.Baskets.Learning;
|
||||
|
||||
/// <summary>
|
||||
/// "Forward thinking" for volatility (§8.5): the next 1-4 hours' realised volatility of
|
||||
/// the synthetic cross, by an EWMA baseline and by HAR-RV on 15-minute realised
|
||||
/// variance — <c>RV_{t+1} = β₀ + β_d·RV_t + β_w·mean(RV over 4 h) + β_m·mean(RV over 24 h)</c>,
|
||||
/// refitted by OLS once a day. Both are scored by squared error on a rolling window and
|
||||
/// the better one is used.
|
||||
/// </summary>
|
||||
public sealed class VolForecaster
|
||||
{
|
||||
private readonly int _horizonBars;
|
||||
private readonly List<double> _rv = [];
|
||||
private readonly List<(double Ewma, double Har, double Realised)> _scores = [];
|
||||
private double[]? _beta;
|
||||
private DateTime _lastFitUtc = DateTime.MinValue;
|
||||
private double _ewmaVar;
|
||||
private bool _ewmaSeeded;
|
||||
|
||||
public VolForecaster(int horizonBars = 8, double ewmaSpan = 100)
|
||||
{
|
||||
_horizonBars = horizonBars;
|
||||
EwmaAlpha = 2.0 / (ewmaSpan + 1);
|
||||
}
|
||||
|
||||
public double EwmaAlpha { get; }
|
||||
|
||||
public int Count => _rv.Count;
|
||||
|
||||
public string ActiveModel { get; private set; } = "EWMA";
|
||||
|
||||
/// <summary>Feeds one bar's squared return (the 15-minute realised variance).</summary>
|
||||
public void Observe(double logReturn, DateTime nowUtc)
|
||||
{
|
||||
double r2 = logReturn * logReturn;
|
||||
_rv.Add(r2);
|
||||
if (_rv.Count > 20_000)
|
||||
{
|
||||
_rv.RemoveAt(0);
|
||||
}
|
||||
|
||||
if (!_ewmaSeeded)
|
||||
{
|
||||
_ewmaVar = r2;
|
||||
_ewmaSeeded = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ewmaVar = ((1 - EwmaAlpha) * _ewmaVar) + (EwmaAlpha * r2);
|
||||
}
|
||||
|
||||
// Score the forecasts made `horizon` bars ago against what happened.
|
||||
if (_pending.Count > 0 && _rv.Count - _pending.Peek().At >= _horizonBars)
|
||||
{
|
||||
(int at, double e, double h) = _pending.Dequeue();
|
||||
double realised = 0;
|
||||
for (int i = at; i < Math.Min(_rv.Count, at + _horizonBars); i++)
|
||||
{
|
||||
realised += _rv[i];
|
||||
}
|
||||
|
||||
realised = Math.Sqrt(realised / _horizonBars);
|
||||
_scores.Add((e, h, realised));
|
||||
if (_scores.Count > 500)
|
||||
{
|
||||
_scores.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (nowUtc - _lastFitUtc >= TimeSpan.FromDays(1) && _rv.Count >= 96 * 5)
|
||||
{
|
||||
Fit();
|
||||
_lastFitUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Queue<(int At, double Ewma, double Har)> _pending = new();
|
||||
|
||||
/// <summary>Per-bar volatility forecast for the next horizon (in return units per bar).</summary>
|
||||
public double Forecast()
|
||||
{
|
||||
if (!_ewmaSeeded)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double ewma = Math.Sqrt(_ewmaVar);
|
||||
double har = HarForecast();
|
||||
_pending.Enqueue((_rv.Count, ewma, double.IsFinite(har) ? har : ewma));
|
||||
|
||||
ActiveModel = ChooseModel();
|
||||
return ActiveModel == "HAR" && double.IsFinite(har) ? har : ewma;
|
||||
}
|
||||
|
||||
private string ChooseModel()
|
||||
{
|
||||
if (_scores.Count < 50 || _beta is null)
|
||||
{
|
||||
return "EWMA";
|
||||
}
|
||||
|
||||
double e = 0, h = 0;
|
||||
foreach ((double ewma, double har, double realised) in _scores)
|
||||
{
|
||||
e += (ewma - realised) * (ewma - realised);
|
||||
h += (har - realised) * (har - realised);
|
||||
}
|
||||
|
||||
return h < e ? "HAR" : "EWMA";
|
||||
}
|
||||
|
||||
private double HarForecast()
|
||||
{
|
||||
if (_beta is null || _rv.Count < 96)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
(double d, double w, double m) = Components(_rv.Count - 1);
|
||||
double variance = _beta[0] + (_beta[1] * d) + (_beta[2] * w) + (_beta[3] * m);
|
||||
return variance > 0 ? Math.Sqrt(variance) : double.NaN;
|
||||
}
|
||||
|
||||
private (double D, double W, double M) Components(int at)
|
||||
{
|
||||
double d = _rv[at];
|
||||
double w = Mean(at - 15, at);
|
||||
double m = Mean(at - 95, at);
|
||||
return (d, w, m);
|
||||
}
|
||||
|
||||
private double Mean(int from, int to)
|
||||
{
|
||||
from = Math.Max(0, from);
|
||||
double s = 0;
|
||||
int n = 0;
|
||||
for (int i = from; i <= to; i++)
|
||||
{
|
||||
s += _rv[i];
|
||||
n++;
|
||||
}
|
||||
|
||||
return n > 0 ? s / n : 0;
|
||||
}
|
||||
|
||||
/// <summary>OLS of the next-bar RV on the daily/weekly/monthly components (here 15 min / 4 h / 24 h).</summary>
|
||||
private void Fit()
|
||||
{
|
||||
int n = Math.Min(_rv.Count - 97, 96 * 60);
|
||||
if (n < 200)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<double[]> x = new(n);
|
||||
List<double> y = new(n);
|
||||
int start = _rv.Count - 1 - n;
|
||||
for (int t = Math.Max(96, start); t < _rv.Count - 1; t++)
|
||||
{
|
||||
(double d, double w, double m) = Components(t);
|
||||
x.Add([1, d, w, m]);
|
||||
y.Add(_rv[t + 1]);
|
||||
}
|
||||
|
||||
OlsFit? fit = Ols.Fit(x, y);
|
||||
if (fit is not null)
|
||||
{
|
||||
_beta = fit.Coefficients;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Average forecast over the last <paramref name="days"/> days of bars (for the zIn scaling of §5.8).</summary>
|
||||
public double AverageVolatility(int days)
|
||||
{
|
||||
int n = Math.Min(_rv.Count, days * 96);
|
||||
if (n < 96)
|
||||
{
|
||||
return double.NaN;
|
||||
}
|
||||
|
||||
double s = 0;
|
||||
for (int i = _rv.Count - n; i < _rv.Count; i++)
|
||||
{
|
||||
s += _rv[i];
|
||||
}
|
||||
|
||||
return Math.Sqrt(s / n);
|
||||
}
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
if (_scores.Count == 0)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"EWMA σ {Math.Sqrt(_ewmaVar):0.0000} per barra, HAR non ancora confrontabile ({_rv.Count} barre)");
|
||||
}
|
||||
|
||||
double e = 0, h = 0;
|
||||
foreach ((double ewma, double har, double realised) in _scores)
|
||||
{
|
||||
e += (ewma - realised) * (ewma - realised);
|
||||
h += (har - realised) * (har - realised);
|
||||
}
|
||||
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"{ActiveModel} in uso; errore quadratico medio su {_scores.Count} previsioni: EWMA {Math.Sqrt(e / _scores.Count):0.00000}, HAR {Math.Sqrt(h / _scores.Count):0.00000}; σ prevista {Forecast():0.0000} per barra");
|
||||
}
|
||||
}
|
||||
|
||||