feat: add 5 dashboard features — dark mode, trade history, backtests, model insights, alerts

- Dark mode: class-based theme toggle with localStorage persistence and flash prevention
- Trade History (/trades): paginated table, stats cards, equity curve chart with DB API endpoints
- Backtest Viewer (/backtests): log parser for 35 backtest results, sidebar + detail + comparison tabs
- Model Insights: dashboard card + dialog showing feature importance, regime distribution, training history
- Alert/Signal Log (/alerts): signal stats, filterable table with execution tracking
- API: 8 new endpoints with psycopg2 DB connection pool
- Dark mode sweep across books page, about dialog, and all dashboard components
- Architecture docs rewritten with Mermaid diagrams (23 docs)
- README and FEATURES.md rewritten bilingual (Indonesian + English)
- main_live.py: write model_metrics.json on startup and retrain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
GifariKemal
2026-02-09 05:46:54 +07:00
co-authored by Claude Opus 4.6
parent b2dc2dacd7
commit e8355b3f62
230 changed files with 69573 additions and 5673 deletions
@@ -0,0 +1,170 @@
"use client";
import { Children, isValidElement, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { MermaidDiagram } from "./mermaid-diagram";
interface MarkdownRendererProps {
content: string;
}
function getTextContent(node: React.ReactNode): string {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (!node) return "";
if (Array.isArray(node)) return node.map(getTextContent).join("");
if (isValidElement(node) && (node.props as Record<string, unknown>)?.children)
return getTextContent((node.props as Record<string, unknown>).children as React.ReactNode);
return "";
}
const components: Components = {
// ── Headings ──
h1: ({ children }) => (
<h1 className="text-[2rem] font-bold text-slate-800 mt-10 mb-5 pb-3 border-b-2 border-blue-200/60 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-[1.6rem] font-semibold text-slate-800 mt-9 mb-4 pb-2 border-b border-slate-200/80">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-[1.35rem] font-semibold text-slate-700 mt-6 mb-3 pl-3 border-l-3 border-blue-400/40">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-[1.15rem] font-semibold text-slate-700 mt-5 mb-2">
{children}
</h4>
),
// ── Paragraphs & text ──
p: ({ children }) => (
<p className="text-[1.05rem] leading-[1.8] text-slate-600 mb-4">
{children}
</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-slate-800">{children}</strong>
),
em: ({ children }) => (
<em className="italic text-slate-500">{children}</em>
),
// ── Lists ──
ul: ({ children }) => (
<ul className="list-disc list-outside ml-5 mb-4 space-y-1.5 text-[1.05rem] text-slate-600">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-outside ml-5 mb-4 space-y-1.5 text-[1.05rem] text-slate-600">
{children}
</ol>
),
li: ({ children }) => <li className="leading-[1.7] pl-1">{children}</li>,
// ── Links ──
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline underline-offset-2 decoration-blue-300 hover:decoration-blue-500 transition-colors"
>
{children}
</a>
),
// ── Blockquotes ──
blockquote: ({ children }) => (
<blockquote className="border-l-[3px] border-amber-400/60 pl-4 py-2 my-4 bg-amber-50/40 rounded-r-lg">
<div className="text-slate-600 [&>p]:text-slate-600 [&>p]:mb-1">{children}</div>
</blockquote>
),
// ── Code blocks — with Mermaid detection ──
pre: ({ children }) => {
const child = Children.only(children);
const childProps = isValidElement(child) ? (child.props as Record<string, unknown>) : null;
if (childProps?.className === "language-mermaid") {
const chart = getTextContent(childProps.children as React.ReactNode);
return <MermaidDiagram chart={chart} />;
}
return (
<pre className="my-4 p-4 rounded-xl bg-slate-50/80 border border-slate-200/80 overflow-x-auto text-[0.95rem] leading-relaxed">
{children}
</pre>
);
},
code: ({ className, children }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className="block font-mono text-slate-700">{children}</code>
);
}
return (
<code className="px-1.5 py-0.5 rounded-md bg-blue-50/70 text-blue-700 font-mono text-[0.95rem] border border-blue-100/50">
{children}
</code>
);
},
// ── Tables — Excel-style ──
table: ({ children }) => (
<div className="my-5 overflow-x-auto rounded-xl border border-slate-200 shadow-sm">
<table className="w-full text-[1rem] border-collapse">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-gradient-to-b from-slate-100 to-slate-50 border-b-2 border-slate-200">
{children}
</thead>
),
th: ({ children }) => (
<th className="px-4 py-2.5 text-left font-semibold text-slate-700 text-[0.95rem] uppercase tracking-wide border-r border-slate-200/60 last:border-r-0">
{children}
</th>
),
tbody: ({ children }) => <tbody className="divide-y divide-slate-100">{children}</tbody>,
tr: ({ children }) => (
<tr className="hover:bg-blue-50/30 transition-colors even:bg-slate-50/40">
{children}
</tr>
),
td: ({ children }) => (
<td className="px-4 py-2.5 text-slate-600 border-r border-slate-100/80 last:border-r-0 align-top">
{children}
</td>
),
// ── Horizontal rule ──
hr: () => (
<hr className="my-8 border-0 h-px bg-gradient-to-r from-transparent via-slate-300 to-transparent" />
),
// ── Images (placeholder) ──
img: ({ alt }) => (
<span className="block my-4 text-center text-slate-400 text-sm italic">
[{alt || "image"}]
</span>
),
};
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
const processedContent = useMemo(() => content, [content]);
return (
<div className="max-w-none prose-slate">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{processedContent}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,281 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import {
ZoomIn,
ZoomOut,
Maximize2,
Move,
RotateCcw,
} from "lucide-react";
interface MermaidDiagramProps {
chart: string;
}
const MIN_ZOOM = 0.3;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.2;
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string>("");
// Transform state
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
useEffect(() => {
let cancelled = false;
async function render() {
try {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({
startOnLoad: false,
theme: "base",
themeVariables: {
primaryColor: "#e8f0fe",
primaryTextColor: "#1d1d1f",
primaryBorderColor: "#007AFF",
lineColor: "#86868b",
secondaryColor: "#f0f7ee",
tertiaryColor: "#fef7e0",
fontFamily: "IBM Plex Sans, system-ui, sans-serif",
fontSize: "13px",
nodeBorder: "#007AFF",
mainBkg: "#e8f0fe",
clusterBkg: "#f5f5f7",
clusterBorder: "#d1d1d6",
titleColor: "#1d1d1f",
edgeLabelBackground: "#ffffff",
},
flowchart: {
htmlLabels: true,
curve: "basis",
padding: 12,
},
});
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
const { svg: renderedSvg } = await mermaid.render(id, chart.trim());
if (!cancelled) {
setSvg(renderedSvg);
setError("");
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Diagram error");
}
}
}
render();
return () => {
cancelled = true;
};
}, [chart]);
// Reset when chart changes
useEffect(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, [chart]);
const handleZoomIn = useCallback(() => {
setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP));
}, []);
const handleZoomOut = useCallback(() => {
setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP));
}, []);
const handleReset = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
const handleFitToView = useCallback(() => {
if (!containerRef.current || !viewportRef.current) return;
const viewport = viewportRef.current.getBoundingClientRect();
const svgEl = containerRef.current.querySelector("svg");
if (!svgEl) return;
const svgW = svgEl.getBoundingClientRect().width / zoom;
const svgH = svgEl.getBoundingClientRect().height / zoom;
if (svgW === 0 || svgH === 0) return;
const fitZoom = Math.min(
(viewport.width - 32) / svgW,
(viewport.height - 32) / svgH,
MAX_ZOOM
);
setZoom(Math.max(MIN_ZOOM, fitZoom));
setPan({ x: 0, y: 0 });
}, [zoom]);
// Mouse wheel zoom
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((z) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z + delta)));
}, []);
// Drag to pan
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
setIsDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
},
[pan]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
},
[isDragging]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// Touch support for mobile
const touchStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (e.touches.length === 1) {
const t = e.touches[0];
setIsDragging(true);
touchStart.current = { x: t.clientX, y: t.clientY, panX: pan.x, panY: pan.y };
}
},
[pan]
);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!isDragging || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - touchStart.current.x;
const dy = t.clientY - touchStart.current.y;
setPan({ x: touchStart.current.panX + dx, y: touchStart.current.panY + dy });
},
[isDragging]
);
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
}, []);
if (error) {
return (
<div className="my-4 p-4 rounded-xl bg-red-50 border border-red-200 text-sm text-red-700">
<p className="font-medium mb-1">Diagram Error</p>
<pre className="text-xs whitespace-pre-wrap">{error}</pre>
<details className="mt-2">
<summary className="cursor-pointer text-xs text-red-500">Source</summary>
<pre className="mt-1 text-xs whitespace-pre-wrap text-red-600">{chart}</pre>
</details>
</div>
);
}
if (!svg) {
return (
<div className="my-4 flex items-center justify-center p-8 rounded-xl bg-slate-50 border border-slate-200">
<div className="animate-pulse text-sm text-slate-400">Rendering diagram...</div>
</div>
);
}
const zoomPercent = Math.round(zoom * 100);
return (
<div className="my-4 rounded-xl bg-white/60 border border-slate-200 overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-1.5 bg-slate-50/80 border-b border-slate-200/60">
<div className="flex items-center gap-1">
<button
onClick={handleZoomOut}
className="p-1.5 rounded-md hover:bg-slate-200/60 text-slate-500 hover:text-slate-700 transition-colors"
title="Zoom out"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<span className="text-[11px] font-mono text-slate-400 w-10 text-center select-none">
{zoomPercent}%
</span>
<button
onClick={handleZoomIn}
className="p-1.5 rounded-md hover:bg-slate-200/60 text-slate-500 hover:text-slate-700 transition-colors"
title="Zoom in"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<div className="w-px h-4 bg-slate-200 mx-1" />
<button
onClick={handleFitToView}
className="p-1.5 rounded-md hover:bg-slate-200/60 text-slate-500 hover:text-slate-700 transition-colors"
title="Fit to view"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleReset}
className="p-1.5 rounded-md hover:bg-slate-200/60 text-slate-500 hover:text-slate-700 transition-colors"
title="Reset"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-slate-400">
<Move className="h-3 w-3" />
<span>Drag untuk geser, scroll untuk zoom</span>
</div>
</div>
{/* Viewport */}
<div
ref={viewportRef}
className="relative overflow-hidden"
style={{ height: "clamp(200px, 50vh, 600px)", cursor: isDragging ? "grabbing" : "grab" }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
ref={containerRef}
className="flex justify-center items-start min-w-full min-h-full p-4 select-none"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "center top",
transition: isDragging ? "none" : "transform 0.15s ease-out",
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
</div>
);
}