- Remove symbol badge between input boxes (input now stays in sync) - Change label to 'Symbol: IREN' with the symbol value in accent/pink color
257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
'use client';
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { api } from '@/lib/trpc';
|
|
import { useActiveSymbol } from '@/stores/active-symbol-store';
|
|
import { CollapsibleSection } from '@/components/CollapsibleSection';
|
|
import { SymbolAutocomplete } from '@/components/SymbolAutocomplete';
|
|
import { ConfluenceChart } from '@/components/ConfluenceChart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types (subset of server types for display)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Quality = 'strong-bullish' | 'moderate-bullish' | 'weak-bullish' | 'mixed' | 'weak-bearish' | 'moderate-bearish' | 'strong-bearish' | 'sparse';
|
|
|
|
const QUALITY_COLOR: Record<Quality, string> = {
|
|
'strong-bullish': 'bg-green-600/20 text-green-300 border-green-600/40',
|
|
'moderate-bullish': 'bg-green-600/10 text-green-400 border-green-600/20',
|
|
'weak-bullish': 'bg-green-600/5 text-green-500 border-green-600/10',
|
|
'mixed': 'bg-zinc-700/30 text-zinc-300 border-zinc-600/30',
|
|
'weak-bearish': 'bg-red-600/5 text-red-500 border-red-600/10',
|
|
'moderate-bearish': 'bg-red-600/10 text-red-400 border-red-600/20',
|
|
'strong-bearish': 'bg-red-600/20 text-red-300 border-red-600/40',
|
|
'sparse': 'bg-zinc-800/50 text-zinc-500 border-zinc-700/30',
|
|
};
|
|
|
|
const QUALITY_LABEL: Record<Quality, string> = {
|
|
'strong-bullish': 'Strong Bullish',
|
|
'moderate-bullish': 'Moderate Bullish',
|
|
'weak-bullish': 'Weak Bullish',
|
|
'mixed': 'Mixed',
|
|
'weak-bearish': 'Weak Bearish',
|
|
'moderate-bearish': 'Moderate Bearish',
|
|
'strong-bearish': 'Strong Bearish',
|
|
'sparse': 'Sparse Data',
|
|
};
|
|
|
|
const BODY_BADGE: Record<string, string> = {
|
|
bull: 'bg-green-600/15 text-green-400',
|
|
bear: 'bg-red-600/15 text-red-400',
|
|
exit: 'bg-amber-600/15 text-amber-400',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Panel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function ConfluencePanel() {
|
|
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
|
const setActiveSymbol = useActiveSymbol((s) => s.setActive);
|
|
const [symbol, setSymbol] = useState(activeSymbol);
|
|
const [searchQuery, setSearchQuery] = useState(activeSymbol);
|
|
const [selectedRackId, setSelectedRackId] = useState<string | null>(null);
|
|
|
|
const pickSymbol = useCallback((sym: string) => {
|
|
setSymbol(sym);
|
|
setSearchQuery(sym);
|
|
setActiveSymbol(sym);
|
|
}, [setActiveSymbol]);
|
|
|
|
// Sync searchQuery when activeSymbol changes externally (e.g., from another panel)
|
|
useEffect(() => {
|
|
setSearchQuery(activeSymbol);
|
|
}, [activeSymbol]);
|
|
|
|
// Racks
|
|
const [racks, setRacks] = useState<{ system: Array<{ id: string; name: string }>; user: Array<{ id: string; name: string }> }>({ system: [], user: [] });
|
|
useEffect(() => {
|
|
api.confluence.racks().then(setRacks).catch(() => {});
|
|
}, []);
|
|
|
|
// Evaluation
|
|
const [evaluation, setEvaluation] = useState<Record<string, unknown> | null>(null);
|
|
const rackId = selectedRackId ?? racks.system[0]?.id ?? null;
|
|
useEffect(() => {
|
|
if (!symbol) return;
|
|
api.confluence.evaluation(symbol, { rackId: rackId ?? undefined, limit: 6 })
|
|
.then((r) => setEvaluation(r as Record<string, unknown>))
|
|
.catch(() => setEvaluation(null));
|
|
}, [symbol, rackId]);
|
|
|
|
// Scorecard
|
|
const [scorecard, setScorecard] = useState<{ slots: Array<Record<string, unknown>>; note: string } | null>(null);
|
|
useEffect(() => {
|
|
if (!symbol) return;
|
|
api.confluence.scorecard(symbol)
|
|
.then((r) => setScorecard(r as { slots: Array<Record<string, unknown>>; note: string }))
|
|
.catch(() => setScorecard(null));
|
|
}, [symbol]);
|
|
|
|
// Slots
|
|
const [slotMeta, setSlotMeta] = useState<Map<string, { name: string; body: string; explain: string }>>(new Map());
|
|
useEffect(() => {
|
|
api.confluence.slots()
|
|
.then((r) => setSlotMeta(new Map(r.slots.map((s) => [s.id, { name: s.name, body: s.body, explain: s.explain }]))))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const [evaluating, setEvaluating] = useState(false);
|
|
|
|
const runEvaluation = async () => {
|
|
setEvaluating(true);
|
|
try {
|
|
await api.confluence.runEvaluationNow({ symbol });
|
|
window.location.reload();
|
|
} finally {
|
|
setEvaluating(false);
|
|
}
|
|
};
|
|
|
|
const latest = (evaluation?.latest as Record<string, unknown>) ?? null;
|
|
const quality = (latest?.quality as Quality) ?? null;
|
|
const assessments = (latest?.assessments as Array<{ id: string; state: string; note?: string }>) ?? [];
|
|
const bullEvidence = Number(latest?.bullEvidence ?? 0);
|
|
const bearEvidence = Number(latest?.bearEvidence ?? 0);
|
|
const change = evaluation?.change as { changed: boolean; changeType: string; previous: string | null } | null;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Explanatory text */}
|
|
<div className="text-xs text-fg-muted leading-relaxed">
|
|
Signal Confluence combines technical, seasonal, and valuation signals into a single
|
|
directional read. Each slot fires (green dot) when its condition is met, or stays
|
|
dormant. The overall quality grade reflects how many slots fired and in which direction.
|
|
Use the rack selector to switch between different signal subsets.
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">
|
|
Symbol: <span className="text-accent font-medium">{symbol}</span>
|
|
</label>
|
|
<div className="relative w-48">
|
|
<SymbolAutocomplete
|
|
value={searchQuery}
|
|
onChange={setSearchQuery}
|
|
onPick={pickSymbol}
|
|
onSubmitRaw={(raw) => pickSymbol(raw.toUpperCase())}
|
|
placeholder="Search symbol…"
|
|
ariaLabel="Select symbol for signal confluence"
|
|
inputClassName="w-full bg-surface-sunken border border-line rounded-lg px-3 py-1.5 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">Rack</label>
|
|
<select
|
|
value={rackId ?? ''}
|
|
onChange={(e) => setSelectedRackId(e.target.value || null)}
|
|
className="rounded-lg bg-surface-sunken border border-line px-3 py-1.5 text-xs text-fg min-w-[180px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
|
|
>
|
|
{racks.system.map((r) => (
|
|
<option key={r.id} value={r.id}>{r.name}</option>
|
|
))}
|
|
{racks.user.map((r) => (
|
|
<option key={r.id} value={r.id}>{r.name} (yours)</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quality + evidence summary */}
|
|
{quality ? (
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-sm font-medium ${QUALITY_COLOR[quality]}`}>
|
|
{QUALITY_LABEL[quality]}
|
|
</div>
|
|
<div className="text-xs text-fg-muted">
|
|
Bull <span className="text-green-400 font-medium">{bullEvidence.toFixed(1)}</span>
|
|
{' '}·{' '}
|
|
Bear <span className="text-red-400 font-medium">{bearEvidence.toFixed(1)}</span>
|
|
{' '}·{' '}
|
|
Net <span className={bullEvidence - bearEvidence >= 0 ? 'text-green-400' : 'text-red-400'}>
|
|
{(bullEvidence - bearEvidence).toFixed(1)}
|
|
</span>
|
|
</div>
|
|
{change?.changed && (
|
|
<div className={`text-xs px-2 py-0.5 rounded ${change.changeType === 'improved' ? 'bg-green-600/10 text-green-400' : 'bg-red-600/10 text-red-400'}`}>
|
|
{change.changeType === 'improved' ? 'Improved' : 'Deteriorated'} from {change.previous ?? '?'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : evaluation !== null ? (
|
|
<div className="flex items-center gap-3 text-sm text-fg-muted">
|
|
<span>No evaluations yet for {symbol}.</span>
|
|
<button
|
|
onClick={runEvaluation}
|
|
disabled={evaluating}
|
|
className="rounded bg-bg-secondary border border-fg-muted/30 px-3 py-1.5 text-xs text-fg hover:border-fg-muted/60 disabled:opacity-50"
|
|
>
|
|
{evaluating ? 'Evaluating…' : 'Run evaluation now'}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Signal Timeline chart */}
|
|
{symbol && (
|
|
<CollapsibleSection title="Signal Timeline" defaultOpen>
|
|
<ConfluenceChart symbol={symbol} />
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Slot evidence grid */}
|
|
{assessments.length > 0 && (
|
|
<CollapsibleSection title="Slot Evidence" defaultOpen>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
{assessments.map((a) => {
|
|
const meta = slotMeta.get(a.id);
|
|
const fired = a.state === 'fired';
|
|
return (
|
|
<div key={a.id} className={`rounded border px-2.5 py-2 text-xs ${fired ? 'border-fg-muted/30 bg-bg-secondary' : 'border-fg-muted/10 bg-bg-secondary/50 opacity-60'}`}>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`inline-block w-1.5 h-1.5 rounded-full ${fired ? 'bg-green-400' : 'bg-zinc-600'}`} />
|
|
<span className="font-medium text-fg truncate">{meta?.name ?? a.id}</span>
|
|
<span className={`ml-auto text-[10px] px-1 rounded ${BODY_BADGE[meta?.body ?? 'bull']}`}>
|
|
{meta?.body ?? '?'}
|
|
</span>
|
|
</div>
|
|
{a.note && <div className="text-fg-muted leading-tight">{a.note}</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Scorecard */}
|
|
{scorecard && scorecard.slots.length > 0 && (
|
|
<CollapsibleSection title="Reliability Scorecard">
|
|
<p className="text-xs text-fg-muted mb-2">{scorecard.note}</p>
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-fg-muted border-b border-fg-muted/20">
|
|
<th className="text-left py-1">Slot</th>
|
|
<th className="text-right py-1">Fires</th>
|
|
<th className="text-right py-1">Hit Rate</th>
|
|
<th className="text-right py-1">Reliability</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{scorecard.slots
|
|
.sort((a, b) => ((b.hitRate as number) ?? 0) - ((a.hitRate as number) ?? 0))
|
|
.map((s) => (
|
|
<tr key={s.slotId as string} className="border-b border-fg-muted/10">
|
|
<td className="py-1">{slotMeta.get(s.slotId as string)?.name ?? (s.slotId as string)}</td>
|
|
<td className="text-right py-1">{s.fires as number}</td>
|
|
<td className="text-right py-1">{s.hitRate != null ? `${((s.hitRate as number) * 100).toFixed(0)}%` : '-'}</td>
|
|
<td className="text-right py-1">{(s.reliabilityWeight as number).toFixed(2)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CollapsibleSection>
|
|
)}
|
|
</div>
|
|
);
|
|
} |