From f9971253a81a3f54ef735764ce4d583507b8f785 Mon Sep 17 00:00:00 2001 From: Metacube Date: Sun, 24 May 2026 09:40:25 +0200 Subject: [PATCH] Add trading cockpit frontend logic --- trade_web/static/app.js | 980 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 980 insertions(+) create mode 100644 trade_web/static/app.js diff --git a/trade_web/static/app.js b/trade_web/static/app.js new file mode 100644 index 0000000..7c0ffaf --- /dev/null +++ b/trade_web/static/app.js @@ -0,0 +1,980 @@ +const frameContainer = document.querySelector("#frames"); +const symbolSelect = document.querySelector("#symbol"); +const signalModeSelect = document.querySelector("#signal-mode"); +const customSymbol = document.querySelector("#custom-symbol"); +const form = document.querySelector("#symbol-form"); +const refreshButton = document.querySelector("#refresh"); +const demoToggle = document.querySelector("#demo"); +const runBacktestButton = document.querySelector("#run-backtest"); +const runOptimizerButton = document.querySelector("#run-optimizer"); +const runForecastButton = document.querySelector("#run-forecast"); +const cancelOptimizerButton = document.querySelector("#cancel-optimizer"); +const refreshHistoryButton = document.querySelector("#refresh-history"); +const refreshOrdersButton = document.querySelector("#refresh-orders"); +const paperOrderButton = document.querySelector("#paper-order"); +const primaryPaperOrderButton = document.querySelector("#primary-paper-order"); +let optimizerJobId = null; +let optimizerPollTimer = null; +let latestAnalysis = null; + +const statusColor = { + STRONG_BUY: "green", + BUY: "green", + WEAK_BUY: "orange", + STRONG_SELL: "red", + SELL: "red", + WEAK_SELL: "orange", + NONE: "grey", +}; + +const signalModeLabels = { + balanced: "Ausgewogen", + high_precision: "Hohe Trefferwahrscheinlichkeit", + lux_style: "Lux-inspiriert", +}; + +function money(value) { + if (!value) return "--"; + return Number(value).toLocaleString("de-CH", { maximumFractionDigits: value > 100 ? 2 : 5 }); +} + +function compactNumber(value, suffix = "") { + if (!Number.isFinite(value)) return "--"; + const abs = Math.abs(value); + const digits = abs >= 1000 ? 0 : abs >= 100 ? 1 : abs >= 10 ? 2 : 3; + return `${Number(value).toLocaleString("de-CH", { maximumFractionDigits: digits })}${suffix}`; +} + +function formatChartTime(timestamp) { + if (!timestamp) return "--"; + const millis = timestamp > 100000000000 ? timestamp : timestamp * 1000; + return new Date(millis).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit" }); +} + +function chartTicks(min, max, count = 5) { + if (!Number.isFinite(min) || !Number.isFinite(max)) return []; + if (min === max) return [min]; + return Array.from({ length: count }, (_, index) => min + (index / (count - 1)) * (max - min)); +} + +function renderChartFrame({ width, height, padLeft, padRight, padTop, padBottom, minY, maxY, y, xTicks = [], ySuffix = "", zeroY = null, splitX = null }) { + const plotLeft = padLeft; + const plotRight = width - padRight; + const plotTop = padTop; + const plotBottom = height - padBottom; + const yTicks = chartTicks(minY, maxY, 5); + const horizontal = yTicks.map(value => { + const yy = y(value); + return ` + + ${compactNumber(value, ySuffix)} + `; + }).join(""); + const vertical = xTicks.map(tick => ` + + ${tick.label} + `).join(""); + const zero = Number.isFinite(zeroY) && zeroY >= plotTop && zeroY <= plotBottom + ? `` + : ""; + const split = Number.isFinite(splitX) + ? `` + : ""; + return ` + ${horizontal} + ${vertical} + ${zero} + ${split} + + + `; +} + +async function loadConfig() { + const response = await fetch("/api/config"); + const config = await response.json(); + symbolSelect.innerHTML = ""; + for (const symbol of config.available_symbols || []) { + const option = document.createElement("option"); + option.value = symbol; + option.textContent = symbol; + option.selected = symbol === config.symbol; + symbolSelect.append(option); + } + signalModeSelect.innerHTML = ""; + for (const mode of config.available_signal_modes || ["balanced", "high_precision", "lux_style"]) { + const option = document.createElement("option"); + option.value = mode; + option.textContent = signalModeLabels[mode] || mode; + option.selected = mode === (config.signal_mode || "balanced"); + signalModeSelect.append(option); + } +} + +async function saveSettings(symbol, signalMode) { + await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ symbol, signal_mode: signalMode }), + }); + await loadConfig(); +} + +async function refresh() { + refreshButton.disabled = true; + refreshButton.textContent = "Lädt..."; + try { + const demo = demoToggle.checked ? "?demo=1" : ""; + const response = await fetch(`/api/analyze${demo}`); + const data = await response.json(); + latestAnalysis = data; + render(data); + loadHealth(); + } finally { + refreshButton.disabled = false; + refreshButton.textContent = "Aktualisieren"; + } +} + +function render(data) { + document.querySelector("#updated").textContent = `${data.symbol} · ${new Date(data.updated_at * 1000).toLocaleString("de-CH")}`; + renderSignal(data.signal); + renderFrames(data.frames, data.signal); + renderPattern(data.pattern); + renderCorrelations(data.correlations); + renderMacro(data.macro); + renderRiskPlan(data.risk_plan); + renderSummaryChips(data); + renderMethodology(data.methodology); + renderIndicatorAudit(data.indicator_audit); + renderDataQuality(data.data_quality); + renderWarnings(data.warnings || []); +} + +function signalLabel(type) { + if (type === "STRONG_BUY") return "Kaufen"; + if (type === "BUY") return "Kaufen"; + if (type === "WEAK_BUY") return "Beobachten"; + if (type === "STRONG_SELL") return "Short"; + if (type === "SELL") return "Short"; + if (type === "WEAK_SELL") return "Short beobachten"; + return "Warten"; +} + +function decisionCopy(signal, riskPlan) { + if (!signal) return "Noch keine Daten geladen."; + if (riskPlan?.paper_allowed) return "Signal ist stark genug fuer Paper-Trading. Risiko und Stop sind berechnet."; + if (signal.signal_type === "WEAK_BUY") return "Setup entsteht, aber die Bestaetigung reicht noch nicht fuer eine Order."; + if (signal.signal_type === "WEAK_SELL") return "Bearishes Setup entsteht, aber die Bestaetigung reicht noch nicht fuer eine Short-Order."; + if (signal.signal_type === "NONE") return "Kein sauberer Einstieg. Das System wartet auf bessere Bestaetigung."; + return "Signal vorhanden, aber der Risk-Plan blockiert die Ausfuehrung."; +} + +function renderSummaryChips(data) { + document.querySelector("#macro-chip").textContent = data.macro?.label || "--"; + document.querySelector("#risk-chip").textContent = data.risk_plan?.paper_allowed ? "handelbar" : "blockiert"; + document.querySelector("#data-chip").textContent = data.data_quality + ? `${data.data_quality.mode}${data.data_quality.fallbacks ? `/${data.data_quality.fallbacks}` : ""}` + : "--"; + document.querySelector("#mode-chip").textContent = data.signal?.mode_label || "--"; +} + +function renderRiskPlan(plan) { + const target = document.querySelector("#risk-plan"); + if (!target || !plan) return; + const blockers = plan.blockers && plan.blockers.length ? plan.blockers.join(" · ") : "keine"; + target.innerHTML = ` +
Richtung${plan.side || "--"}
+
Paper erlaubt${plan.paper_allowed ? "Ja" : "Nein"}
+
Menge${plan.quantity}
+
Notional${plan.notional}
+
Risiko${plan.risk_amount} (${plan.risk_per_trade_pct}%)
+
Modus${plan.mode}${plan.testnet ? " · Testnet" : ""}
+
Blocker${blockers}
+ `; + paperOrderButton.disabled = !plan.paper_allowed; + primaryPaperOrderButton.disabled = !plan.paper_allowed; +} + +function renderSignal(signal) { + const light = document.querySelector("#signal-light"); + light.className = `signal-light ${statusColor[signal.signal_type] || "grey"}`; + document.querySelector("#signal-type").textContent = signalLabel(signal.signal_type); + document.querySelector("#decision-copy").textContent = decisionCopy(signal, latestAnalysis?.risk_plan); + document.querySelector("#signal-meta").textContent = `${signal.mode_label || "Ausgewogen"} · ${signal.reasons.join(" · ")}`; + document.querySelector("#entry-price").textContent = money(signal.entry_price); + document.querySelector("#stop-loss").textContent = money(signal.stop_loss); + document.querySelector("#target").textContent = money(signal.target); + document.querySelector("#risk-reward").textContent = signal.risk_reward ? signal.risk_reward.toFixed(2) : "--"; + const blockers = signal.blockers || []; + document.querySelector("#signal-blockers").innerHTML = blockers.length + ? blockers.map(blocker => `${blocker}`).join("") + : `Keine Signalblocker`; + renderDecisionGraphic(signal); +} + +function renderDecisionGraphic(signal) { + const target = document.querySelector("#decision-graphic"); + if (!target) return; + const params = signal.params || {}; + const strong = Number(params.strong_buy || 7); + const buy = Number(params.buy || 5); + const weak = Number(params.weak_buy || 3); + const maxScore = Math.max(strong + 1, signal.strength || 0, 1); + const scorePct = Math.max(0, Math.min(100, ((signal.strength || 0) / maxScore) * 100)); + const rr = Number(signal.risk_reward || 0); + const rrTarget = Number(params.rr_good || 1.4); + const rrPct = Math.max(0, Math.min(100, (rr / Math.max(rrTarget * 1.8, 1)) * 100)); + const confidencePct = Math.max(0, Math.min(100, Number(signal.confidence || 0))); + const thresholds = [ + { label: "W", title: "Weak", value: weak }, + { label: "B", title: "Buy/Sell", value: buy }, + { label: "S", title: "Strong", value: strong }, + ].map(item => `${item.label}`).join(""); + target.innerHTML = ` +
+ Signalgrafik + ${signal.side || "--"} · ${signal.signal_type} +
+
+ +
${thresholds}
+ ${signal.strength ?? 0} +
+
+ +
+ ${rr ? rr.toFixed(2) : "--"} +
+
+ +
+ ${signal.confidence ?? 0}% +
+ `; +} + +function renderFrames(frames, signal) { + frameContainer.innerHTML = ""; + const timeframeScores = {}; + const weightedFrames = [["15m", 1], ["30m", 1], ["4h", 2], ["1d", 2]]; + const scoredFrames = weightedFrames.filter(([name]) => frames[name]).map(([name]) => name); + (signal?.score_parts || []).forEach((part, index) => { + timeframeScores[scoredFrames[index]] = part; + }); + for (const [timeframe, frame] of Object.entries(frames)) { + const article = document.createElement("article"); + article.className = "timeframe"; + const statuses = Object.values(frame.statuses || {}); + const green = statuses.filter(status => status.status === "green").length; + const orange = statuses.filter(status => status.status === "orange").length; + const red = statuses.filter(status => status.status === "red").length; + const total = Math.max(statuses.length, 1); + const score = timeframeScores[timeframe] || {}; + article.innerHTML = ` +

${timeframe} ${money(frame.price)}

+
+
+ ${score.points ?? 0} + ${score.label || "neutral"} +
+
+ + + +
+
${statuses.map(status => ``).join("")}
+
+ `; + for (const [name, status] of Object.entries(frame.statuses)) { + const row = document.createElement("div"); + row.className = "status-row"; + row.innerHTML = ` + +
${name}${status.message}
+ `; + article.append(row); + } + frameContainer.append(article); + } +} + +function renderPattern(pattern) { + const target = document.querySelector("#pattern"); + const rows = [ + ["Status", pattern.pattern_detected ? pattern.pattern_type : "Kein W-Muster"], + ["Konfidenz", `${pattern.confidence || 0}%`], + ["Entry", money(pattern.optimal_entry)], + ["Stop", money(pattern.stop_loss)], + ["Ziel", money(pattern.target_price)], + ["R/R", pattern.risk_reward ? pattern.risk_reward.toFixed(2) : "--"], + ]; + target.innerHTML = rows.map(([label, value]) => `
${label}${value}
`).join(""); +} + +function renderCorrelations(rows) { + const target = document.querySelector("#correlations"); + if (!rows.length) { + target.textContent = "Keine Korrelationsdaten"; + return; + } + target.innerHTML = rows.map(row => { + const corrClass = row.correlation >= 0 ? "text-green" : "text-red"; + const strengthClass = row.relative_strength >= 0 ? "text-green" : "text-red"; + return `
+ ${row.symbol} + ${row.correlation ?? "--"} + ${row.relative_strength > 0 ? "+" : ""}${row.relative_strength}% +
`; + }).join(""); +} + +function renderWarnings(warnings) { + document.querySelector("#warnings").innerHTML = warnings.map(warning => `
${warning}
`).join(""); +} + +function renderDataQuality(dataQuality) { + const updated = document.querySelector("#updated"); + if (!dataQuality) return; + const marker = dataQuality.mode === "live" && dataQuality.fallbacks === 0 ? "Live-Daten" : `${dataQuality.mode} · ${dataQuality.fallbacks} Fallbacks`; + updated.textContent = `${updated.textContent} · ${marker}`; +} + +function renderMethodology(methodology) { + const target = document.querySelector("#methodology"); + if (!methodology) { + target.textContent = "Keine Methodikdaten"; + return; + } + target.innerHTML = ` +
+
+ Modell +

${methodology.model_type}

+

${methodology.accuracy_status}

+
+
+ Formel +
    ${methodology.signal_formula.map(item => `
  • ${item}
  • `).join("")}
+
+
+ Eingebaut +

${methodology.macro_status}

+
    ${(methodology.included_inputs || []).map(item => `
  • ${item}
  • `).join("")}
+
+
+ Fehlt noch +
    ${methodology.missing_inputs.map(item => `
  • ${item}
  • `).join("")}
+
+
+ `; +} + +function renderIndicatorAudit(audit) { + const target = document.querySelector("#indicator-audit"); + if (!target || !audit) return; + const checks = (audit.checks || []).map(check => ` +
+ +
+ ${check.name} + ${check.tradingview} + ${check.method} + ${check.note} +
+
+ `).join(""); + const values = Object.entries(audit.values || {}).map(([timeframe, row]) => ` +
+ ${timeframe} + RSI ${row.rsi14 ?? "--"} + MACD ${row.macd ?? "--"} / ${row.macd_signal ?? "--"} + Hist ${row.macd_hist ?? "--"} + EMA20 ${money(row.ema20)} + EMA50 ${money(row.ema50)} + SMA200 ${money(row.sma200)} +
+ `).join(""); + target.innerHTML = ` +
+

${audit.source}

+

${audit.feed_warning}

+
+
${checks}
+
${values}
+ `; +} + +function renderMacro(macro) { + const target = document.querySelector("#macro"); + if (!macro) { + target.textContent = "Keine Makrodaten"; + return; + } + const scoreClass = macro.status === "green" ? "text-green" : macro.status === "red" ? "text-red" : "text-orange"; + const components = (macro.components || []).map(component => ` +
+ +
+ ${component.name} + ${component.message} +
+
+ `).join(""); + target.innerHTML = ` +
+ ${macro.label} + Score: ${macro.score} + ${macro.source_note || ""} +
+
${components}
+ `; +} + +async function runBacktest() { + runBacktestButton.disabled = true; + runBacktestButton.textContent = "Backtest läuft..."; + const target = document.querySelector("#backtest"); + target.innerHTML = `

Historische Binance-Kerzen werden geladen und Strategien verglichen.

`; + try { + const demo = demoToggle.checked ? "?demo=1" : ""; + const response = await fetch(`/api/backtest${demo}`); + const data = await response.json(); + renderBacktest(data); + } finally { + runBacktestButton.disabled = false; + runBacktestButton.textContent = "Backtest starten"; + } +} + +async function runOptimizer() { + runOptimizerButton.disabled = true; + cancelOptimizerButton.disabled = false; + runOptimizerButton.textContent = "Optimiert..."; + const target = document.querySelector("#backtest"); + const progressTarget = document.querySelector("#optimizer-progress"); + target.innerHTML = `

Parameter-Kombinationen werden per Backtest, Out-of-sample und Walk-forward verglichen.

`; + progressTarget.innerHTML = `
`; + try { + const response = await fetch("/api/optimize/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ demo: demoToggle.checked }), + }); + const data = await response.json(); + optimizerJobId = data.job_id; + pollOptimizer(); + } catch (error) { + target.innerHTML = `

Optimizer konnte nicht gestartet werden: ${error}

`; + runOptimizerButton.disabled = false; + cancelOptimizerButton.disabled = true; + runOptimizerButton.textContent = "Live optimieren"; + } +} + +async function pollOptimizer() { + if (!optimizerJobId) return; + const response = await fetch(`/api/optimize/status/${optimizerJobId}`); + const data = await response.json(); + renderOptimizerProgress(data); + if (data.status === "running") { + optimizerPollTimer = window.setTimeout(pollOptimizer, 1200); + return; + } + runOptimizerButton.disabled = false; + cancelOptimizerButton.disabled = true; + runOptimizerButton.textContent = "Live optimieren"; + optimizerJobId = null; + if (data.status === "done" || data.status === "cancelled") { + renderOptimizer(data.result || { best: data.best, candidates: data.candidates, settings: {} }); + } else if (data.status === "error") { + document.querySelector("#backtest").innerHTML = `

Optimizer-Fehler: ${data.error}

`; + } +} + +function renderOptimizerProgress(data) { + const target = document.querySelector("#optimizer-progress"); + const total = data.total || 1; + const pct = Math.round((data.done || 0) / total * 100); + const rows = (data.candidates || []).slice(0, 5); + target.innerHTML = ` +
+
+ ${data.done || 0}/${data.total || "?"} · ${pct}% + Status: ${data.status} +
+
+ ${rows.map(row => ` +
+ Score ${row.score} + ${row.trades} Trades · PF ${row.avg_profit_factor} · OOS ${row.out_of_sample_score} · WF ${row.walk_forward_score} +
+ `).join("")} +
+ ${renderConvergenceChart(data.best_history || [])} + `; +} + +async function loadForecast() { + const target = document.querySelector("#forecast"); + runForecastButton.disabled = true; + runForecastButton.textContent = "Lädt..."; + target.innerHTML = `

Forecast wird aus den letzten 4h-Kerzen berechnet.

`; + try { + const demo = demoToggle.checked ? "?demo=1" : ""; + const response = await fetch(`/api/forecast${demo}`); + const data = await response.json(); + renderForecast(data); + } finally { + runForecastButton.disabled = false; + runForecastButton.textContent = "Forecast laden"; + } +} + +function renderForecast(data) { + const target = document.querySelector("#forecast"); + if (data.error) { + target.innerHTML = `

${data.error}

`; + return; + } + target.innerHTML = ` +
+ ${data.settings.mode} + ${data.settings.timeframe} + ${data.settings.candles} Kerzen + Horizont ${data.settings.horizon} + Vol ${data.metrics.volatility_pct}% +
+ ${renderForecastChart(data)} + ${renderLearningCurve(data.learning_curve || [])} + `; +} + +function renderBacktest(data) { + const target = document.querySelector("#backtest"); + const rows = data.summary || []; + if (!rows.length) { + target.innerHTML = `

Keine Trades im Backtest gefunden.

`; + return; + } + target.innerHTML = ` +
+ ${data.settings.mode} + ${data.settings.candles} Kerzen + Horizont ${data.settings.horizon_candles} x 4h + Backtest nutzt echte 4h/1d-Historie +
+
+
+ SymbolModusTradesWinrateReturnPFDD +
+ ${rows.map(row => ` +
+ ${row.symbol} + ${signalModeLabels[row.mode] || row.mode} + ${row.trades} + ${row.win_rate}% + ${row.total_return_pct}% + ${row.profit_factor} + ${row.max_drawdown_pct}% +
+ `).join("")} +
+ ${renderPriceChart(rows[0]?.chart || {})} + ${renderEquityChart(rows[0]?.equity_curve || [])} + `; +} + +function renderOptimizer(data) { + const target = document.querySelector("#backtest"); + if (!data.best) { + target.innerHTML = `

Keine optimierbaren Trades gefunden.

`; + return; + } + const params = data.best.params; + const quality = data.best.quality || { passed: false, flags: ["Quality-Daten fehlen"] }; + const bestRun = (data.best.best_runs || [])[0] || {}; + const applyDisabled = quality.passed ? "" : "disabled"; + target.innerHTML = ` +
+ ${data.settings.mode} + Score ${data.best.score} + Train ${data.best.train_score} + OOS ${data.best.out_of_sample_score} + WF ${data.best.walk_forward_score} + ${data.best.trades} Trades + PF ${data.best.avg_profit_factor} + Winrate ${data.best.avg_win_rate}% + Return ${data.best.total_return_pct}% + DD ${data.best.max_drawdown_pct}% +
+
+ ${quality.passed ? "Quality-Gate bestanden" : "Quality-Gate blockiert"} + ${quality.flags && quality.flags.length ? quality.flags.join(" · ") : "keine Warnungen"} +
+
+ Vorschlag +

weak=${params.weak_buy}, buy=${params.buy}, strong=${params.strong_buy}, R/R=${params.rr_good}/${params.rr_excellent}, RSI=${params.rsi_oversold}/${params.rsi_bullish}/${params.rsi_overbought}

+ +
+ ${renderConvergenceChart(data.convergence || [])} + ${renderPriceChart(bestRun.chart || {})} + ${renderEquityChart(bestRun.equity_curve || [])} + `; + const applyButton = document.querySelector("#apply-optimizer"); + if (!quality.passed) return; + applyButton.addEventListener("click", async () => { + await fetch("/api/optimize/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signal_params: params, symbol_params: data.best.symbol_params || {} }), + }); + await refresh(); + }); +} + +function renderForecastChart(data) { + const history = data.history || []; + const forecast = data.forecast || []; + if (!history.length || !forecast.length) return ""; + const width = 760; + const height = 330; + const padLeft = 74; + const padRight = 24; + const padTop = 20; + const padBottom = 42; + const allValues = [ + ...history.flatMap(point => [point.high, point.low]), + ...forecast.flatMap(point => [point.upper, point.lower, point.price]), + ]; + const min = Math.min(...allValues); + const max = Math.max(...allValues); + const total = history.length + forecast.length; + const x = index => padLeft + index * ((width - padLeft - padRight) / Math.max(total - 1, 1)); + const y = value => height - padBottom - ((value - min) / Math.max(max - min, 1)) * (height - padTop - padBottom); + const histLine = history.map((point, index) => `${index ? "L" : "M"}${x(index).toFixed(1)} ${y(point.close).toFixed(1)}`).join(" "); + const start = history.length - 1; + const forecastLine = forecast.map((point, index) => `${index ? "L" : "M"}${x(start + index + 1).toFixed(1)} ${y(point.price).toFixed(1)}`).join(" "); + const upper = forecast.map((point, index) => `${index ? "L" : "M"}${x(start + index + 1).toFixed(1)} ${y(point.upper).toFixed(1)}`).join(" "); + const lower = forecast.map((point, index) => `L${x(start + forecast.length - index).toFixed(1)} ${y(forecast[forecast.length - 1 - index].lower).toFixed(1)}`).join(" "); + const splitX = x(start); + const firstTime = history[0]?.time; + const lastHistoryTime = history[history.length - 1]?.time; + const forecastLabel = `+${forecast[forecast.length - 1].step} Kerzen`; + const frame = renderChartFrame({ + width, + height, + padLeft, + padRight, + padTop, + padBottom, + minY: min, + maxY: max, + y, + xTicks: [ + { x: x(0), label: formatChartTime(firstTime), anchor: "start" }, + { x: splitX, label: formatChartTime(lastHistoryTime) }, + { x: x(total - 1), label: forecastLabel, anchor: "end" }, + ], + splitX, + }); + return ` +
+ Forecast-Pfad + + ${frame} + + + + Forecast + +
HistorieForecastBand = Unsicherheit
+
+ `; +} + +function renderLearningCurve(points) { + if (!points.length) return ""; + const series = points.map(point => ({ x: point.window, y: point.error_pct })); + return renderLineCard("Lernkurve: Fehler sinkt mit mehr Historie", series, "window", "error_pct", true); +} + +function renderConvergenceChart(points) { + if (!points.length) return ""; + const series = points.map(point => ({ x: point.step, y: point.best_score })); + return renderLineCard("Optimizer-Konvergenz: bester Score", series, "step", "best_score", false); +} + +function renderLineCard(title, points, xLabel, yLabel, invertGood) { + const width = 720; + const height = 250; + const padLeft = 66; + const padRight = 22; + const padTop = 20; + const padBottom = 42; + const xs = points.map(point => point.x); + const ys = points.map(point => point.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const x = value => padLeft + ((value - minX) / Math.max(maxX - minX, 1)) * (width - padLeft - padRight); + const y = value => height - padBottom - ((value - minY) / Math.max(maxY - minY, 1)) * (height - padTop - padBottom); + const line = points.map((point, index) => `${index ? "L" : "M"}${x(point.x).toFixed(1)} ${y(point.y).toFixed(1)}`).join(" "); + const last = points[points.length - 1]; + const frame = renderChartFrame({ + width, + height, + padLeft, + padRight, + padTop, + padBottom, + minY, + maxY, + y, + ySuffix: yLabel.includes("pct") ? "%" : "", + zeroY: minY < 0 && maxY > 0 ? y(0) : null, + xTicks: [ + { x: x(minX), label: compactNumber(minX), anchor: "start" }, + { x: x((minX + maxX) / 2), label: compactNumber((minX + maxX) / 2) }, + { x: x(maxX), label: compactNumber(maxX), anchor: "end" }, + ], + }); + return ` +
+ ${title} + + ${frame} + + + ${yLabel} + ${xLabel} + +
${xLabel}: ${last.x}${yLabel}: ${last.y}
+
+ `; +} + +async function loadHistory() { + const response = await fetch("/api/history?limit=12"); + const data = await response.json(); + const target = document.querySelector("#history"); + target.innerHTML = (data.runs || []).map(run => { + const payload = run.payload || {}; + const best = payload.best || (payload.summary || [])[0] || {}; + return `
+ #${run.id} ${run.kind} + ${new Date(run.created_at * 1000).toLocaleString("de-CH")} + ${run.label || "--"} + Trades ${best.trades ?? "--"} · Return ${best.total_return_pct ?? "--"} · Score ${best.score ?? "--"} +
`; + }).join("") || `

Keine gespeicherten Runs.

`; +} + +async function loadHealth() { + const response = await fetch("/api/health"); + const data = await response.json(); + const target = document.querySelector("#health"); + if (!target) return; + target.innerHTML = ` +
Status${data.status}
+
Execution${data.execution.mode} · Kill ${data.execution.kill_switch ? "an" : "aus"}
+
Exchange${data.exchange_guard.live_ready ? "live bereit" : data.exchange_guard.blockers.join(" · ")}
+
Risk/Trade${data.risk_management.risk_per_trade_pct}%
+
Optimizer${data.optimizer_jobs.running} laufend · ${data.optimizer_jobs.total} total
+ `; +} + +function renderPriceChart(chart) { + const candles = chart.candles || []; + if (!candles.length) return ""; + const trades = chart.trades || []; + const width = 720; + const height = 300; + const padLeft = 72; + const padRight = 22; + const padTop = 20; + const padBottom = 42; + const lows = candles.map(candle => candle.low); + const highs = candles.map(candle => candle.high); + const min = Math.min(...lows); + const max = Math.max(...highs); + const xStep = (width - padLeft - padRight) / Math.max(candles.length, 1); + const y = value => height - padBottom - ((value - min) / Math.max(max - min, 1)) * (height - padTop - padBottom); + const candleSvg = candles.map((candle, index) => { + const x = padLeft + index * xStep + xStep / 2; + const color = candle.close >= candle.open ? "up" : "down"; + const bodyTop = y(Math.max(candle.open, candle.close)); + const bodyHeight = Math.max(2, Math.abs(y(candle.open) - y(candle.close))); + return ` + + + `; + }).join(""); + const firstTime = candles[0].time || 0; + const lastTime = candles[candles.length - 1].time || firstTime + 1; + const timeX = time => padLeft + ((time - firstTime) / Math.max(lastTime - firstTime, 1)) * (width - padLeft - padRight); + const tradeSvg = trades.map(trade => { + const x = timeX(trade.entry_time || firstTime); + return ` + + + + + `; + }).join(""); + const frame = renderChartFrame({ + width, + height, + padLeft, + padRight, + padTop, + padBottom, + minY: min, + maxY: max, + y, + xTicks: [ + { x: timeX(firstTime), label: formatChartTime(firstTime), anchor: "start" }, + { x: timeX((firstTime + lastTime) / 2), label: formatChartTime((firstTime + lastTime) / 2) }, + { x: timeX(lastTime), label: formatChartTime(lastTime), anchor: "end" }, + ], + }); + return ` +
+ Preis / Trades + + ${frame} + ${candleSvg} + ${tradeSvg} + Preis + +
gruen: Aufwaertskerzerot: Abwaertskerze/Exit
+
+ `; +} + +async function loadPaperOrders() { + const response = await fetch("/api/paper/orders"); + const data = await response.json(); + const target = document.querySelector("#paper-orders"); + target.innerHTML = (data.orders || []).map(order => ` +
+ #${order.id} ${order.symbol} + ${order.side} · ${order.status} + Menge ${order.quantity} + Entry ${money(order.entry)} · Stop ${money(order.stop)} · Ziel ${money(order.target)} +
+ `).join("") || `

Keine Paper Orders.

`; +} + +async function createPaperOrder() { + if (!latestAnalysis) return; + const response = await fetch("/api/paper/order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signal: latestAnalysis.signal }), + }); + if (!response.ok) { + const data = await response.json(); + document.querySelector("#paper-orders").innerHTML = `

${data.error}: ${(data.risk_plan?.blockers || []).join(" · ")}

`; + return; + } + await loadPaperOrders(); +} + +function renderEquityChart(points) { + if (!points.length) return ""; + const width = 720; + const height = 250; + const padLeft = 66; + const padRight = 22; + const padTop = 20; + const padBottom = 42; + const values = points.map(point => point.equity_pct); + const dds = points.map(point => -Math.abs(point.drawdown_pct || 0)); + const min = Math.min(...values, ...dds, -1); + const max = Math.max(...values, 1); + const x = index => padLeft + index * ((width - padLeft - padRight) / Math.max(points.length - 1, 1)); + const y = value => height - padBottom - ((value - min) / Math.max(max - min, 1)) * (height - padTop - padBottom); + const equityLine = points.map((point, index) => `${index ? "L" : "M"}${x(index).toFixed(1)} ${y(point.equity_pct).toFixed(1)}`).join(" "); + const drawdownLine = points.map((point, index) => `${index ? "L" : "M"}${x(index).toFixed(1)} ${y(-Math.abs(point.drawdown_pct || 0)).toFixed(1)}`).join(" "); + const frame = renderChartFrame({ + width, + height, + padLeft, + padRight, + padTop, + padBottom, + minY: min, + maxY: max, + y, + ySuffix: "%", + zeroY: y(0), + xTicks: [ + { x: x(0), label: "Start", anchor: "start" }, + { x: x(Math.floor((points.length - 1) / 2)), label: "Mitte" }, + { x: x(points.length - 1), label: "Ende", anchor: "end" }, + ], + }); + return ` +
+ Equity / Drawdown + + ${frame} + + + % + +
Equity %Drawdown %
+
+ `; +} + +function activateTab(name) { + document.querySelectorAll(".tab-button").forEach(button => { + button.classList.toggle("active", button.dataset.tab === name); + }); + document.querySelectorAll(".tab-panel").forEach(panel => { + panel.classList.toggle("active", panel.id === `tab-${name}`); + }); + if (name === "optimizer") loadHistory(); + if (name === "forecast") loadForecast(); + if (name === "risk") { + loadHealth(); + loadPaperOrders(); + } +} + +form.addEventListener("submit", async event => { + event.preventDefault(); + const symbol = (customSymbol.value || symbolSelect.value).trim().toUpperCase(); + const signalMode = signalModeSelect.value; + if (!symbol) return; + await saveSettings(symbol, signalMode); + customSymbol.value = ""; + await refresh(); +}); + +refreshButton.addEventListener("click", refresh); +runBacktestButton.addEventListener("click", runBacktest); +runOptimizerButton.addEventListener("click", runOptimizer); +runForecastButton.addEventListener("click", loadForecast); +refreshHistoryButton.addEventListener("click", loadHistory); +refreshOrdersButton.addEventListener("click", loadPaperOrders); +paperOrderButton.addEventListener("click", createPaperOrder); +primaryPaperOrderButton.addEventListener("click", createPaperOrder); +cancelOptimizerButton.addEventListener("click", async () => { + if (!optimizerJobId) return; + await fetch(`/api/optimize/cancel/${optimizerJobId}`, { method: "POST" }); + cancelOptimizerButton.disabled = true; +}); +demoToggle.addEventListener("change", refresh); +signalModeSelect.addEventListener("change", async () => { + await saveSettings(symbolSelect.value, signalModeSelect.value); + await refresh(); +}); +document.querySelectorAll(".tab-button").forEach(button => { + button.addEventListener("click", () => activateTab(button.dataset.tab)); +}); + +loadConfig().then(refresh);