feat: add TalibPatternPanel, TrainingPanel, ModelSelector UI components (tasks 5-8)

- TalibPatternPanel: pattern checkboxes, detect button, results summary, clear-all and per-pattern delete
- TrainingPanel: model type selector, dataset info, start training, polling, run history
- ModelSelector: dropdown of completed runs, wired into PredictionPanel for model switching
- page.tsx: integrate all three panels into sidebar, wire callbacks (model load, annotations refresh)
- tasks.md: mark all 39 tasks complete
This commit is contained in:
Marko Djordjevic 2026-02-17 18:55:52 +01:00
parent 2a02669222
commit 12a9603fce
7 changed files with 849 additions and 23 deletions

View file

@ -0,0 +1,305 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { ChevronDown, Trash2 } from 'lucide-react';
interface PatternInfo {
function_name: string;
display_name: string;
}
interface DetectionResult {
label: string;
count: number;
}
interface TalibPatternPanelProps {
activeChartId: number | null;
onAnnotationsChanged: () => void;
getCandles: () => any[] | undefined;
}
export default function TalibPatternPanel({
activeChartId,
onAnnotationsChanged,
getCandles,
}: TalibPatternPanelProps) {
const [expanded, setExpanded] = useState(false);
const [patterns, setPatterns] = useState<PatternInfo[]>([]);
const [selectedPatterns, setSelectedPatterns] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [isFetchingPatterns, setIsFetchingPatterns] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<DetectionResult[]>([]);
const [deletingLabel, setDeletingLabel] = useState<string | null>(null);
const [isDeletingAll, setIsDeletingAll] = useState(false);
// Fetch available patterns when panel expands
useEffect(() => {
if (expanded && patterns.length === 0) {
setIsFetchingPatterns(true);
fetch('/api/patterns/available')
.then((r) => r.json())
.then((data) => {
const list: PatternInfo[] = data.patterns || data;
setPatterns(list);
})
.catch(() => setError('Failed to load patterns'))
.finally(() => setIsFetchingPatterns(false));
}
}, [expanded, patterns.length]);
const handleSelectAll = () => {
setSelectedPatterns(new Set(patterns.map((p) => p.function_name)));
};
const handleDeselectAll = () => {
setSelectedPatterns(new Set());
};
const handleTogglePattern = (fn: string) => {
setSelectedPatterns((prev) => {
const next = new Set(prev);
if (next.has(fn)) next.delete(fn);
else next.add(fn);
return next;
});
};
const handleDetect = useCallback(async () => {
if (!activeChartId) {
setError('Load a chart first');
return;
}
const candles = getCandles();
if (!candles || candles.length === 0) {
setError('No candle data available');
return;
}
if (selectedPatterns.size === 0) return;
setIsLoading(true);
setError(null);
setResults([]);
try {
// Detect patterns
const detectRes = await fetch('/api/patterns/detect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
candles,
patterns: Array.from(selectedPatterns),
}),
});
if (!detectRes.ok) {
const err = await detectRes.json();
throw new Error(err.detail || err.error || 'Detection failed');
}
const detectData = await detectRes.json();
const annotations: any[] = detectData.annotations || [];
if (annotations.length === 0) {
setResults([]);
return;
}
// Save each annotation via POST /api/span-annotations
await Promise.all(
annotations.map((ann: any) =>
fetch('/api/span-annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chart_id: activeChartId,
start_time: ann.start_time,
end_time: ann.end_time,
label: ann.label,
confidence: ann.confidence ?? null,
source: 'talib',
}),
})
)
);
// Build results summary
const counts: Record<string, number> = {};
for (const ann of annotations) {
counts[ann.label] = (counts[ann.label] || 0) + 1;
}
setResults(
Object.entries(counts).map(([label, count]) => ({ label, count }))
);
onAnnotationsChanged();
} catch (e) {
setError(e instanceof Error ? e.message : 'Detection failed');
} finally {
setIsLoading(false);
}
}, [activeChartId, getCandles, selectedPatterns, onAnnotationsChanged]);
const handleClearAll = async () => {
if (!activeChartId) return;
setIsDeletingAll(true);
try {
await fetch(
`/api/span-annotations?source=talib&chartId=${activeChartId}`,
{ method: 'DELETE' }
);
setResults([]);
onAnnotationsChanged();
} catch {
setError('Failed to clear TA-Lib annotations');
} finally {
setIsDeletingAll(false);
}
};
const handleDeleteByLabel = async (label: string) => {
if (!activeChartId) return;
setDeletingLabel(label);
try {
await fetch(
`/api/span-annotations?source=talib&label=${encodeURIComponent(label)}&chartId=${activeChartId}`,
{ method: 'DELETE' }
);
setResults((prev) => prev.filter((r) => r.label !== label));
onAnnotationsChanged();
} catch {
setError('Failed to delete pattern annotations');
} finally {
setDeletingLabel(null);
}
};
const totalDetected = results.reduce((s, r) => s + r.count, 0);
return (
<div>
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between py-1"
>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
TA-Lib Patterns
</span>
<ChevronDown
className={`w-3 h-3 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
{expanded && (
<div className="mt-1 space-y-2">
{isFetchingPatterns && (
<p className="text-[10px] text-muted-foreground">Loading patterns...</p>
)}
{!isFetchingPatterns && patterns.length > 0 && (
<>
{/* Select All / Deselect All */}
<div className="flex gap-1">
<button
onClick={handleSelectAll}
className="flex-1 px-1.5 py-0.5 text-[10px] bg-secondary text-secondary-foreground rounded hover:opacity-80"
>
Select All
</button>
<button
onClick={handleDeselectAll}
className="flex-1 px-1.5 py-0.5 text-[10px] bg-secondary text-secondary-foreground rounded hover:opacity-80"
>
Deselect All
</button>
</div>
{/* Pattern checkboxes */}
<div className="max-h-40 overflow-y-auto scrollbar-thin space-y-0.5 pr-1">
{patterns.map((p) => (
<label
key={p.function_name}
className="flex items-center gap-1.5 p-0.5 rounded hover:bg-secondary/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedPatterns.has(p.function_name)}
onChange={() => handleTogglePattern(p.function_name)}
className="w-3 h-3"
/>
<span className="text-[10px] text-foreground truncate">{p.display_name}</span>
</label>
))}
</div>
{/* Detect button */}
<button
onClick={handleDetect}
disabled={isLoading || selectedPatterns.size === 0 || !activeChartId}
className="w-full px-2 py-1.5 text-[10px] bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isLoading
? 'Detecting...'
: selectedPatterns.size === 0
? 'Detect Patterns'
: `Detect Patterns (${selectedPatterns.size} selected)`}
</button>
</>
)}
{/* Error */}
{error && (
<p className="text-[10px] text-destructive">{error}</p>
)}
{/* Results summary */}
{results.length > 0 && (
<div className="p-2 bg-secondary/30 rounded space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
Found: {totalDetected} pattern{totalDetected !== 1 ? 's' : ''}
</span>
<button
onClick={handleClearAll}
disabled={isDeletingAll}
className="text-[10px] text-destructive hover:opacity-80 disabled:opacity-50"
title="Clear all TA-Lib annotations"
>
{isDeletingAll ? 'Clearing...' : 'Clear All'}
</button>
</div>
<div className="space-y-0.5">
{results.map((r) => (
<div key={r.label} className="flex items-center justify-between gap-1">
<span className="text-[10px] text-foreground truncate flex-1">{r.label}</span>
<span className="text-[10px] font-mono text-muted-foreground">{r.count}</span>
<button
onClick={() => handleDeleteByLabel(r.label)}
disabled={deletingLabel === r.label}
className="text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
title={`Delete all ${r.label} annotations`}
>
<Trash2 className="w-2.5 h-2.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* Clear All button when results are empty but TA-Lib annotations may exist */}
{results.length === 0 && !isLoading && (
<button
onClick={handleClearAll}
disabled={isDeletingAll || !activeChartId}
className="w-full px-2 py-1 text-[10px] text-destructive border border-destructive/30 rounded hover:bg-destructive/10 transition-colors disabled:opacity-50"
>
{isDeletingAll ? 'Clearing...' : 'Clear All TA-Lib'}
</button>
)}
</div>
)}
</div>
);
}