Files
investor-flow/app/src/components/ConfluencePanel.tsx
T
Investor Flow Build 43786161cf fix: remove redundant symbol badge, highlight symbol in label
- Remove symbol badge between input boxes (input now stays in sync)
- Change label to 'Symbol: IREN' with the symbol value in accent/pink color
2026-08-11 23:30:07 -04:00

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>
{' '}&middot;{' '}
Bear <span className="text-red-400 font-medium">{bearEvidence.toFixed(1)}</span>
{' '}&middot;{' '}
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>
);
}