diff --git a/Encelado/.vscode/extensions.json b/Encelado/.vscode/extensions.json index 8e23111..4bdca3b 100644 --- a/Encelado/.vscode/extensions.json +++ b/Encelado/.vscode/extensions.json @@ -4,9 +4,9 @@ "ms-dotnettools.csharp", "ms-dotnettools.csdevkit", - // Colora installer\Encelado.iss e ne conosce direttive e costanti. Serve solo a - // leggere e scrivere quel file: l'installer si costruisce con il task - // "installer", che non dipende da nessuna estensione. - "idleberg.innosetup" + // 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" ] } diff --git a/Encelado/.vscode/launch.json b/Encelado/.vscode/launch.json index cc95b9f..493f479 100644 --- a/Encelado/.vscode/launch.json +++ b/Encelado/.vscode/launch.json @@ -1,17 +1,55 @@ { - // One way to launch, on purpose. Encelado is a desktop application: F5 here starts - // the same window you get by double-clicking Encelado.exe. Everything else — login, - // start/stop, backtest, settings — lives inside that window. + // F5 avvia il server (motore + interfaccia web) come processo locale, fuori dal + // container, con la cartella dati in Documenti\Encelado. Il browser si apre da solo + // appena Kestrel è in ascolto. "Encelado (campione)" serve l'interfaccia con dati + // finti, senza chiavi né mercato: è quello che si usa per lavorare sulle pagine. "version": "0.2.0", "configurations": [ { - "name": "Encelado", + "name": "Encelado (server)", "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows/Encelado.exe", - "cwd": "${workspaceFolder}/src/Encelado.Bot/bin/Debug/net10.0-windows", - "console": "internalConsole", + "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", + "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 } ] diff --git a/Encelado/.vscode/tasks.json b/Encelado/.vscode/tasks.json index c5a3d7f..3ab954a 100644 --- a/Encelado/.vscode/tasks.json +++ b/Encelado/.vscode/tasks.json @@ -3,7 +3,8 @@ // 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. + // È la stessa impostazione di Mimante/AutoBidder, con l'immagine Docker al + // posto dell'installatore. "version": "2.0.0", "tasks": [ { @@ -39,7 +40,7 @@ }, { "label": "backtest", - "detail": "Ricerca sui basket: ticks (tick MT5 → barre), baskets (griglia, PSR/DSR, PBO, walk-forward), falsify (test di falsificazione).", + "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": [ @@ -55,14 +56,14 @@ "problemMatcher": [] }, { - "label": "crea installatore", - "detail": "Verifica, pubblica ed esegue Inno Setup: bin/installer/Encelado--setup.exe. Crea il tag a pacchetto pronto. Non tocca Gitea.", + "label": "immagine docker", + "detail": "Verifica e costruisce l'immagine 192.168.30.23:3000/alby96/encelado: (e :latest) con la Dockerfile alla radice. Non pubblica niente.", "type": "process", "command": "dotnet", "args": [ "msbuild", "${workspaceFolder}/build/Release.proj", - "-t:Pacchetto", + "-t:Docker", "-p:Versione=${input:versione}", "-nologo", "-v:m" @@ -71,8 +72,8 @@ "problemMatcher": "$msCompile" }, { - "label": "crea installatore (senza rieseguire i test)", - "detail": "Solo pubblicazione e Inno Setup. Da usare quando i test sono appena passati.", + "label": "pacchetto", + "detail": "Verifica, pubblica la cartella portabile (bin/installer/Encelado__portabile.zip) e costruisce l'immagine. Crea il tag a pacchetto pronto. Non tocca Gitea.", "type": "process", "command": "dotnet", "args": [ @@ -80,7 +81,6 @@ "${workspaceFolder}/build/Release.proj", "-t:Pacchetto", "-p:Versione=${input:versione}", - "-p:SaltaVerifica=true", "-nologo", "-v:m" ], @@ -89,7 +89,7 @@ }, { "label": "rilascia su Gitea", - "detail": "Verifica, pubblica, installatore, tag e release su Gitea con i file allegati. La versione viene dal tag su HEAD. Richiede build/gitea.json.", + "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": [ @@ -110,13 +110,22 @@ }, "presentation": { "reveal": "always", "panel": "dedicated" }, "problemMatcher": "$msCompile" + }, + { + "label": "docker compose up", + "detail": "Avvia il container dalla docker-compose.yml alla radice (immagine locale, cartelle ./deploy/local/config e ./deploy/local/data).", + "type": "shell", + "command": "docker compose up --build", + "options": { "cwd": "${workspaceFolder}" }, + "presentation": { "reveal": "always", "panel": "dedicated" }, + "problemMatcher": [] } ], "inputs": [ { "id": "versione", "type": "promptString", - "description": "Versione — lascia vuoto se hai già taggato (git tag v3.3.0), o per la minor successiva", + "description": "Versione — lascia vuoto se hai già taggato (git tag v5.0.0), o per la minor successiva", "default": "" }, { @@ -128,14 +137,14 @@ { "id": "dati", "type": "promptString", - "description": "Cartella dei dati: data/market (barre M15) per baskets e falsify, la cartella dei tick MT5 per ticks", + "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": "Cosa misurare", - "options": ["baskets", "falsify", "ticks"], + "description": "Comando del backtest", + "options": ["baskets", "falsify", "ticks", "learn"], "default": "baskets" } ] diff --git a/Encelado/Directory.Build.props b/Encelado/Directory.Build.props index 240668b..9cf1993 100644 --- a/Encelado/Directory.Build.props +++ b/Encelado/Directory.Build.props @@ -13,12 +13,12 @@ false Encelado Encelado - - 4.0.0 + + 5.0.0 - net10.0-windows - WinExe - true - - - enable - enable - latest - - - false - false - Encelado.Bot - Encelado - Assets\encelado.ico - true - false - false - false - - true - - - - - - - - - - - - - - - - - - - - diff --git a/Encelado/src/Encelado.Bot/GlobalUsings.cs b/Encelado/src/Encelado.Bot/GlobalUsings.cs deleted file mode 100644 index 1de43a9..0000000 --- a/Encelado/src/Encelado.Bot/GlobalUsings.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Declared as a real source file rather than . The temporary project -// MSBuild generates to compile XAML markup does not inherit that property, so the -// markup pass would otherwise fail on types the rest of the project takes for granted. -global using System; -global using System.Collections.Generic; -global using System.IO; -global using System.Net.Http; -global using System.Linq; -global using System.Threading; -global using System.Threading.Tasks; diff --git a/Encelado/src/Encelado.Bot/MainWindow.xaml b/Encelado/src/Encelado.Bot/MainWindow.xaml deleted file mode 100644 index b1a6456..0000000 --- a/Encelado/src/Encelado.Bot/MainWindow.xaml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ` : ""}`; + }; + const basketCard = (b) => `
+
${esc(b.name)} (${esc(b.cross)})
${esc(b.stateLabel)} +
z${fmtSigned(b.z)}ρ${fmtNum(b.rho)}Pips${b.isOpen ? fmtSigned(b.pips, 1) : "—"}P&L${b.isOpen ? money(b.pnlUsd, true) : "—"}
+ ${b.isOpen ? `
` : ""}
`; + const render = () => { + const s = state.snapshot; if (!s) return; + $("env-chip").textContent = s.mode; $("env-chip").className = "chip env " + s.environmentKind; + stateChip(s); + const running = s.state === "running" || s.state === "starting"; + const power = $("power"); power.textContent = running ? "FERMA" : "AVVIA"; power.classList.toggle("running", running); power.disabled = s.state === "starting" || s.state === "stopping" || state.busy; + $("kill").disabled = s.state !== "running"; + $("preset").value = s.preset === "—" ? "MODERATE" : s.preset; $("preset").disabled = s.state !== "running"; + if ($("currency").value !== state.currency) $("currency").value = state.currency; + banner(s); + $("k-equity").textContent = money(s.equity); $("k-equity").title = moneyTitle(s.equity); $("k-equity-sub").textContent = `saldo ${money(s.balance)}`; + $("k-today").textContent = money(s.todayPnl, true); $("k-today").className = "value num " + cls(s.todayPnl); $("k-today").title = moneyTitle(s.todayPnl); $("k-today-sub").textContent = fmtPct(s.todayPnlPct); + $("k-open").textContent = money(s.accountOpenPnl, true); $("k-open").className = "value num " + cls(s.accountOpenPnl); $("k-open").title = moneyTitle(s.accountOpenPnl); $("k-open-sub").textContent = `di cui basket ${money(s.openPnl, true)}`; + $("k-dd").textContent = fmtPct(s.drawdownPct); $("k-dd").className = "value num " + (s.drawdownPct >= s.equityStopPct * 0.7 ? "down" : ""); $("k-dd-sub").textContent = `picco ${money(s.peakEquity)} · stop a ${fmtPct(s.equityStopPct)}${s.cumulativeCashFlow ? ` · cassa ${money(s.cumulativeCashFlow, true)}` : ""}`; + $("k-baskets").textContent = s.maxBaskets ? `${s.openBaskets} / ${s.maxBaskets}` : String(s.openBaskets); + const sub = $("k-baskets-sub"); sub.textContent = `in attesa ${s.pendingBaskets} · orfane ${s.orphanLegs} · esterne ${s.foreignPositions}`; sub.className = "sub " + (s.orphanLegs > 0 ? "down" : ""); + $("k-margin").textContent = money(s.usedMargin); $("k-margin").title = `${fmtNum(s.usedMargin)} USD impegnati, ${fmtNum(s.availableBalance)} USD disponibili`; + $("k-margin-sub").textContent = `disponibile ${money(s.availableBalance)}${s.usedMargin > 0 ? ` · equity / margine ${fmtNum(s.equity / s.usedMargin, 2)}` : ""}`; + $("baskets-body").innerHTML = s.baskets.map(basketRow).join(""); + $("baskets-cards").innerHTML = s.baskets.map(basketCard).join(""); + const next = (s.context && s.context.nextEvents || []).find((e) => new Date(e.timeUtc) >= new Date()); + $("ctx-event").textContent = next ? `${next.currency} ${next.title} · ${shortTime(next.timeUtc)} (${next.impact})` : "nessun evento ad alto impatto in vista"; + $("ctx-calendar").textContent = s.context ? `${s.context.calendarState} · ${s.context.volForecast}` : "—"; + $("ctx-api").textContent = `${s.apiState}${Number.isFinite(s.apiLatencyMs) ? ` · ${fmtNum(s.apiLatencyMs, 0)} ms` : ""} · scarto orologio ${fmtSigned(s.clockSkewSeconds, 1)} s`; + $("ctx-counters").textContent = s.counters; $("ctx-telegram").textContent = s.notifierStatus || "—"; $("ctx-news").textContent = s.context ? s.context.newsState : "—"; + $("activity").innerHTML = s.events.slice().reverse().map((e) => `
  • ${esc(e.time)}${esc(e.message)}
  • `).join(""); + if (!state.zone) { state.zoneLabel = ""; } + }; + document.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-close]"); if (!btn) return; + const name = btn.dataset.close; + if (await confirm("Chiusura basket", `Chiudere entrambe le gambe di ${name} al prezzo di mercato?`, "", "Chiudi")) await command("close", { argument: name }); + }); + + // ---------------------------------------------------------------- stream + let es = null; let pollTimer = null; + const apply = (s) => { state.snapshot = s; if (!state.currencyTouched) { state.currency = s.displayCurrency || "USD"; } render(); }; + // ?once=1: a single snapshot and no stream — for the screenshots of the documentation + // (a headless browser with an open event stream never considers the page loaded). + const once = new URLSearchParams(location.search).has("once"); + const connect = () => { + if (once) { api("/api/snapshot").then(({ data }) => apply(data)).catch(() => {}); return; } + if (!("EventSource" in window)) { poll(); return; } + es = new EventSource("/api/stream"); + es.addEventListener("snapshot", (ev) => { try { apply(JSON.parse(ev.data)); } catch { /* a partial frame */ } }); + es.onerror = () => { es.close(); es = null; setTimeout(connect, 3000); }; + }; + const poll = async () => { try { const { data } = await api("/api/snapshot"); apply(data); } catch { /* retry */ } pollTimer = setTimeout(poll, 2000); }; + $("currency").addEventListener("change", () => { state.currencyTouched = true; }); + + // ---------------------------------------------------------------- history + const q = (obj) => Object.entries(obj).filter(([, v]) => v).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); + const ordersFilter = () => ({ basket: $("f-basket").value.trim(), symbol: $("f-symbol").value.trim(), outcome: $("f-outcome").value, from: $("f-from").value, to: $("f-to").value ? $("f-to").value + "T23:59:59Z" : "" }); + const loadHistory = async () => { + busy(true); + try { + if (state.tab === "ordini") { + const { data } = await api("/api/history/orders?" + q(ordersFilter())); + $("history-problem").hidden = !data.problem; $("history-problem").textContent = data.problem || ""; + $("orders-body").innerHTML = data.rows.map((o) => `${shortTime(o.ts)}${esc(o.basket)}
    ${esc(o.basketId)}
    ${esc(o.symbol)}${esc(o.side)}${esc(o.leg)}${fmtNum(o.requestedUnits, 2)}${o.executedUnits ? fmtNum(o.executedUnits, 2) : "—"}${o.requestedPrice ? fmtNum(o.requestedPrice, 5) : "—"}${o.fillRate ? fmtNum(o.fillRate, 5) : "—"}${o.slippagePips === null ? "—" : fmtSigned(o.slippagePips, 1)}${esc(o.status)}${esc(o.resolution)}${o.orderId || "—"}${o.positionId || "—"}${o.fees ? fmtNum(o.fees) : "—"}`).join("") || `nessun ordine nel periodo`; + } else if (state.tab === "posizioni") { + const { data } = await api("/api/history/positions?" + q({ origin: $("p-origin").value })); + $("history-problem").hidden = !data.problem; $("history-problem").textContent = data.problem || ""; + $("positions-body").innerHTML = data.rows.map((p) => `${esc(p.origin)}${p.isOpen ? ' aperta' : ""}${esc(p.basket)}
    ${esc(p.basketId)}
    ${esc(p.symbol)}${p.origin === "movimento di cassa" ? "—" : esc(p.side)}${p.units ? fmtNum(p.units, 2) : "—"}${shortTime(p.openedUtc)}${p.closedUtc ? shortTime(p.closedUtc) : "—"}${money(p.pnlGrossUsd, true)}${fmtNum(p.feesUsd)}${money(p.pnlNetUsd, true)}${p.pips === null ? "—" : fmtSigned(p.pips, 1)}${p.origin === "movimento di cassa" ? "—" : fmtNum(p.durationMinutes, 0) + " min"}${esc(p.exitReason)}`).join("") || `nessuna posizione`; + } else { + const custom = $("per-from").value && $("per-to").value ? { from: $("per-from").value, to: $("per-to").value + "T23:59:59Z" } : {}; + const { data } = await api("/api/history/periods?" + q(custom)); + $("history-problem").hidden = !data.problem; $("history-problem").textContent = data.problem || ""; + $("equity-chart").innerHTML = data.equitySvg; + $("periods-body").innerHTML = data.rows.map((p) => `${esc(p.periodo)}
    ${shortTime(p.fromUtc).slice(0, 10)} → ${shortTime(p.toUtc).slice(0, 10)}
    ${p.nBasket}${p.nPosizioni}${p.vinti}${p.persi}${p.winRate === null ? "—" : fmtPct(p.winRate)}${money(p.pnlLordo, true)}${money(p.fee)}${money(p.pnlNetto, true)}${p.mediaPerBasket === null ? "—" : money(p.mediaPerBasket, true)}${money(p.maxDd)}${p.movimentiDiCassa ? money(p.movimentiDiCassa, true) : "—"}`).join(""); + } + } catch (e) { snack(String(e.message || e), true); } + finally { busy(false); } + }; + document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((x) => x.setAttribute("aria-selected", String(x === t))); + state.tab = t.dataset.tab; + ["ordini", "posizioni", "periodi"].forEach((n) => { $("tab-" + n).hidden = n !== state.tab; }); + loadHistory(); + })); + $("f-apply").addEventListener("click", loadHistory); $("p-apply").addEventListener("click", loadHistory); $("per-apply").addEventListener("click", loadHistory); + $("export-orders").addEventListener("click", () => { location.href = "/api/history/orders?format=csv&" + q(ordersFilter()); }); + $("export-positions").addEventListener("click", () => { location.href = "/api/history/positions?format=csv&" + q({ origin: $("p-origin").value }); }); + $("export-periods").addEventListener("click", () => { location.href = "/api/history/periods?format=csv"; }); + + // ---------------------------------------------------------------- log + const loadLog = async () => { + if (state.page !== "log") return; + try { + const { data } = await api("/api/log?" + q({ level: $("log-level").value, q: $("log-search").value.trim(), limit: 2000, after: state.lastLogSeq })); + if (data.file) $("log-file").textContent = data.file; + if (data.rows.length) { + const body = $("log-body"); + body.insertAdjacentHTML("beforeend", data.rows.map((r) => `${esc(r.time)}${esc(r.message)}`).join("")); + while (body.rows.length > 5000) body.deleteRow(0); + if ($("log-follow").checked) $("log-wrap").scrollTop = $("log-wrap").scrollHeight; + } + state.lastLogSeq = data.lastSeq; + } catch { /* next tick */ } + }; + if (!once) setInterval(loadLog, 1500); + const refilterLog = () => { state.lastLogSeq = 0; $("log-body").innerHTML = ""; loadLog(); }; + $("log-level").addEventListener("change", refilterLog); $("log-search").addEventListener("input", () => { clearTimeout(refilterLog.t); refilterLog.t = setTimeout(refilterLog, 300); }); + + // ---------------------------------------------------------------- settings + const loadSettings = async () => { + try { + const { data } = await api("/api/settings"); state.settings = data; state.dirty = {}; + $("settings-path").textContent = data.configPath; $("keys-status").textContent = data.keys; + $("settings-groups").innerHTML = data.groups.map((g) => `

    ${esc(g.title)}

    ${esc(g.description)}
    ${g.fields.map(fieldHtml).join("")}
    `).join(""); + updateSaveState(); + } catch (e) { snack(String(e.message || e), true); } + }; + const fieldHtml = (f) => { + const id = "f_" + f.path.replace(/[^a-z0-9]/gi, "_"); + const input = f.options.length + ? `` + : ``; + return `
    ${input}${esc(f.suffix)} ${esc(f.error)}
    `; + }; + const updateSaveState = () => { + const n = Object.keys(state.dirty).length; $("settings-save").disabled = n === 0; + $("settings-status").textContent = n === 0 ? "Nessuna modifica da salvare" : `${n} modifica/e non salvate — l'interfaccia le applica subito, il motore al prossimo avvio`; + }; + $("settings-groups").addEventListener("input", (e) => { + const el = e.target.closest("[data-path]"); if (!el) return; + const path = el.dataset.path; const field = state.settings.groups.flatMap((g) => g.fields).find((f) => f.path === path); + const value = el.value; if (value === field.value) delete state.dirty[path]; else state.dirty[path] = value; + el.classList.toggle("dirty", path in state.dirty); updateSaveState(); + }); + $("settings-revert").addEventListener("click", loadSettings); + $("settings-save").addEventListener("click", async () => { + busy(true); + try { + const { ok, data } = await api("/api/settings", { method: "POST", body: JSON.stringify(state.dirty) }); + snack(data.message, !ok); if (ok) { if ("ui.theme" in state.dirty) applyTheme(state.dirty["ui.theme"]); if ("ui.displayCurrency" in state.dirty) { state.currency = state.dirty["ui.displayCurrency"]; state.currencyTouched = true; render(); } loadSettings(); } + } finally { busy(false); } + }); + $("keys-save").addEventListener("click", async () => { + busy(true); + try { const { ok, data } = await api("/api/settings/keys", { method: "POST", body: JSON.stringify({ apiKey: $("key-api").value, userKey: $("key-user").value }) }); snack(data.message, !ok); if (ok) { $("key-api").value = ""; $("key-user").value = ""; loadSettings(); } } + finally { busy(false); } + }); + $("keys-forget").addEventListener("click", async () => { + if (!(await confirm("Rimozione chiavi", "Rimuovere le chiavi eToro salvate nel file cifrato per l'ambiente corrente?", "", "Rimuovi", true))) return; + const { ok, data } = await api("/api/settings/keys", { method: "DELETE" }); snack(data.message, !ok); loadSettings(); + }); + $("restore-defaults").addEventListener("click", async () => { + if (!(await confirm("Ripristino dei valori predefiniti", "Riscrivere l'intera configurazione con i valori di fabbrica? Vengono persi i valori cambiati e i commenti scritti nel file; il file attuale viene salvato con la data accanto. Le chiavi e strategy.json non vengono toccati.", "", "Ripristina", true))) return; + const { ok, data } = await api("/api/settings/restore", { method: "POST" }); snack(data.message, !ok); if (ok) loadSettings(); + }); + const applyTheme = (t) => { document.documentElement.dataset.theme = t === "light" ? "light" : "dark"; try { localStorage.setItem("ui.theme", document.documentElement.dataset.theme); } catch { /* per-viewer */ } }; + const loadInfo = async () => { + try { + const { data } = await api("/api/info"); + const kv = (pairs) => pairs.map(([k, v]) => `
    ${esc(k)}
    ${esc(v)}
    `).join(""); + $("about-kv").innerHTML = kv([["Nome", data.name], ["Autore", data.author], ["Versione", data.version], ["Data di build", data.buildDate], ["Commit", data.commit], ["Licenza", data.license], ["Documentazione", "docs/ nel repository (RUNBOOK, ARCHITECTURE, DOCKER, UI_GUIDELINES)"]]); + $("diag-kv").innerHTML = kv([["Strategia e run", data.strategyVersion], ["Configurazione", data.configPath], ["Strategia", data.strategyPath], ["Dati", data.dataPath], ["Conoscenza", data.knowledgePath], ["Rapporti", data.reportsPath], ["Log", data.logPath], [".NET", data.dotnet], ["Sistema", data.os + (data.container ? " (container)" : "")], ["Uptime del server", Math.round(data.uptimeSeconds / 60) + " min"], ["Quote API", data.counters], ["Fuso orario", data.timeZone + " (" + data.timeZoneLabel + ")"]]); + state.zone = data.timeZone.startsWith("(") ? "" : data.timeZone.split(" (")[0]; state.zoneLabel = data.timeZoneLabel; + if (state.zone === "UTC" || /fuso del computer/.test(data.timeZone)) state.zone = data.timeZone === "UTC" ? "UTC" : ""; + } catch (e) { snack(String(e.message || e), true); } + }; + const loadResearch = async () => { + try { + const { data } = await api("/api/research"); + const kv = (pairs) => pairs.map(([k, v]) => `
    ${esc(k)}
    ${esc(v)}
    `).join(""); + $("research-kv").innerHTML = kv([["Apprendimento a runtime", data.learningEnabled ? "attivo" : "disattivato (ADR-0006): tutto in ombra"], ["Ciclo settimanale nel bot", data.weeklyCycle ? "sì" : "no"], ["Challenger MLP", data.challenger ? "sì" : "no"], ["Modello in ombra", data.mlState], ["Bandit", data.banditProposal], ["Volatilità", data.volForecast], ["Criterio", data.criterion], ["File in knowledge/", data.knowledgeFiles.map((f) => f.name).join(", ") || "nessuno"]]); + } catch { /* not yet */ } + }; + $("learn").addEventListener("click", async () => { await command("learn"); loadResearch(); }); + $("bonifica-list").addEventListener("click", async () => { + const { data } = await command("bonifica", { argument: "list" }); + const rows = data.positions || []; + $("bonifica-list-out").innerHTML = rows.length ? `
      ${rows.map((p) => `
    • ${esc(p.origin)} ${p.positionId} ${esc(p.symbol)} ${p.isBuy ? "long" : "short"} ${fmtNum(p.units, 2)} P&L ${fmtSigned(p.unrealizedPnl)} — ${esc(p.reason)} ${p.origin === "orfana-bot" ? `` : ""}
    • `).join("")}
    ` : `

    niente da bonificare

    `; + }); + $("bonifica-list-out").addEventListener("click", async (e) => { + const b = e.target.closest("[data-bonifica]"); if (!b) return; + if (await confirm("Bonifica", `Chiudere la posizione orfana ${b.dataset.bonifica}?`, "", "Chiudi", true)) { await command("bonifica", { argument: "close:" + b.dataset.bonifica }); $("bonifica-list").click(); } + }); + + // ---------------------------------------------------------------- boot + (() => { + let open = true; + try { const saved = localStorage.getItem("ui.navExpanded"); if (saved !== null) open = saved === "1"; const th = localStorage.getItem("ui.theme"); if (th) document.documentElement.dataset.theme = th; } catch { /* defaults */ } + if (window.innerWidth < 600) open = false; + setNav(open); + api("/api/info").then(({ data }) => { if (data.timeZone) loadInfo(); }).catch(() => {}); + api("/api/settings").then(({ data }) => { const t = data.groups.flatMap((g) => g.fields).find((f) => f.path === "ui.theme"); if (t) applyTheme(t.value); const nav = data.groups.flatMap((g) => g.fields).find((f) => f.path === "ui.navExpanded"); if (nav && localStorage.getItem("ui.navExpanded") === null && window.innerWidth >= 600) setNav(nav.value === "sì"); }).catch(() => {}); + navigate(); + connect(); + })(); +})(); diff --git a/Encelado/src/Encelado.Server/Web/wwwroot/icon.svg b/Encelado/src/Encelado.Server/Web/wwwroot/icon.svg new file mode 100644 index 0000000..6b10bad --- /dev/null +++ b/Encelado/src/Encelado.Server/Web/wwwroot/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Encelado/src/Encelado.Server/Web/wwwroot/index.html b/Encelado/src/Encelado.Server/Web/wwwroot/index.html new file mode 100644 index 0000000..a891acc --- /dev/null +++ b/Encelado/src/Encelado.Server/Web/wwwroot/index.html @@ -0,0 +1,215 @@ + + + + + + + Encelado + + + + + + + + + + +
    +
    +

    Dashboard

    +
    + --:--:-- UTC + --:--:-- +
    + + + +
    + + +
    + + +
    + +
    +
    Equity
    saldo —
    +
    P&L oggi
    +
    P&L aperto (conto)
    di cui basket —
    +
    Drawdown
    picco —
    +
    Basket aperti
    in attesa 0 · orfane 0 · esterne 0
    +
    Margine usato
    +
    + +
    +

    Basket

    +
    + + +
    Basket (cross)StatozρHLPipsTPP&LCostoProssimo evento
    +
    +
    + +
    +

    Prossimo evento

    +

    Collegamento eToro

    +

    Telegram

    +
    + +

    Attività

      +
      + + + + + + + + + +
      +
      + + + +
      +

      Conferma

      +

      +
      +
      +
      +
      + + +
      +

      Motivazione

      +

      + +

      +
      +
      +
      + + +

      Ripristino

      +
        +
        +
        + + + + + + diff --git a/Encelado/src/Encelado.Server/Web/wwwroot/login.html b/Encelado/src/Encelado.Server/Web/wwwroot/login.html new file mode 100644 index 0000000..417c9a0 --- /dev/null +++ b/Encelado/src/Encelado.Server/Web/wwwroot/login.html @@ -0,0 +1,21 @@ + + + + + + Encelado — accesso + + + + +
        + +

        Encelado

        +

        Inserisci il token locale (variabile ENCELADO_WEB_TOKEN del container). Resta in un cookie HttpOnly per trenta giorni.

        +
        + + +
        +
        + + diff --git a/Encelado/src/Encelado.Server/Web/wwwroot/manifest.webmanifest b/Encelado/src/Encelado.Server/Web/wwwroot/manifest.webmanifest new file mode 100644 index 0000000..05cdb7b --- /dev/null +++ b/Encelado/src/Encelado.Server/Web/wwwroot/manifest.webmanifest @@ -0,0 +1,10 @@ +{ + "name": "Encelado", + "short_name": "Encelado", + "description": "Correlation Baskets su eToro", + "start_url": "/", + "display": "standalone", + "background_color": "#101418", + "theme_color": "#101418", + "icons": [{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }] +} diff --git a/Encelado/tests/Encelado.Tests/BasketTests.cs b/Encelado/tests/Encelado.Tests/BasketTests.cs index 984de34..deda0a7 100644 --- a/Encelado/tests/Encelado.Tests/BasketTests.cs +++ b/Encelado/tests/Encelado.Tests/BasketTests.cs @@ -1,5 +1,6 @@ -using Encelado.Bot.Baskets; +using Encelado.Engine.Baskets; using Encelado.Core.Baskets; +using Encelado.Core.Baskets.History; using Encelado.Core.Baskets.Data; using Encelado.Core.Broker; diff --git a/Encelado/tests/Encelado.Tests/ConfigurationTests.cs b/Encelado/tests/Encelado.Tests/ConfigurationTests.cs index 253b490..be17c40 100644 --- a/Encelado/tests/Encelado.Tests/ConfigurationTests.cs +++ b/Encelado/tests/Encelado.Tests/ConfigurationTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; -using Encelado.Bot.Configuration; -using Encelado.Bot.Ui; +using Encelado.Engine.Configuration; +using Encelado.Engine.Settings; using Encelado.Core.Baskets; namespace Encelado.Tests; @@ -404,7 +404,7 @@ public class UiClockTests TimeZoneInfo previous = UiClock.Zone; try { - UiClock.Zone = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time"); + UiClock.Zone = TimeZoneInfo.CreateCustomTimeZone("Test+09", TimeSpan.FromHours(9), "Test+09", "Test+09"); DateTime utc = new(2026, 9, 16, 10, 0, 0, DateTimeKind.Utc); Assert.Equal("19:00:00", UiClock.Format(utc, "HH:mm:ss")); diff --git a/Encelado/tests/Encelado.Tests/CredentialStoreTests.cs b/Encelado/tests/Encelado.Tests/CredentialStoreTests.cs deleted file mode 100644 index 1bc06bb..0000000 --- a/Encelado/tests/Encelado.Tests/CredentialStoreTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Encelado.Bot.Configuration; - -namespace Encelado.Tests; - -/// -/// Every test redirects the store to a scratch directory via ENCELADO_HOME, so the -/// developer's real saved keys are never read, written or deleted. -/// -public sealed class EtoroKeyStoreTests : IDisposable -{ - private readonly string _home; - private readonly string? _previousHome; - - public EtoroKeyStoreTests() - { - _previousHome = Environment.GetEnvironmentVariable("ENCELADO_HOME"); - _home = Path.Combine(Path.GetTempPath(), $"encelado-store-{Guid.NewGuid():N}"); - Environment.SetEnvironmentVariable("ENCELADO_HOME", _home); - } - - public void Dispose() - { - Environment.SetEnvironmentVariable("ENCELADO_HOME", _previousHome); - if (Directory.Exists(_home)) - { - Directory.Delete(_home, recursive: true); - } - } - - [Fact] - public void HonoursTheHomeOverride() => - Assert.Equal(Path.Combine(_home, "etoro.dat"), EtoroKeyStore.FilePath); - - [Fact] - public void KeysRoundTripPerEnvironment() - { - Assert.False(EtoroKeyStore.Exists); - Assert.Null(EtoroKeyStore.Load(demo: true)); - - EtoroKeyStore.Save(demo: true, new EtoroKeys("api-demo", "user-demo", DateTime.UtcNow)); - EtoroKeyStore.Save(demo: false, new EtoroKeys("api-real", "user-real", DateTime.UtcNow)); - - Assert.True(EtoroKeyStore.Exists); - Assert.Equal("api-demo", EtoroKeyStore.Load(demo: true)!.ApiKey); - Assert.Equal("user-real", EtoroKeyStore.Load(demo: false)!.UserKey); - - Assert.True(EtoroKeyStore.Clear(demo: true)); - Assert.Null(EtoroKeyStore.Load(demo: true)); - Assert.NotNull(EtoroKeyStore.Load(demo: false)); - Assert.True(EtoroKeyStore.Clear(demo: false)); - Assert.False(EtoroKeyStore.Exists); - Assert.False(EtoroKeyStore.Clear(demo: false)); - } - - [Fact] - public void KeysAreNotReadableAsPlainTextOnWindows() - { - EtoroKeyStore.Save(demo: true, new EtoroKeys("api-key-value", "user-key-value", DateTime.UtcNow)); - string onDisk = File.ReadAllText(EtoroKeyStore.FilePath); - - if (EtoroKeyStore.IsEncrypted) - { - Assert.DoesNotContain("user-key-value", onDisk, StringComparison.Ordinal); - } - else - { - Assert.Contains("user-key-value", onDisk, StringComparison.Ordinal); - } - } - - [Fact] - public void ACorruptFileIsTreatedAsAbsentRatherThanThrowing() - { - Directory.CreateDirectory(_home); - File.WriteAllBytes(EtoroKeyStore.FilePath, [0x00, 0x01, 0x02, 0x03, 0x04]); - - Assert.Null(EtoroKeyStore.Load(demo: true)); - - EtoroKeyStore.Save(demo: true, new EtoroKeys("new-api", "new-user", DateTime.UtcNow)); - Assert.Equal("new-api", EtoroKeyStore.Load(demo: true)!.ApiKey); - } - - [Fact] - public void ResolvePrefersTheEnvironmentThenTheStore() - { - BotConfig config = new(); - Assert.False(EtoroKeyStore.Resolve(config, out string origin)); - Assert.Contains("nessuna", origin, StringComparison.OrdinalIgnoreCase); - - EtoroKeyStore.Save(demo: true, new EtoroKeys("api-stored", "user-stored", DateTime.UtcNow)); - Assert.True(EtoroKeyStore.Resolve(config, out origin)); - Assert.Equal("api-stored", config.Etoro.ApiKey); - Assert.Contains("salvate", origin, StringComparison.OrdinalIgnoreCase); - - BotConfig fromEnv = new(); - fromEnv.Etoro.ApiKey = "api-env"; - fromEnv.Etoro.UserKey = "user-env"; - Assert.True(EtoroKeyStore.Resolve(fromEnv, out origin)); - Assert.Equal("api-env", fromEnv.Etoro.ApiKey); - Assert.Contains("ambiente", origin, StringComparison.OrdinalIgnoreCase); - } - - [Theory] - [InlineData("PKABCDEFGH1234", "PKABCD********")] - [InlineData("PKAB", "****")] - [InlineData("ab", "**")] - [InlineData("", "(vuota)")] - [InlineData(null, "(vuota)")] - public void MaskKeepsOnlyThePrefix(string? input, string expected) => - Assert.Equal(expected, EtoroKeyStore.Mask(input)); - - [Theory] - [InlineData(" PKKEY123 ", "PKKEY123")] - [InlineData("PKKEY123", "PKKEY123")] - [InlineData("PK​KEY123", "PKKEY123")] - [InlineData("P\0K\0K\0E\0Y\0", "PKKEY")] - [InlineData("PKKEY123\r\n", "PKKEY123")] - public void CleanStripsInvisibleCharactersFromPastedKeys(string raw, string expected) => - Assert.Equal(expected, EtoroKeyStore.Clean(raw)); - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("\r\n")] - public void CleanReturnsNullWhenNothingUsableRemains(string? raw) => - Assert.Null(EtoroKeyStore.Clean(raw)); - - [Fact] - public void MaskNeverEchoesAWholeSecret() - { - const string secret = "abcdefghijklmnopqrstuvwxyz0123456789"; - string masked = EtoroKeyStore.Mask(secret); - - Assert.DoesNotContain(secret, masked, StringComparison.Ordinal); - Assert.DoesNotContain(secret[4..], masked, StringComparison.Ordinal); - Assert.StartsWith("abcd", masked, StringComparison.Ordinal); - } -} diff --git a/Encelado/tests/Encelado.Tests/Encelado.Tests.csproj b/Encelado/tests/Encelado.Tests/Encelado.Tests.csproj index 022993d..fc65b8f 100644 --- a/Encelado/tests/Encelado.Tests/Encelado.Tests.csproj +++ b/Encelado/tests/Encelado.Tests/Encelado.Tests.csproj @@ -2,20 +2,15 @@ false - - net10.0-windows + + net10.0 Encelado.Tests - - true - - + false false @@ -29,15 +24,12 @@ - - + folder and from the Docker build stage, where the repository root is out of reach. --> @@ -45,8 +37,9 @@ - + + diff --git a/Encelado/tests/Encelado.Tests/ExecutionTests.cs b/Encelado/tests/Encelado.Tests/ExecutionTests.cs index 20a446a..09fe3ee 100644 --- a/Encelado/tests/Encelado.Tests/ExecutionTests.cs +++ b/Encelado/tests/Encelado.Tests/ExecutionTests.cs @@ -4,7 +4,7 @@ using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; -using Encelado.Bot.Baskets; +using Encelado.Engine.Baskets; using Encelado.Core.Baskets; using Encelado.Core.Baskets.Data; using Encelado.Core.Baskets.History; diff --git a/Encelado/tests/Encelado.Tests/HistoryTests.cs b/Encelado/tests/Encelado.Tests/HistoryTests.cs new file mode 100644 index 0000000..ac6d1a6 --- /dev/null +++ b/Encelado/tests/Encelado.Tests/HistoryTests.cs @@ -0,0 +1,154 @@ +using Encelado.Core.Baskets; +using Encelado.Core.Baskets.History; +using Encelado.Core.Broker; + +namespace Encelado.Tests; + +/// The Storico page's builder: origins, pips, periods, equity curve, CSV. +public class HistoryBuilderTests +{ + private static readonly DateTime Now = new(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc); + + private static OrderRecord Order(string basketId, string basket, string symbol, long instrumentId, bool isBuy, OrderLeg leg, long positionId, string clientRef, DateTime ts, OrderResolution resolution = OrderResolution.Filled) => + new(ts, "run", "Demo", basket, basketId, symbol, instrumentId, isBuy, leg, 10_000, 10_000, 1.1, 1.1001, 1, "Filled", 3, resolution, 500 + positionId, positionId, clientRef, 0, "risolto", "x"); + + private static BasketOutcomeRow Basket(string id, string name, string exit, DateTime opened) => + new(id, "run", name, "Demo", "MODERATE", opened, opened.AddHours(2), true, -2.1, -0.2, 12, 8, 8, 3, 3.4, 0.5, 0, 8, exit, 10_000, double.NaN, "y"); + + private static string Symbol(long id) => id switch { 1 => "EURUSD", 6 => "USDCHF", 7 => "AUDUSD", _ => "XXXYYY" }; + + [Fact] + public void PositionsAreClassifiedByTheOrderRegisterAndPipsFollowTheDirection() + { + DateTime open = Now.AddHours(-3); + List orders = + [ + Order("B1", "EURUSD/USDCHF", "EURUSD", 1, true, OrderLeg.A, 11, "a", open), + Order("B1", "EURUSD/USDCHF", "USDCHF", 6, true, OrderLeg.B, 12, "b", open.AddSeconds(3)), + Order("B2", "AUDUSD/USDCAD", "AUDUSD", 7, false, OrderLeg.A, 13, "c", open.AddHours(1)), + ]; + List baskets = + [ + Basket("B1", "EURUSD/USDCHF", "tp_pips", open), + Basket("B2", "AUDUSD/USDCAD", "leg_risk_unwind", open.AddHours(1)), + ]; + List closed = + [ + new(11, 1, true, 10_000, 1.1000, 1.1010, open, open.AddHours(2), 10.0, 0.5), + new(12, 6, true, 10_000, 0.8000, 0.7995, open, open.AddHours(2), -6.0, 0.5), + new(13, 7, false, 10_000, 0.6500, 0.6510, open.AddHours(1), open.AddHours(1.5), -10.0, 0.0), + new(99, 1, true, 1_000, 1.1000, 1.1020, open.AddDays(-1), open.AddDays(-1).AddHours(1), 2.0, 0.0), + ]; + List open_ = [new(14, 1, true, 5_000, 1.1000, Now.AddMinutes(-30), 1.09, 0, 10, 550, 3.0, 0.1, 1.1006)]; + List cash = [new(open.AddDays(-2), 30_000, "accredito virtuale")]; + + List rows = HistoryBuilder.Positions(closed, open_, baskets, orders, cash, Symbol, PipMath.Pip, Now); + + Assert.Equal(6, rows.Count); + PositionRecord p11 = rows.Single(r => r.PositionId == 11); + Assert.Equal("basket", p11.Origin); + Assert.Equal("B1", p11.BasketId); + Assert.Equal("tp_pips", p11.ExitReason); + Assert.Equal(10.0, p11.Pips, 6); + Assert.Equal(9.5, p11.PnlNetUsd, 6); + Assert.Equal("orfana-bot", rows.Single(r => r.PositionId == 13).Origin); + Assert.Equal(-10.0, rows.Single(r => r.PositionId == 13).Pips, 6); + Assert.Equal("esterna", rows.Single(r => r.PositionId == 99).Origin); + PositionRecord openRow = rows.Single(r => r.PositionId == 14); + Assert.True(openRow.IsOpen); + Assert.Equal("esterna", openRow.Origin); + Assert.Equal(6.0, openRow.Pips, 6); + PositionRecord cashRow = rows.Single(r => r.Origin == "movimento di cassa"); + Assert.Equal(30_000, cashRow.PnlNetUsd, 6); + Assert.Equal("deposito", cashRow.ExitReason); + + // Open positions first (no closing time), then newest closing first. + Assert.True(rows[0].IsOpen); + Assert.Equal(11, rows[1].PositionId); + } + + [Fact] + public void PeriodsCountBasketsNotLegsAndKeepCashOutOfTheResult() + { + DateTime open = Now.AddHours(-3); + List rows = + [ + new(11, "EURUSD", true, 10_000, 1.1, 1.101, open, open.AddHours(2), 10, 0.5, 9.5, 10, "basket", "B1", "EURUSD/USDCHF", "tp_pips", false, ""), + new(12, "USDCHF", true, 10_000, 0.8, 0.7995, open, open.AddHours(2), -6, 0.5, -6.5, -5, "basket", "B1", "EURUSD/USDCHF", "tp_pips", false, ""), + new(13, "AUDUSD", false, 10_000, 0.65, 0.651, open, open.AddHours(1), -10, 0, -10, -10, "orfana-bot", "B2", "AUDUSD/USDCAD", "leg_risk_unwind", false, ""), + new(99, "EURUSD", true, 1_000, 1.1, 1.102, Now.AddDays(-1), Now.AddDays(-1).AddHours(1), 2, 0, 2, 20, "esterna", "", "", "", false, ""), + new(0, "USD", true, 0, 0, 0, Now.AddDays(-2), Now.AddDays(-2), 30_000, 0, 30_000, double.NaN, "movimento di cassa", "", "", "deposito", false, "accredito"), + ]; + + List periods = HistoryBuilder.Periods(rows, Now); + PeriodStats today = periods.Single(static p => p.Periodo == "oggi"); + Assert.Equal(3, today.NPosizioni); + Assert.Equal(2, today.NBasket); + Assert.Equal(1, today.Vinti); + Assert.Equal(1, today.Persi); + Assert.Equal(0.5, today.WinRate, 6); + Assert.Equal(-7.0, today.PnlNetto, 6); + Assert.Equal(1.0, today.Fee, 6); + Assert.Equal(0, today.MovimentiDiCassa, 6); + + PeriodStats all = periods.Single(static p => p.Periodo == "tutto"); + Assert.Equal(30_000, all.MovimentiDiCassa, 6); + Assert.Equal(-7.0, all.PnlNetto, 6); // the external position and the cash movement do not count + Assert.Contains("movimenti di cassa", all.Motivazione, StringComparison.Ordinal); + + PeriodStats custom = HistoryBuilder.Periods(rows, Now, (Now.AddDays(-10), Now.AddDays(-5))).Single(static p => p.Periodo == "personalizzato"); + Assert.Equal(0, custom.NPosizioni); + Assert.True(double.IsNaN(custom.WinRate)); + } + + [Fact] + public void TheEquityCurveIsCumulativeAndTheDrawdownIsPeakToTrough() + { + DateTime t = Now.AddHours(-5); + List rows = + [ + new(1, "EURUSD", true, 1, 1, 1, t, t.AddHours(1), 10, 0, 10, 0, "basket", "B1", "b", "tp", false, ""), + new(2, "EURUSD", true, 1, 1, 1, t, t.AddHours(2), -30, 0, -30, 0, "basket", "B2", "b", "stop", false, ""), + new(3, "EURUSD", true, 1, 1, 1, t, t.AddHours(3), 5, 0, 5, 0, "basket", "B3", "b", "tp", false, ""), + new(4, "EURUSD", true, 1, 1, 1, t, null, 7, 0, 7, 0, "basket", "B4", "b", "", true, ""), + ]; + + List curve = HistoryBuilder.EquityCurve(rows); + Assert.Equal(3, curve.Count); + Assert.Equal([10.0, -20.0, -15.0], curve.Select(static p => p.CumulativeNetUsd)); + Assert.Equal(30.0, HistoryBuilder.MaxDrawdown(curve), 6); + + string svg = HistoryBuilder.EquitySvg(curve); + Assert.StartsWith(" lines = + [ + Order("B1", "EURUSD/USDCHF", "EURUSD", 1, true, OrderLeg.A, 0, "a", t, OrderResolution.Pending), + Order("B1", "EURUSD/USDCHF", "USDCHF", 6, true, OrderLeg.B, 12, "b", t.AddSeconds(5)), + Order("B1", "EURUSD/USDCHF", "EURUSD", 1, true, OrderLeg.A, 11, "a", t.AddSeconds(9)), + ]; + + List latest = HistoryBuilder.LatestOrders(lines); + Assert.Equal(2, latest.Count); + Assert.Equal("b", latest[0].ClientRef); // sent later, listed first + Assert.Equal(OrderResolution.Filled, latest.Single(static o => o.ClientRef == "a").Resolution); + Assert.Equal(11, latest.Single(static o => o.ClientRef == "a").PositionId); + + string orders = HistoryBuilder.OrdersCsv(latest); + Assert.StartsWith(HistoryBuilder.OrdersHeader + "\n", orders.Replace("\r\n", "\n"), StringComparison.Ordinal); + Assert.Equal(3, orders.Replace("\r\n", "\n").TrimEnd('\n').Split('\n').Length); + + List rows = [new(11, "EURUSD", true, 1, 1.1, 1.101, t, t.AddMinutes(30), 10, 0.5, 9.5, 10, "basket", "B1", "EURUSD/USDCHF", "tp_pips", false, "a; b")]; + string positions = HistoryBuilder.PositionsCsv(rows); + Assert.StartsWith(PositionRecord.Header, positions, StringComparison.Ordinal); + Assert.Contains("a, b", positions, StringComparison.Ordinal); // the motivation never breaks the column count + Assert.StartsWith(PeriodStats.Header, HistoryBuilder.PeriodsCsv(HistoryBuilder.Periods(rows, Now)), StringComparison.Ordinal); + } +} diff --git a/Encelado/tests/Encelado.Tests/KeyStoreTests.cs b/Encelado/tests/Encelado.Tests/KeyStoreTests.cs new file mode 100644 index 0000000..9d251ba --- /dev/null +++ b/Encelado/tests/Encelado.Tests/KeyStoreTests.cs @@ -0,0 +1,150 @@ +using Encelado.Engine.Configuration; + +namespace Encelado.Tests; + +/// The portable key store of the 5.0: an AES-GCM file under a passphrase, the environment first. +public sealed class KeyStoreTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"encelado-keys-{Guid.NewGuid():N}"); + + private string FilePath => Path.Combine(_dir, KeyStores.FileName); + + public void Dispose() + { + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, recursive: true); + } + } + + [Fact] + public void KeysRoundTripPerEnvironmentAndAreNotReadableOnDisk() + { + EncryptedFileKeyStore store = new(FilePath, "una passphrase lunga a sufficienza"); + Assert.False(store.Exists); + Assert.Null(store.Load(demo: true)); + + store.Save(demo: true, new EtoroKeys("api-demo", "user-demo-value", DateTime.UtcNow)); + store.Save(demo: false, new EtoroKeys("api-real", "user-real", DateTime.UtcNow)); + + Assert.True(store.Exists); + Assert.Equal("api-demo", store.Load(demo: true)!.ApiKey); + Assert.Equal("user-real", store.Load(demo: false)!.UserKey); + byte[] onDisk = File.ReadAllBytes(FilePath); + Assert.DoesNotContain("user-demo-value"u8.ToArray(), Windows(onDisk, 15)); + Assert.Equal("ENC1"u8.ToArray(), onDisk[..4]); + + Assert.True(store.Clear(demo: true)); + Assert.Null(store.Load(demo: true)); + Assert.NotNull(store.Load(demo: false)); + Assert.True(store.Clear(demo: false)); + Assert.False(store.Exists); + Assert.False(store.Clear(demo: false)); + + static IEnumerable Windows(byte[] data, int size) + { + for (int i = 0; i + size <= data.Length; i++) + { + yield return data[i..(i + size)]; + } + } + } + + [Fact] + public void AWrongPassphraseOrACorruptFileReadsAsNoKeys() + { + EncryptedFileKeyStore store = new(FilePath, "giusta-giusta-giusta"); + store.Save(demo: true, new EtoroKeys("api", "user", DateTime.UtcNow)); + + Assert.Null(new EncryptedFileKeyStore(FilePath, "sbagliata-sbagliata").Load(demo: true)); + Assert.Null(new EncryptedFileKeyStore(FilePath, string.Empty).Load(demo: true)); + + File.WriteAllBytes(FilePath, [0x00, 0x01, 0x02, 0x03, 0x04]); + Assert.Null(store.Load(demo: true)); + + store.Save(demo: true, new EtoroKeys("new-api", "new-user", DateTime.UtcNow)); + Assert.Equal("new-api", store.Load(demo: true)!.ApiKey); + } + + [Fact] + public void WithoutAPassphraseTheFileStoreCannotSave() + { + EncryptedFileKeyStore store = new(FilePath, string.Empty); + Assert.False(store.CanSave); + Assert.Throws(() => store.Save(true, new EtoroKeys("a", "b", DateTime.UtcNow))); + } + + [Fact] + public void ResolvePrefersTheEnvironmentThenTheEncryptedFile() + { + string? previous = Environment.GetEnvironmentVariable(KeyStores.PassphraseVariable); + try + { + Environment.SetEnvironmentVariable(KeyStores.PassphraseVariable, "passphrase-di-prova"); + BotConfig config = new(); + config.Run.BaseDirectory = _dir; + Assert.False(KeyStores.Resolve(config, out string origin)); + Assert.Contains("nessuna", origin, StringComparison.OrdinalIgnoreCase); + + KeyStores.File(config).Save(demo: true, new EtoroKeys("api-stored", "user-stored", DateTime.UtcNow)); + Assert.True(KeyStores.Resolve(config, out origin)); + Assert.Equal("api-stored", config.Etoro.ApiKey); + Assert.Contains("cifrato", origin, StringComparison.OrdinalIgnoreCase); + + BotConfig fromEnv = new(); + fromEnv.Run.BaseDirectory = _dir; + fromEnv.Etoro.ApiKey = "api-env"; + fromEnv.Etoro.UserKey = "user-env"; + Assert.True(KeyStores.Resolve(fromEnv, out origin)); + Assert.Equal("api-env", fromEnv.Etoro.ApiKey); + Assert.Contains("ambiente", origin, StringComparison.OrdinalIgnoreCase); + + Environment.SetEnvironmentVariable(KeyStores.PassphraseVariable, null); + BotConfig noPass = new(); + noPass.Run.BaseDirectory = _dir; + Assert.False(KeyStores.Resolve(noPass, out origin)); + Assert.Contains(KeyStores.PassphraseVariable, origin, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(KeyStores.PassphraseVariable, previous); + } + } + + [Theory] + [InlineData("PKABCDEFGH1234", "PKABCD********")] + [InlineData("PKAB", "****")] + [InlineData("ab", "**")] + [InlineData("", "(vuota)")] + [InlineData(null, "(vuota)")] + public void MaskKeepsOnlyThePrefix(string? input, string expected) => + Assert.Equal(expected, KeyStores.Mask(input)); + + [Theory] + [InlineData(" PKKEY123 ", "PKKEY123")] + [InlineData("PKKEY123", "PKKEY123")] + [InlineData("PK​KEY123", "PKKEY123")] + [InlineData("P\0K\0K\0E\0Y\0", "PKKEY")] + [InlineData("PKKEY123\r\n", "PKKEY123")] + public void CleanStripsInvisibleCharactersFromPastedKeys(string raw, string expected) => + Assert.Equal(expected, KeyStores.Clean(raw)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\r\n")] + public void CleanReturnsNullWhenNothingUsableRemains(string? raw) => + Assert.Null(KeyStores.Clean(raw)); + + [Fact] + public void MaskNeverEchoesAWholeSecret() + { + const string secret = "abcdefghijklmnopqrstuvwxyz0123456789"; + string masked = KeyStores.Mask(secret); + + Assert.DoesNotContain(secret, masked, StringComparison.Ordinal); + Assert.DoesNotContain(secret[4..], masked, StringComparison.Ordinal); + Assert.StartsWith("abcd", masked, StringComparison.Ordinal); + } +} diff --git a/Encelado/tests/Encelado.Tests/LoggingTests.cs b/Encelado/tests/Encelado.Tests/LoggingTests.cs index c9377b0..65c07e2 100644 --- a/Encelado/tests/Encelado.Tests/LoggingTests.cs +++ b/Encelado/tests/Encelado.Tests/LoggingTests.cs @@ -1,5 +1,5 @@ using System.Text; -using Encelado.Bot.Logging; +using Encelado.Engine.Logging; namespace Encelado.Tests; diff --git a/Encelado/tests/Encelado.Tests/NewsTests.cs b/Encelado/tests/Encelado.Tests/NewsTests.cs index db527c5..7d395c1 100644 --- a/Encelado/tests/Encelado.Tests/NewsTests.cs +++ b/Encelado/tests/Encelado.Tests/NewsTests.cs @@ -1,4 +1,4 @@ -using Encelado.Bot.Baskets; +using Encelado.Engine.Baskets; using Encelado.Core.News; namespace Encelado.Tests; diff --git a/Encelado/tests/Encelado.Tests/PowerButtonTests.cs b/Encelado/tests/Encelado.Tests/PowerButtonTests.cs deleted file mode 100644 index e967b90..0000000 --- a/Encelado/tests/Encelado.Tests/PowerButtonTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using Encelado.Bot.Engine; -using Encelado.Bot.Ui; - -namespace Encelado.Tests; - -/// -/// The start/stop button's enabled state. -/// -/// A bug worth a dedicated file: the window used to disable the button by assigning -/// PowerBtn.IsEnabled = false around an in-flight start. In WPF that replaces the -/// binding with a local value and detaches it permanently, so the button went dead on -/// the first click and the running bot could no longer be stopped from the window. -/// -/// -public class PowerButtonStateTests -{ - private static MainViewModel Vm() => new() { Log = new LogViewModel(100) }; - - private static BotSnapshot Snapshot(BotState state) => TestSnapshots.Minimal(state); - - [Theory] - [InlineData(BotState.Stopped, true)] - [InlineData(BotState.Running, true)] - [InlineData(BotState.Faulted, true)] - [InlineData(BotState.Starting, false)] - [InlineData(BotState.Stopping, false)] - public void TheButtonFollowsTheEngineState(BotState state, bool expected) - { - MainViewModel vm = Vm(); - vm.Apply(Snapshot(state)); - - Assert.Equal(expected, vm.CanToggle); - } - - [Fact] - public void ARunningBotCanAlwaysBeStopped() - { - // The reported symptom, stated as a requirement. - MainViewModel vm = Vm(); - - vm.Apply(Snapshot(BotState.Starting)); - vm.Apply(Snapshot(BotState.Running)); - - Assert.True(vm.IsRunning); - Assert.True(vm.CanToggle, "un bot in esecuzione deve sempre poter essere fermato"); - Assert.Equal("FERMA", vm.PowerText); - } - - [Fact] - public void BusyBlocksTheButtonAndReleasingItRestoresIt() - { - MainViewModel vm = Vm(); - vm.Apply(Snapshot(BotState.Running)); - Assert.True(vm.CanToggle); - - vm.IsBusy = true; - Assert.False(vm.CanToggle); - - vm.IsBusy = false; - Assert.True(vm.CanToggle, "rilasciare il flag deve riabilitare il bottone"); - } - - [Fact] - public void AFullStartStopCycleLeavesTheButtonUsable() - { - MainViewModel vm = Vm(); - - // Start. - vm.IsBusy = true; - vm.Apply(Snapshot(BotState.Starting)); - Assert.False(vm.CanToggle); - - vm.Apply(Snapshot(BotState.Running)); - vm.IsBusy = false; - Assert.True(vm.CanToggle); - - // Stop. - vm.IsBusy = true; - vm.Apply(Snapshot(BotState.Stopping)); - Assert.False(vm.CanToggle); - - vm.Apply(Snapshot(BotState.Stopped)); - vm.IsBusy = false; - Assert.True(vm.CanToggle); - Assert.Equal("AVVIA", vm.PowerText); - } - - [Fact] - public void ChangingBusyRaisesTheNotificationTheBindingNeeds() - { - MainViewModel vm = Vm(); - List raised = []; - vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); - - vm.IsBusy = true; - - // Without this the bound button would never repaint. - Assert.Contains(nameof(MainViewModel.CanToggle), raised); - } - - [Fact] - public void ApplyRaisesCanToggleSoTheButtonTracksTheEngine() - { - MainViewModel vm = Vm(); - vm.Apply(Snapshot(BotState.Stopped)); - - List raised = []; - vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); - - vm.Apply(Snapshot(BotState.Starting)); - - Assert.Contains(nameof(MainViewModel.CanToggle), raised); - Assert.False(vm.CanToggle); - } -} - -/// Proves the WPF behaviour that caused the bug, so the reason stays on record. -[Collection("wpf")] -public class DependencyPropertyBindingTests -{ - [Fact] - public void AssigningABoundPropertyDetachesTheBindingForEver() - { - WpfRunner.Run(() => - { - MainViewModel vm = new() { Log = new LogViewModel(100) }; - Button button = new() { DataContext = vm }; - button.SetBinding(UIElement.IsEnabledProperty, new Binding(nameof(MainViewModel.CanToggle))); - - Assert.NotNull(BindingOperations.GetBindingExpression(button, UIElement.IsEnabledProperty)); - - // Exactly what the window used to do. - button.IsEnabled = false; - - // The binding is gone, and no later view-model change can bring it back. - Assert.Null(BindingOperations.GetBindingExpression(button, UIElement.IsEnabledProperty)); - - vm.IsBusy = false; - Assert.False(button.IsEnabled); - }); - } -} diff --git a/Encelado/tests/Encelado.Tests/TelegramTests.cs b/Encelado/tests/Encelado.Tests/TelegramTests.cs index f329e06..d010423 100644 --- a/Encelado/tests/Encelado.Tests/TelegramTests.cs +++ b/Encelado/tests/Encelado.Tests/TelegramTests.cs @@ -2,8 +2,9 @@ using System.Diagnostics; using System.Net; using System.Net.Http; using System.Text; -using Encelado.Bot.Baskets; -using Encelado.Bot.Engine; +using Encelado.Engine.Baskets; +using Encelado.Engine; +using Encelado.Core.Baskets.History; using Encelado.Core.Notifications; namespace Encelado.Tests; @@ -14,7 +15,7 @@ public class TelegramReportTests [Fact] public void TheHourlyStatusCarriesTheNumbersOfTheDashboard() { - BotSnapshot s = TestSnapshots.Populated(); + BotSnapshot s = SampleSnapshot.Populated(); string text = TelegramReports.Hourly(s, "Telegram: ultimo invio 09:00:00 UTC, coda 0"); Assert.Contains("attivo", text, StringComparison.Ordinal); diff --git a/Encelado/tests/Encelado.Tests/UiBindingTests.cs b/Encelado/tests/Encelado.Tests/UiBindingTests.cs deleted file mode 100644 index 02df58e..0000000 --- a/Encelado/tests/Encelado.Tests/UiBindingTests.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System.Diagnostics; -using System.Text; -using System.Windows; -using System.Windows.Controls; -using Encelado.Bot.Engine; -using Encelado.Bot.Ui; -using Encelado.Bot.Ui.Pages; - -namespace Encelado.Tests; - -/// -/// Renders every page against a fully populated view model and fails if WPF reports a -/// single binding error. -/// -/// This exists because a wrong binding path in XAML is silent: the control simply shows -/// nothing, the build passes, the tests pass, and the defect is found by looking at the -/// running application — if the panel happens to be one you looked at. WPF does report -/// each failure to a trace source, so listening to it turns an invisible mistake into a -/// failing test. -/// -/// -[Collection("wpf")] -public class UiBindingTests -{ - internal static MainViewModel BuildViewModel() - { - MainViewModel vm = new() { StatusLines = 200, Log = new LogViewModel(5_000) }; - vm.Apply(TestSnapshots.Populated()); - - foreach (EventRow row in TestSnapshots.Populated().Events) - { - vm.Log.Enqueue(row); - } - - vm.Log.Flush(); - vm.Clock = "12:00:00"; - return vm; - } - - private static void AssertNoBindingErrors(Func build) - { - WpfRunner.Run(() => - { - using BindingErrorSpy spy = new(); - - UserControl page = build(); - page.DataContext = BuildViewModel(); - - // Measure and arrange forces the whole tree to realise and every binding to - // resolve. Creating the control alone would leave most of them untouched. - page.Measure(new Size(1400, 900)); - page.Arrange(new Rect(0, 0, 1400, 900)); - page.UpdateLayout(); - - Assert.True(spy.Errors.Count == 0, - $"binding non risolti in {page.GetType().Name}:\n{string.Join("\n", spy.Errors)}"); - }); - } - - [Fact] - public void DashboardPageBindsCleanly() => AssertNoBindingErrors(() => new DashboardPage()); - - [Fact] - public void LogPageBindsCleanly() => AssertNoBindingErrors(() => new LogPage()); - - [Fact] - public void SettingsPageBindsCleanly() => AssertNoBindingErrors(() => new SettingsPage()); - - [Fact] - public void TheDashboardShowsTheOpenBasketWithItsCloseButton() - { - WpfRunner.Run(() => - { - DashboardPage page = new() { DataContext = BuildViewModel() }; - page.Measure(new Size(1400, 900)); - page.Arrange(new Rect(0, 0, 1400, 900)); - page.UpdateLayout(); - - List