305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
'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: 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>
|
|
);
|
|
}
|