const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; const sides = { male: { canvas: $("#maleCanvas"), ctx: $("#maleCanvas").getContext("2d"), surface: [], skeleton: [], neuron: null, connections: [], direction: "outputs", mode: "demo", color: "#7cff99", activity: 0, cognitiveState: null, camera: { rx: -0.12, ry: 0.45, zoom: 1 }, }, female: { canvas: $("#femaleCanvas"), ctx: $("#femaleCanvas").getContext("2d"), surface: [], skeleton: [], neuron: null, connections: [], direction: "outputs", mode: "demo", color: "#ff89c9", activity: 0, cognitiveState: null, camera: { rx: -0.12, ry: 0.45, zoom: 1 }, }, }; const state = { syncCamera: true, autoRotate: true, dragging: null, lastX: 0, lastY: 0, chatRunId: 0, autoChat: false, arenaRunId: 0, arenaAuto: false, backtestJobId: null, backtestPolling: false, }; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } async function fetchJSON(url) { const response = await fetch(url); const data = await response.json(); if (!response.ok) throw new Error(data.error || data.message || "Request failed"); return data; } async function postJSON(url, payload, timeoutMs = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: controller.signal, cache: "no-store", }); const data = await response.json(); if (!response.ok) throw new Error(data.error || data.message || "Request failed"); return data; } catch (error) { if (error.name === "AbortError") { throw new Error("回應逾時,已自動解除卡住狀態,請再試一次。"); } throw error; } finally { clearTimeout(timer); } } function showNotice(message) { const el = $("#notice"); if (!message) { el.classList.add("hidden"); el.textContent = ""; return; } el.textContent = message; el.classList.remove("hidden"); } async function loadSources() { try { const data = await fetchJSON("/api/source"); sides.male.mode = data.male_mode || data.active_mode || "demo"; sides.female.mode = data.female_mode || "demo"; $("#maleMode").textContent = sides.male.mode.toUpperCase(); $("#femaleMode").textContent = sides.female.mode.toUpperCase(); $("#sourceStatus").textContent = `MALE ${sides.male.mode.toUpperCase()} · FEMALE ${sides.female.mode.toUpperCase()}`; } catch (error) { $("#sourceStatus").textContent = "SOURCE ERROR"; } } async function loadSurfaces() { const [maleResult, femaleResult] = await Promise.allSettled([ fetchJSON("/api/cns-surface?max_edges=2200"), fetchJSON("/api/banc/surface?max_edges=2200"), ]); if (maleResult.status === "fulfilled") { sides.male.surface = maleResult.value.segments || []; } if (femaleResult.status === "fulfilled") { sides.female.surface = femaleResult.value.segments || []; } const problems = []; if (maleResult.status === "rejected") problems.push("MaleCNS surface"); if (femaleResult.status === "rejected") problems.push("BANC surface"); if (problems.length) showNotice(problems.join("、") + " 載入失敗。"); } async function compare() { const query = $("#searchInput").value.trim(); if (!query) return; $("#compareBtn").disabled = true; $("#compareBtn").textContent = "比較中…"; showNotice(""); clearSide("male"); clearSide("female"); try { const [maleData, femaleData] = await Promise.all([ fetchJSON("/api/neurons?q=" + encodeURIComponent(query) + "&limit=12"), fetchJSON("/api/banc/neurons?q=" + encodeURIComponent(query) + "&limit=12"), ]); const maleNeuron = pickBestNeuron(maleData.results || [], query); const femaleNeuron = pickBestNeuron(femaleData.results || [], query); sides.male.mode = maleData.mode || sides.male.mode; sides.female.mode = femaleData.mode || sides.female.mode; $("#maleMode").textContent = sides.male.mode.toUpperCase(); $("#femaleMode").textContent = sides.female.mode.toUpperCase(); if (!maleNeuron && !femaleNeuron) { throw new Error("MaleCNS 與 BANC 都找不到這個 neuron。"); } await Promise.all([ maleNeuron ? selectNeuron("male", maleNeuron) : Promise.resolve(), femaleNeuron ? selectNeuron("female", femaleNeuron) : Promise.resolve(), ]); updatePairSummary(query); const notices = [maleData.notice, femaleData.notice].filter(Boolean); if (notices.length) showNotice(notices.join(" | ")); } catch (error) { showNotice(error.message); } finally { $("#compareBtn").disabled = false; $("#compareBtn").textContent = "比較"; } } function pickBestNeuron(rows, query) { if (!rows.length) return null; const q = query.toLowerCase(); return rows.find((row) => String(row.type || "").toLowerCase() === q) || rows.find((row) => String(row.name || "").toLowerCase().includes(q)) || rows[0]; } function clearSide(sideName) { const side = sides[sideName]; side.neuron = null; side.skeleton = []; side.connections = []; $("#" + sideName + "Title").textContent = sideName === "male" ? "MaleCNS" : "BANC"; $("#" + sideName + "Meta").textContent = "等待搜尋"; $("#" + sideName + "Segments").textContent = "0 segments"; $("#" + sideName + "Connections").innerHTML = '
等待 neuron
'; } async function selectNeuron(sideName, neuron) { const side = sides[sideName]; side.neuron = neuron; const label = neuron.name || neuron.type || neuron.bodyid; $("#" + sideName + "Title").textContent = label; renderMeta(sideName, neuron); await Promise.all([ loadSkeleton(sideName), loadConnections(sideName), ]); } function renderMeta(sideName, neuron) { const values = [ ["id", neuron.bodyid], ["type", neuron.type], ["class", neuron.superclass], ["side", neuron.somaSide], ].filter(([, value]) => value !== undefined && value !== null && value !== ""); $("#" + sideName + "Meta").innerHTML = values.length ? values.map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}` ).join("") : "無 metadata"; } async function loadSkeleton(sideName) { const side = sides[sideName]; if (!side.neuron) return; const id = side.neuron.bodyid || side.neuron.type || side.neuron.name; const url = sideName === "male" ? "/api/skeleton/" + encodeURIComponent(id) + "?max_segments=2800" : "/api/banc/skeleton/" + encodeURIComponent(id) + "?max_segments=2800"; $("#" + sideName + "Segments").textContent = "loading…"; try { const data = await fetchJSON(url); side.skeleton = data.segments || []; $("#" + sideName + "Segments").textContent = (data.segment_count || side.skeleton.length) + " segments"; } catch (error) { side.skeleton = []; $("#" + sideName + "Segments").textContent = "0 segments"; } } async function loadConnections(sideName) { const side = sides[sideName]; if (!side.neuron) return; const id = side.neuron.bodyid || side.neuron.type || side.neuron.name; const base = sideName === "male" ? "/api/connections/" : "/api/banc/connections/"; const url = base + encodeURIComponent(id) + "?direction=" + encodeURIComponent(side.direction) + "&limit=20"; $("#" + sideName + "Connections").innerHTML = '
讀取 connectivity…
'; try { const data = await fetchJSON(url); side.connections = data.results || []; renderConnections(sideName); updateDifference(); } catch (error) { side.connections = []; $("#" + sideName + "Connections").innerHTML = '
connectivity 讀取失敗
'; updateDifference(); } } function renderConnections(sideName) { const side = sides[sideName]; const root = $("#" + sideName + "Connections"); if (!side.connections.length) { root.innerHTML = '
沒有可顯示的 partner
'; return; } const maxWeight = Math.max( ...side.connections.map((row) => Number(row.weight) || 0), 1 ); root.innerHTML = side.connections.map((row) => { const weight = Number(row.weight) || 0; const percent = Math.max(4, Math.round(weight / maxWeight * 100)); const label = row.name || row.type || row.partner || "unknown"; const sub = [row.type, row.superclass, row.somaSide] .filter(Boolean) .join(" · "); return `
${escapeHtml(weight)} ${escapeHtml(label)} ${escapeHtml(sub)}
`; }).join(""); } function partnerKey(row) { return String(row.type || row.name || row.partner || "").trim(); } function updateDifference() { const maleMap = new Map(); const femaleMap = new Map(); for (const row of sides.male.connections) { const key = partnerKey(row); if (key) maleMap.set(key, row); } for (const row of sides.female.connections) { const key = partnerKey(row); if (key) femaleMap.set(key, row); } const maleKeys = new Set(maleMap.keys()); const femaleKeys = new Set(femaleMap.keys()); const common = [...maleKeys].filter((key) => femaleKeys.has(key)); const maleOnly = [...maleKeys].filter((key) => !femaleKeys.has(key)); const femaleOnly = [...femaleKeys].filter((key) => !maleKeys.has(key)); $("#commonCount").textContent = common.length; $("#maleOnlyCount").textContent = maleOnly.length; $("#femaleOnlyCount").textContent = femaleOnly.length; const maleTop = Number(sides.male.connections[0]?.weight) || 0; const femaleTop = Number(sides.female.connections[0]?.weight) || 0; if (maleTop) { const delta = ((femaleTop - maleTop) / maleTop) * 100; $("#weightDelta").textContent = (delta >= 0 ? "+" : "") + delta.toFixed(1) + "%"; } else { $("#weightDelta").textContent = "—"; } renderChips("#commonPartners", common); renderChips("#maleOnlyPartners", maleOnly); renderChips("#femaleOnlyPartners", femaleOnly); } function renderChips(selector, values) { const root = $(selector); root.innerHTML = values.length ? values.slice(0, 18).map((value) => `${escapeHtml(value)}` ).join("") : '—'; } function updatePairSummary(query) { const male = sides.male.neuron; const female = sides.female.neuron; if (male && female) { const sameType = male.type && female.type && String(male.type).toLowerCase() === String(female.type).toLowerCase(); $("#pairTitle").textContent = sameType ? `${male.type} · shared cell type` : `${male.type || male.name || query} ↔ ${female.type || female.name || query}`; } else if (male) { $("#pairTitle").textContent = "只有 MaleCNS 找到配對"; } else if (female) { $("#pairTitle").textContent = "只有 BANC 找到配對"; } else { $("#pairTitle").textContent = "沒有配對"; } } function getBounds(side) { const source = side.surface.length ? side.surface : side.skeleton; if (!source.length) return { cx: 0, cy: 0, cz: 0, extent: 1 }; let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; for (const seg of source) { for (const p of [ [Number(seg.x1), Number(seg.y1), Number(seg.z1)], [Number(seg.x2), Number(seg.y2), Number(seg.z2)], ]) { if (!p.every(Number.isFinite)) continue; minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]); minZ = Math.min(minZ, p[2]); maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]); maxZ = Math.max(maxZ, p[2]); } } if (!Number.isFinite(minX)) return { cx: 0, cy: 0, cz: 0, extent: 1 }; return { cx: (minX + maxX) / 2, cy: (minY + maxY) / 2, cz: (minZ + maxZ) / 2, extent: Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1), }; } function rotatePoint(point, camera) { let [x, y, z] = point; const cy = Math.cos(camera.ry); const sy = Math.sin(camera.ry); const x1 = x * cy + z * sy; const z1 = -x * sy + z * cy; const cx = Math.cos(camera.rx); const sx = Math.sin(camera.rx); const y1 = y * cx - z1 * sx; const z2 = y * sx + z1 * cx; return [x1, y1, z2]; } function project(point, bounds, camera, width, height) { const normalized = [ (point[0] - bounds.cx) / bounds.extent * 2.15, -(point[1] - bounds.cy) / bounds.extent * 2.15, (point[2] - bounds.cz) / bounds.extent * 2.15, ]; const [x, y, z] = rotatePoint(normalized, camera); const cameraDistance = 4.2; const perspective = cameraDistance / Math.max(.8, cameraDistance - z); const base = Math.min(width, height) * .38 * camera.zoom; return { x: width / 2 + x * base * perspective, y: height / 2 + y * base * perspective, z, p: perspective, }; } function resizeCanvas(canvas, ctx) { const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); const pixelWidth = Math.max(1, Math.round(rect.width * dpr)); const pixelHeight = Math.max(1, Math.round(rect.height * dpr)); if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { width: rect.width, height: rect.height }; } function hexToRgba(hex, alpha) { const clean = hex.replace("#", ""); const value = parseInt(clean, 16); return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha})`; } function drawSegments(ctx, segments, bounds, camera, width, height, color, alphaMin, alphaMax, lineWidth) { const projected = []; for (const seg of segments) { const aRaw = [Number(seg.x1), Number(seg.y1), Number(seg.z1)]; const bRaw = [Number(seg.x2), Number(seg.y2), Number(seg.z2)]; if (![...aRaw, ...bRaw].every(Number.isFinite)) continue; const a = project(aRaw, bounds, camera, width, height); const b = project(bRaw, bounds, camera, width, height); projected.push({ a, b, z: (a.z + b.z) / 2 }); } projected.sort((a, b) => a.z - b.z); for (const line of projected) { const depth = Math.max(0, Math.min(1, (line.z + 2.2) / 4.4)); const alpha = alphaMin + depth * (alphaMax - alphaMin); ctx.beginPath(); ctx.moveTo(line.a.x, line.a.y); ctx.lineTo(line.b.x, line.b.y); ctx.strokeStyle = hexToRgba(color, alpha); ctx.lineWidth = Math.max(.35, lineWidth * ((line.a.p + line.b.p) / 2)); ctx.stroke(); } } function drawSide(sideName) { const side = sides[sideName]; const { width, height } = resizeCanvas(side.canvas, side.ctx); const ctx = side.ctx; ctx.clearRect(0, 0, width, height); const bounds = getBounds(side); if (side.surface.length) { drawSegments( ctx, side.surface, bounds, side.camera, width, height, "#7b857e", .025, .12, .55 ); } if (side.skeleton.length) { const boost = Math.max(0, Math.min(1, side.activity || 0)); drawSegments( ctx, side.skeleton, bounds, side.camera, width, height, side.color, .34 + boost * .18, Math.min(1, .98 + boost * .02), 1.25 + boost * 1.15 ); } if (!side.surface.length && !side.skeleton.length) { ctx.fillStyle = "rgba(255,255,255,.20)"; ctx.font = "12px system-ui"; ctx.textAlign = "center"; ctx.fillText("等待 CNS 資料", width / 2, height / 2); } } function animate() { if (state.autoRotate && !state.dragging) { if (state.syncCamera) { sides.male.camera.ry += .0015; syncCameraFrom("male"); } else { sides.male.camera.ry += .0015; sides.female.camera.ry += .0015; } } sides.male.activity *= .985; sides.female.activity *= .985; drawSide("male"); drawSide("female"); requestAnimationFrame(animate); } function syncCameraFrom(sourceName) { const targetName = sourceName === "male" ? "female" : "male"; sides[targetName].camera = { ...sides[sourceName].camera }; } function attachCanvasControls(sideName) { const side = sides[sideName]; const wrap = side.canvas.closest(".viewer-wrap"); side.canvas.addEventListener("pointerdown", (event) => { state.dragging = sideName; state.lastX = event.clientX; state.lastY = event.clientY; wrap.classList.add("dragging"); side.canvas.setPointerCapture(event.pointerId); }); side.canvas.addEventListener("pointermove", (event) => { if (state.dragging !== sideName) return; const dx = event.clientX - state.lastX; const dy = event.clientY - state.lastY; state.lastX = event.clientX; state.lastY = event.clientY; side.camera.ry += dx * .008; side.camera.rx += dy * .008; if (state.syncCamera) syncCameraFrom(sideName); }); const finish = (event) => { if (state.dragging !== sideName) return; state.dragging = null; wrap.classList.remove("dragging"); if (side.canvas.hasPointerCapture(event.pointerId)) { side.canvas.releasePointerCapture(event.pointerId); } }; side.canvas.addEventListener("pointerup", finish); side.canvas.addEventListener("pointercancel", finish); side.canvas.addEventListener("wheel", (event) => { event.preventDefault(); side.camera.zoom *= event.deltaY > 0 ? .92 : 1.08; side.camera.zoom = Math.max(.38, Math.min(3.5, side.camera.zoom)); if (state.syncCamera) syncCameraFrom(sideName); }, { passive: false }); } function renderActivity(sideName, activations, behavior) { const root = $("#" + sideName + "Activity"); const behaviorEl = $("#" + sideName + "Behavior"); behaviorEl.textContent = behavior || "等待刺激"; root.innerHTML = (activations || []).map((item) => { const level = Math.max(0, Math.min(100, Number(item.level) || 0)); return `
${escapeHtml(item.pathway)}
${level}%
`; }).join(""); } function renderMindState(sideName, cognitiveState, goal, decision) { const root = $("#" + sideName + "Mind"); if (!root || !cognitiveState) return; const fields = [ ["飢餓", cognitiveState.hunger], ["警戒", cognitiveState.alertness], ["好奇", cognitiveState.curiosity], ["社交", cognitiveState.social_drive], ]; const memory = Array.isArray(cognitiveState.memory) && cognitiveState.memory.length ? cognitiveState.memory[cognitiveState.memory.length - 1] : "尚無短期記憶"; root.innerHTML = fields.map(([label, value]) => `
${escapeHtml(label)} ${escapeHtml(value ?? 0)}%
`).join("") + `
目標:${escapeHtml(goal || cognitiveState.goal || "觀察")}
決策:${escapeHtml(decision || cognitiveState.decision || "等待")}
記憶:${escapeHtml(memory)}
`; } function appendChatMessage(sideName, text, meta = "") { const log = $("#chatLog"); const empty = log.querySelector(".chat-empty"); if (empty) empty.remove(); const row = document.createElement("div"); row.className = "chat-message " + sideName; const label = sideName === "male" ? "♂ MaleCNS" : "♀ BANC"; row.innerHTML = `
${escapeHtml(text)} ${escapeHtml(label)}${meta ? " · " + escapeHtml(meta) : ""}
`; log.appendChild(row); log.scrollTop = log.scrollHeight; } function pulseBrain(sideName, strength = 1) { const card = document.querySelector(`.brain-card[data-side="${sideName}"]`); sides[sideName].activity = Math.max(sides[sideName].activity || 0, strength); card.classList.remove("pulse-male", "pulse-female"); void card.offsetWidth; card.classList.add(sideName === "male" ? "pulse-male" : "pulse-female"); setTimeout(() => { card.classList.remove("pulse-male", "pulse-female"); }, 900); } function setSignalActive(active, sender = null) { const channel = document.querySelector(".signal-channel"); channel.classList.toggle("active", active); $("#signalStatus").textContent = active ? (sender === "male" ? "♂ → ♀ SIGNAL TRANSMISSION" : "♀ → ♂ SIGNAL TRANSMISSION") : "NEURAL SIGNAL CHANNEL"; } async function sendBrainSignal(sender, text, turn = 0, appendInput = true) { const receiver = sender === "male" ? "female" : "male"; if (appendInput) appendChatMessage(sender, text, "傳出刺激"); setSignalActive(true, sender); pulseBrain(sender, .45); try { const data = await postJSON("/api/brain-chat", { sender, text, turn, male_neuron_type: sides.male.neuron?.type || sides.male.neuron?.name || null, female_neuron_type: sides.female.neuron?.type || sides.female.neuron?.name || null, receiver_state: sides[receiver].cognitiveState, }); sides[receiver].cognitiveState = data.cognitive_state || sides[receiver].cognitiveState; renderActivity(receiver, data.activations || [], data.behavior); renderMindState(receiver, data.cognitive_state, data.goal, data.decision); pulseBrain(receiver, .95); appendChatMessage( receiver, data.reply, `${data.behavior} · 目標:${data.goal || "—"} · 決策:${data.decision || "—"}` ); return { sender: receiver, text: data.reply, nextSignal: data.next_signal || data.reply, result: data, }; } finally { setSignalActive(false); } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function runAutoChat() { state.chatRunId += 1; const runId = state.chatRunId; state.autoChat = true; $("#autoChatBtn").disabled = true; $("#stopChatBtn").classList.add("active"); let sender = "male"; let text = "我偵測到你正在附近移動。"; appendChatMessage(sender, text, "自動對話起始"); pulseBrain(sender, .55); for (let turn = 0; turn < 8; turn += 1) { if (!state.autoChat || runId !== state.chatRunId) break; try { const next = await sendBrainSignal(sender, text, turn, false); sender = next.sender; text = next.nextSignal; } catch (error) { showNotice("Brain-to-Brain 模擬失敗:" + error.message); break; } await sleep(850); } if (runId === state.chatRunId) { state.autoChat = false; $("#autoChatBtn").disabled = false; $("#stopChatBtn").classList.remove("active"); } } function stopAutoChat() { state.autoChat = false; state.chatRunId += 1; $("#autoChatBtn").disabled = false; $("#stopChatBtn").classList.remove("active"); setSignalActive(false); } function clearChat() { stopAutoChat(); $("#chatLog").innerHTML = '
按「讓牠們自己聊」開始,或在下方輸入一個刺激訊息。
'; $("#maleActivity").innerHTML = ""; $("#femaleActivity").innerHTML = ""; $("#maleMind").innerHTML = ""; $("#femaleMind").innerHTML = ""; $("#maleBehavior").textContent = "等待刺激"; $("#femaleBehavior").textContent = "等待刺激"; sides.male.activity = 0; sides.female.activity = 0; sides.male.cognitiveState = null; sides.female.cognitiveState = null; } async function sendManualChat() { const sender = $("#chatSender").value; const input = $("#chatInput"); const button = $("#sendChatBtn"); const text = input.value.trim(); if (!text) return; input.value = ""; const originalText = button.textContent; button.disabled = true; button.textContent = "傳送中…"; $("#signalStatus").textContent = "PROCESSING SIGNAL…"; try { await sendBrainSignal(sender, text, 0, true); } catch (error) { showNotice("訊息模擬失敗:" + error.message); setSignalActive(false); } finally { button.disabled = false; button.textContent = originalText; if (!document.querySelector(".signal-channel").classList.contains("active")) { $("#signalStatus").textContent = "NEURAL SIGNAL CHANNEL"; } } } function formatMoney(value) { const number = Number(value) || 0; return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0, }).format(number); } function renderSensoryChannels(sideName, trader) { const root = $("#" + sideName + "Sensory"); if (!root) return; const channels = trader.sensory_channels || {}; const labels = { trend: "趨勢", momentum: "動能", flow: "資金流", structure: "結構", location: "位置", risk: "風險", }; root.innerHTML = Object.entries(labels).map(([key, label]) => { const value = Number(channels[key] ?? 0); return `
${escapeHtml(label)} ${Number.isFinite(value) ? value.toFixed(2) : "—"}
`; }).join(""); } function formatHoldBars(bars) { const totalMinutes = Math.max(0, Number(bars) || 0) * 15; if (totalMinutes < 60) return totalMinutes + "m"; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return minutes ? `${hours}h ${minutes}m` : `${hours}h`; } function renderConnectomeRuntime(sideName, trader, connectome) { const root = $("#" + sideName + "Connectome"); if (!root) return; if (!connectome || connectome.available === false) { root.innerHTML = `CONNECTOME OFFLINE
${escapeHtml(connectome?.error || "尚未載入")}`; return; } const readout = trader.connectome_readout || {}; const scores = readout.scores || {}; const dynamics = readout.dynamics || {}; const nt = readout.neurotransmitters || {}; const plasticity = readout.plasticity || {}; const circuit = readout.circuit_spikes || {}; const decoder = readout.raw_rates_hz || {}; const fmt = (value) => { const n = Number(value); return Number.isFinite(n) ? n.toFixed(4) : "—"; }; root.innerHTML = ` WHOLE CONNECTOME · LIF
${Number(connectome.node_count || 0).toLocaleString()} neurons · ${Number(connectome.edge_count || 0).toLocaleString()} edges · active ${Number(readout.active_neurons || connectome.active_neurons || 0).toLocaleString()}
${escapeHtml(dynamics.backend || "python_scipy")} · ${fmt(readout.dt_ms)} ms step · ${fmt(readout.window_ms)} ms neural window · ${Number(readout.total_spikes || 0).toLocaleString()} spikes
NT + ${Number(nt.fast_excitatory_or_fallback || 0).toLocaleString()} · NT − ${Number(nt.fast_inhibitory || 0).toLocaleString()} · mod ${Number(nt.modulatory || 0).toLocaleString()} · uncertain ${Number(nt.uncertain_sign || 0).toLocaleString()}
KC ${Number(circuit.kc || 0).toLocaleString()} spikes · MBON ${Number(circuit.mbon07_11 || 0).toLocaleString()} · PAM11 ${Number(circuit.pam11 || 0).toLocaleString()} · PPL101 ${Number(circuit.ppl101 || 0).toLocaleString()}
decoder Δactual ${fmt(decoder.actual_difference_hz)} Hz · Δmirror ${fmt(decoder.mirror_difference_hz)} Hz · contrast ${fmt(decoder.contrast_hz)} Hz · gate ${Number(decoder.paired_gate_spikes || 0)}
plastic ${Number(plasticity.changed_edges || 0).toLocaleString()} / ${Number(plasticity.plastic_edges || 0).toLocaleString()} edges · reward ${fmt(readout.reward_signal)} · η ${fmt(plasticity.eta || 0.001)}
HOLD
${fmt(scores.HOLD)}
LONG
${fmt(scores.LONG)}
SHORT
${fmt(scores.SHORT)}
`; } function renderTradeAction(sideName, trader, connectome) { const el = $("#" + sideName + "StockAction"); const action = String(trader.last_action || "HOLD").toUpperCase(); el.textContent = action; el.className = "trade-action " + action.toLowerCase(); $("#" + sideName + "StockEquity").textContent = formatMoney(trader.equity); $("#" + sideName + "StockPnl").textContent = formatMoney(trader.pnl); $("#" + sideName + "StockTrades").textContent = trader.trades ?? 0; $("#" + sideName + "StockWL").textContent = `${trader.wins ?? 0} / ${trader.losses ?? 0}`; $("#" + sideName + "StockPosition").textContent = trader.position || "FLAT"; $("#" + sideName + "StockHold").textContent = formatHoldBars(trader.hold_bars); $("#" + sideName + "StockUnrealized").textContent = formatMoney(trader.unrealized_pnl); $("#" + sideName + "StockMemory").textContent = trader.long_term_memory_count ?? 0; $("#" + sideName + "StockReason").textContent = `${trader.last_event || "WAIT"} · ${trader.last_reason || "等待市場"}`; renderConnectomeRuntime(sideName, trader, connectome); renderSensoryChannels(sideName, trader); } function renderMarketIndicators(features) { const root = $("#arenaIndicators"); if (!root) return; const items = [ ["H4", "h4_trend", 3], ["Delta", "delta_ratio", 3], ["OBI", "obi", 3], ["MACD H", "macd_hist", 4], ["ADX", "adx", 3], ["CHOP", "choppiness", 3], ["VWAP Δ", "vwap_dist", 4], ["Donchian", "donchian_pos", 3], ["Stoch RSI", "stoch_rsi", 3], ["Volume Z", "volume_zscore", 3], ["BOS", "bos", 2], ["CHOCH", "choch", 2], ["Sweep", "sweep", 2], ["ATR norm", "atr_norm", 4], ["BB pos", "bb_position", 3], ["Realized Vol", "realized_vol_pct", 3], ]; root.innerHTML = items.map(([label, key, digits]) => { const raw = Number(features?.[key]); const value = Number.isFinite(raw) ? raw.toFixed(digits) : "—"; return `
${escapeHtml(label)} ${escapeHtml(value)}
`; }).join(""); } function buildArenaChartPoints(data) { const history = [...(data?.trade_history || [])] .filter((row) => Number.isFinite(Number(row?.ts))) .sort((a, b) => Number(a.ts) - Number(b.ts)); const points = []; let maleArchive = 100000; let femaleArchive = 100000; if (history.length) { points.push({ phase: "archive", male: maleArchive, female: femaleArchive, ts: Number(history[0].ts) - 1, }); history.forEach((row) => { const pnl = Number(row.trade_pnl || 0); if (row.side === "male") maleArchive += pnl; if (row.side === "female") femaleArchive += pnl; points.push({ phase: "archive", male: maleArchive, female: femaleArchive, ts: Number(row.ts), }); }); } const liveRows = [...(data?.timeline || [])] .filter((row) => row && (row.male || row.female)) .sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0)); const liveStartIndex = points.length; liveRows.forEach((row) => { points.push({ phase: "live", male: Number.isFinite(Number(row.male?.equity)) ? Number(row.male.equity) : 100000, female: Number.isFinite(Number(row.female?.equity)) ? Number(row.female.equity) : 100000, ts: Number(row.ts || 0), }); }); if (!points.length) { const maleEquity = Number(data?.male?.equity); const femaleEquity = Number(data?.female?.equity); if (Number.isFinite(maleEquity) || Number.isFinite(femaleEquity)) { points.push({ phase: "live", male: Number.isFinite(maleEquity) ? maleEquity : 100000, female: Number.isFinite(femaleEquity) ? femaleEquity : 100000, ts: Date.now() / 1000, }); } } return { points, liveStartIndex, hasArchive: history.length > 0, hasLive: liveRows.length > 0 }; } function drawArenaChart(data) { const canvas = $("#arenaChart"); if (!canvas) return; const ctx = canvas.getContext("2d"); const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = Math.max(1, Math.round(rect.width * dpr)); const height = Math.max(1, Math.round(rect.height * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, rect.width, rect.height); const chart = buildArenaChartPoints(data); const points = chart.points; if (!points.length) { ctx.fillStyle = "rgba(255,255,255,.22)"; ctx.font = "11px system-ui"; ctx.textAlign = "center"; ctx.fillText("尚無可繪製的 equity 資料", rect.width / 2, rect.height / 2); return; } const values = [ ...points.map((row) => Number(row.male)), ...points.map((row) => Number(row.female)), 100000, ].filter(Number.isFinite); let min = Math.min(...values); let max = Math.max(...values); let span = Math.max(1, max - min); const yMargin = Math.max(8, span * 0.12); min -= yMargin; max += yMargin; span = Math.max(1, max - min); const padLeft = 54; const padRight = 18; const padTop = 24; const padBottom = 24; const xFor = (index) => { if (points.length <= 1) return rect.width / 2; return padLeft + (index / (points.length - 1)) * (rect.width - padLeft - padRight); }; const yFor = (value) => rect.height - padBottom - ((value - min) / span) * (rect.height - padTop - padBottom); ctx.font = "9px system-ui"; ctx.textAlign = "right"; ctx.fillStyle = "rgba(255,255,255,.30)"; [min, (min + max) / 2, max].forEach((value) => { const y = yFor(value); ctx.strokeStyle = "rgba(255,255,255,.045)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padLeft, y); ctx.lineTo(rect.width - padRight, y); ctx.stroke(); ctx.fillText("$" + Math.round(value).toLocaleString(), padLeft - 6, y + 3); }); const baseY = yFor(100000); ctx.strokeStyle = "rgba(255,255,255,.13)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padLeft, baseY); ctx.lineTo(rect.width - padRight, baseY); ctx.stroke(); const segments = []; let start = 0; for (let i = 1; i < points.length; i += 1) { if (points[i].phase !== points[i - 1].phase) { segments.push([start, i - 1]); start = i; } } segments.push([start, points.length - 1]); const drawSeries = (key, color) => { segments.forEach(([from, to]) => { ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2; ctx.beginPath(); for (let i = from; i <= to; i += 1) { const x = xFor(i); const y = yFor(Number(points[i][key])); if (i === from) ctx.moveTo(x, y); else ctx.lineTo(x, y); } if (to > from) ctx.stroke(); for (let i = from; i <= to; i += 1) { const x = xFor(i); const y = yFor(Number(points[i][key])); ctx.beginPath(); ctx.arc(x, y, points.length === 1 ? 4 : 2.5, 0, Math.PI * 2); ctx.fill(); } }); }; drawSeries("male", "#7cff99"); drawSeries("female", "#ff89c9"); if (chart.hasArchive && chart.hasLive && chart.liveStartIndex < points.length) { const dividerX = xFor(chart.liveStartIndex) - 5; ctx.save(); ctx.setLineDash([4, 4]); ctx.strokeStyle = "rgba(255,255,255,.22)"; ctx.beginPath(); ctx.moveTo(dividerX, padTop); ctx.lineTo(dividerX, rect.height - padBottom); ctx.stroke(); ctx.restore(); ctx.fillStyle = "rgba(255,255,255,.42)"; ctx.font = "9px system-ui"; ctx.textAlign = "left"; ctx.fillText("v4 reset", Math.min(dividerX + 5, rect.width - 52), padTop + 9); } ctx.font = "10px system-ui"; ctx.textAlign = "left"; ctx.fillStyle = "#7cff99"; ctx.fillText("♂ Male", padLeft, 13); ctx.fillStyle = "#ff89c9"; ctx.fillText("♀ BANC", padLeft + 58, 13); if (chart.hasArchive && !chart.hasLive) { ctx.fillStyle = "rgba(255,255,255,.35)"; ctx.textAlign = "right"; ctx.fillText("歷史已完成交易重建", rect.width - padRight, 13); } else if (chart.hasArchive && chart.hasLive) { ctx.fillStyle = "rgba(255,255,255,.35)"; ctx.textAlign = "right"; ctx.fillText("歷史 + v4 Live", rect.width - padRight, 13); } else { ctx.fillStyle = "rgba(255,255,255,.35)"; ctx.textAlign = "right"; ctx.fillText("v4 Live equity", rect.width - padRight, 13); } } function renderArenaLog(timeline, data = null) { const root = $("#arenaLog"); if (!root) return; const rows = (timeline || []).slice(-14).reverse(); const history = (data?.trade_history || []).slice(0, 40); const historySummary = data?.trade_history_summary || {}; const hasSavedState = Number(data?.decision_count || 0) > 0 || ["LONG", "SHORT"].includes(String(data?.male?.position || "")) || ["LONG", "SHORT"].includes(String(data?.female?.position || "")); const liveHtml = rows.length ? rows.map((row) => `
#${escapeHtml(row.step)} ${Number(row.price).toFixed(2)} ♂ ${escapeHtml(row.male?.position || "FLAT")} · ${escapeHtml(row.male?.event || row.male?.action || "—")} · ${formatHoldBars(row.male?.hold_bars)} ♀ ${escapeHtml(row.female?.position || "FLAT")} · ${escapeHtml(row.female?.event || row.female?.action || "—")} · ${formatHoldBars(row.female?.hold_bars)}
`).join("") : hasSavedState ? '
已有交易狀態 · 等待下一根 15m K 產生新紀錄
' : '
尚未開始交易
'; const formatTs = (ts) => { const value = Number(ts); if (!Number.isFinite(value) || value <= 0) return "—"; return new Date(value * 1000).toLocaleString("zh-TW", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }); }; const maleSummary = historySummary.male || {}; const femaleSummary = historySummary.female || {}; const archiveHeader = history.length ? `
歷史已完成交易 ♂ ${Number(maleSummary.count || 0)} 筆 · ${formatMoney(maleSummary.pnl || 0)} ♀ ${Number(femaleSummary.count || 0)} 筆 · ${formatMoney(femaleSummary.pnl || 0)}
` : ""; const archiveHtml = history.length ? history.map((row) => { const side = row.side === "male" ? "♂" : "♀"; const pnl = Number(row.trade_pnl || 0); const pnlClass = pnl >= 0 ? "history-win" : "history-loss"; return `
${escapeHtml(formatTs(row.ts))} ${side} ${escapeHtml(row.action || "—")} ${formatHoldBars(row.hold_bars)} · ${escapeHtml(row.reason || "—")} ${formatMoney(pnl)}
`; }).join("") : ""; root.innerHTML = `
Live 決策紀錄
${liveHtml}
${archiveHeader} ${archiveHtml} `; } function formatDecisionCountdown(seconds) { const total = Math.max(0, Math.round(Number(seconds) || 0)); const minutes = Math.floor(total / 60); const secs = total % 60; return `${minutes}m ${String(secs).padStart(2, "0")}s`; } function renderStockArena(data) { if (!data?.available) { showNotice(data?.error || data?.message || "Live 交易競技場資料不可用。"); return; } $("#arenaSymbol").textContent = data.symbol || "—"; $("#arenaPrice").textContent = Number(data.price || data.bar?.close || 0).toFixed(2); $("#arenaBar").textContent = data.live ? formatDecisionCountdown(data.next_decision_in_sec) : `${data.step ?? 0} / ${data.steps_total ?? 0}`; $("#arenaMomentum").textContent = ((Number(data.features?.momentum_5) || 0) * 100).toFixed(2) + "%"; $("#arenaAdvisor").textContent = data.brain_mode === "whole_connectome_live" ? `WHOLE CONNECTOME · LIVE · #${data.decision_count ?? 0}` : data.live ? `LIVE · #${data.decision_count ?? 0}` : "SELF-LEARNING ×2"; const execution = data.execution || {}; const execRoot = $("#arenaExecutionInfo"); if (execRoot) { const allocationPct = (Number(execution.allocation || 0) * 100).toFixed(0); const feePct = (Number(execution.fee_rate_per_side || 0) * 100).toFixed(3); const worker = data.worker || {}; const workerText = worker.ok === true ? "SERVER WORKER OK" : worker.ok === false ? "SERVER WORKER ERROR" : "SERVER WORKER STARTING"; execRoot.textContent = `${workerText} · EXEC ${execution.model || "—"} · ${allocationPct}% allocation · fee ${feePct}%/side · ${Number(execution.slippage_bps || 0).toFixed(1)} bp slip · fresh-price causal fill`; } renderMarketIndicators(data.features || {}); renderTradeAction("male", data.male || {}, data.connectome?.male); renderTradeAction("female", data.female || {}, data.connectome?.female); renderArenaLog(data.timeline || [], data); drawArenaChart(data); if (data.live) { $("#stockAutoBtn").textContent = state.arenaAuto ? "LIVE RUNNING" : "啟動 Live"; $("#stockAutoBtn").classList.toggle("active", state.arenaAuto); } } function renderBacktest(data) { const statusEl = $("#backtestStatus"); const barEl = $("#backtestProgressBar"); const resultEl = $("#backtestResult"); const btn = $("#backtestBtn"); if (!statusEl || !barEl || !resultEl || !btn) return; const status = String(data?.status || "idle"); const completed = Number(data?.completed || 0); const total = Number(data?.total || data?.bars || 0); const percent = Number.isFinite(Number(data?.percent)) ? Math.max(0, Math.min(100, Number(data.percent))) : total > 0 ? Math.max(0, Math.min(100, completed / total * 100)) : 0; barEl.style.width = percent + "%"; if (status === "running" || status === "queued") { statusEl.textContent = (data.symbol || "—") + " · " + completed + "/" + (total || "?") + " · " + percent.toFixed(0) + "%"; btn.disabled = true; btn.textContent = "回測執行中"; } else if (status === "completed") { statusEl.textContent = (data.symbol || "—") + " · 完成 " + (data.bars || total) + " 根"; btn.disabled = false; btn.textContent = "再跑一次回測"; } else if (status === "error") { statusEl.textContent = "回測失敗"; btn.disabled = false; btn.textContent = "重新跑回測"; } else { statusEl.textContent = "尚未執行"; btn.disabled = false; btn.textContent = "跑歷史回測"; } if (status === "error") { resultEl.innerHTML = "錯誤:" + escapeHtml(data.error || "未知錯誤") + ""; return; } const male = data?.male || {}; const female = data?.female || {}; const latest = data?.latest || (Array.isArray(data?.timeline) ? data.timeline.at(-1) : null); if (!Object.keys(male).length && !Object.keys(female).length) { resultEl.innerHTML = "" + escapeHtml(data?.message || "正在載入完整 connectome…") + ""; return; } const card = (label, metrics, sideClass) => { return '
' + "" + escapeHtml(label) + "" + '
Equity' + formatMoney(metrics.equity ?? 100000) + "
" + '
P&L' + formatMoney(metrics.pnl ?? 0) + "
" + '
Return' + Number(metrics.return_pct || 0).toFixed(3) + "%
" + '
Closed Trades' + Number(metrics.closed_trades || 0) + "
" + '
W/L' + Number(metrics.wins || 0) + " / " + Number(metrics.losses || 0) + "
" + '
Max DD' + Number(metrics.max_drawdown_pct || 0).toFixed(3) + "%
" + '
Fee' + formatMoney(metrics.fees || 0) + "
" + '
Position' + escapeHtml(metrics.position || "FLAT") + "
" + "
"; }; let latestText = "等待第一根歷史 K 完成"; if (latest) { latestText = "#" + (latest.step || completed) + " · signal " + Number(latest.signal_close || 0).toFixed(2) + " → next open " + Number(latest.execution_open || 0).toFixed(2) + " · ♂ " + escapeHtml(latest.male?.action || "—") + " (" + Number(latest.male?.contrast_hz || 0).toFixed(1) + "Hz)" + " · ♀ " + escapeHtml(latest.female?.action || "—") + " (" + Number(latest.female?.contrast_hz || 0).toFixed(1) + "Hz)"; } resultEl.innerHTML = card("♂ MaleCNS Backtest", male, "male-bt") + card("♀ BANC Backtest", female, "female-bt") + '
' + latestText + "
"; } async function pollBacktestJob(jobId) { if (!jobId || state.backtestPolling) return; state.backtestPolling = true; state.backtestJobId = jobId; try { while (state.backtestJobId === jobId) { const data = await fetchJSON( "/api/stock-arena/backtest/status/" + encodeURIComponent(jobId) ); renderBacktest(data); if (data.status === "completed" || data.status === "error") { state.backtestJobId = null; break; } await sleep(1500); } } catch (error) { showNotice("回測狀態讀取失敗:" + error.message); } finally { state.backtestPolling = false; if (!state.backtestJobId) { const btn = $("#backtestBtn"); if (btn) btn.disabled = false; } } } async function startBacktest() { const symbol = $("#stockSymbol")?.value || "BTC/USDT"; const bars = Number($("#backtestBars")?.value || 96); const btn = $("#backtestBtn"); if (btn) { btn.disabled = true; btn.textContent = "建立回測…"; } try { const data = await postJSON( "/api/stock-arena/backtest/start", { symbol, bars }, 12000 ); state.backtestJobId = data.job_id; renderBacktest({ ...data, completed: 0, total: data.bars || bars, percent: 0, message: data.message || "後端已開始載入完整 connectome", }); await pollBacktestJob(data.job_id); } catch (error) { if (btn) { btn.disabled = false; btn.textContent = "跑歷史回測"; } showNotice("回測啟動失敗:" + error.message); } } async function loadStockArena() { const symbol = $("#stockSymbol")?.value || "BTC/USDT"; try { const data = await fetchJSON( "/api/stock-arena/live/status?symbol=" + encodeURIComponent(symbol) ); renderStockArena(data); return data; } catch (error) { showNotice("Live 市場載入失敗:" + error.message); throw error; } } async function resetStockArena() { const wasRunning = state.arenaAuto; stopArenaAuto(); const symbol = $("#stockSymbol").value; try { const data = await postJSON("/api/stock-arena/live/reset", { symbol }, 35000); renderStockArena(data); } catch (error) { showNotice("重置 Live 大腦失敗:" + error.message); } if (wasRunning) runArenaAuto(); } async function stepStockArena() { return loadStockArena(); } async function runArenaAuto() { if (state.arenaAuto) return; state.arenaRunId += 1; const runId = state.arenaRunId; state.arenaAuto = true; $("#stockAutoBtn").textContent = "LIVE RUNNING"; $("#stockAutoBtn").classList.add("active"); try { while (state.arenaAuto && runId === state.arenaRunId) { await loadStockArena(); await sleep(5000); } } catch (error) { if (state.arenaAuto && runId === state.arenaRunId) { showNotice("Live 更新暫停:" + error.message); } } finally { if (runId === state.arenaRunId) { state.arenaAuto = false; $("#stockAutoBtn").textContent = "啟動 Live"; $("#stockAutoBtn").classList.remove("active"); } } } function stopArenaAuto() { state.arenaAuto = false; state.arenaRunId += 1; if ($("#stockAutoBtn")) { $("#stockAutoBtn").textContent = "啟動 Live"; $("#stockAutoBtn").classList.remove("active"); } } function resetViews() { sides.male.camera = { rx: -0.12, ry: 0.45, zoom: 1 }; sides.female.camera = { rx: -0.12, ry: 0.45, zoom: 1 }; } $("#compareBtn").addEventListener("click", compare); $("#searchInput").addEventListener("keydown", (event) => { if (event.key === "Enter") compare(); }); $("#syncCameraBtn").addEventListener("click", () => { state.syncCamera = !state.syncCamera; $("#syncCameraBtn").classList.toggle("active", state.syncCamera); if (state.syncCamera) syncCameraFrom("male"); }); $("#autoRotateBtn").addEventListener("click", () => { state.autoRotate = !state.autoRotate; $("#autoRotateBtn").classList.toggle("active", state.autoRotate); }); $("#resetViewBtn").addEventListener("click", resetViews); $("#autoChatBtn").addEventListener("click", runAutoChat); $("#stopChatBtn").addEventListener("click", stopAutoChat); $("#clearChatBtn").addEventListener("click", clearChat); $("#sendChatBtn").addEventListener("click", sendManualChat); $("#chatInput").addEventListener("keydown", (event) => { if (event.key === "Enter") sendManualChat(); }); $("#stockResetBtn").addEventListener("click", resetStockArena); $("#stockStepBtn").addEventListener("click", stepStockArena); $("#stock10Btn").addEventListener("click", stopArenaAuto); $("#stockAutoBtn").addEventListener("click", () => { if (state.arenaAuto) stopArenaAuto(); else runArenaAuto(); }); $("#backtestBtn").addEventListener("click", startBacktest); $("#stockSymbol").addEventListener("change", async () => { try { await loadStockArena(); } catch (error) { showNotice("切換幣別失敗:" + error.message); } }); $$(".direction-toggle").forEach((group) => { const sideName = group.dataset.target; group.querySelectorAll(".direction").forEach((button) => { button.addEventListener("click", async () => { group.querySelectorAll(".direction").forEach((item) => item.classList.remove("active")); button.classList.add("active"); sides[sideName].direction = button.dataset.direction; await loadConnections(sideName); }); }); }); attachCanvasControls("male"); attachCanvasControls("female"); async function boot() { await Promise.all([loadSources(), loadSurfaces()]); await compare(); await loadStockArena(); runArenaAuto(); } boot(); animate();