Big refactoring
Some checks failed
Deploy / lint (push) Failing after 21s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-03-03 15:20:18 +01:00
parent 181cfe6588
commit 0a011d4ce9
55 changed files with 6898 additions and 544 deletions

View File

@@ -17,11 +17,79 @@ export function JobControls() {
const { data: jobs, isLoading } = useJobs();
const toggleJob = useToggleJob();
const triggerJob = useTriggerJob();
const anyJobRunning = (jobs ?? []).some((job) => job.running);
const runningJob = jobs?.find((job) => job.running);
const pausedJob = jobs?.find((job) => !job.running && job.runtime_status === 'rate_limited');
const runningJobLabel = runningJob?.label;
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
return (
<div className="space-y-3">
{runningJob && (
<div className="rounded-xl border border-blue-400/30 bg-blue-500/10 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-blue-300">
Active job: {runningJob.label}
</div>
<div className="mt-0.5 text-[11px] text-blue-100/80">
Manual triggers are blocked until this run finishes.
</div>
</div>
<div className="text-[11px] text-blue-200">
{runningJob.runtime_processed ?? 0}
{typeof runningJob.runtime_total === 'number'
? ` / ${runningJob.runtime_total}`
: ''}
</div>
</div>
<div className="mt-2 h-1.5 w-full rounded-full bg-slate-700/80 overflow-hidden">
<div
className="h-full bg-blue-400 transition-all duration-500"
style={{
width: `${
typeof runningJob.runtime_progress_pct === 'number'
? Math.max(5, Math.min(100, runningJob.runtime_progress_pct))
: 30
}%`,
}}
/>
</div>
{runningJob.runtime_current_ticker && (
<div className="mt-1 text-[11px] text-blue-100/80">
Current: {runningJob.runtime_current_ticker}
</div>
)}
{runningJob.runtime_message && (
<div className="mt-1 text-[11px] text-blue-100/80">
{runningJob.runtime_message}
</div>
)}
</div>
)}
{!runningJob && pausedJob && (
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-amber-300">
Last run paused: {pausedJob.label}
</div>
<div className="mt-0.5 text-[11px] text-amber-100/90">
{pausedJob.runtime_message || 'Rate limit hit. The collector stopped early and will resume from last progress on the next run.'}
</div>
</div>
<div className="text-[11px] text-amber-200">
{pausedJob.runtime_processed ?? 0}
{typeof pausedJob.runtime_total === 'number'
? ` / ${pausedJob.runtime_total}`
: ''}
</div>
</div>
</div>
)}
{jobs?.map((job) => (
<div key={job.name} className="glass p-4 glass-hover">
<div className="flex flex-wrap items-center justify-between gap-4">
@@ -29,7 +97,9 @@ export function JobControls() {
{/* Status dot */}
<span
className={`inline-block h-2.5 w-2.5 rounded-full shrink-0 ${
job.enabled
job.running
? 'bg-blue-400 shadow-lg shadow-blue-400/40'
: job.enabled
? 'bg-emerald-400 shadow-lg shadow-emerald-400/40'
: 'bg-gray-500'
}`}
@@ -37,8 +107,28 @@ export function JobControls() {
<div>
<span className="text-sm font-medium text-gray-200">{job.label}</span>
<div className="flex items-center gap-3 mt-0.5">
<span className={`text-[11px] font-medium ${job.enabled ? 'text-emerald-400' : 'text-gray-500'}`}>
{job.enabled ? 'Active' : 'Inactive'}
<span
className={`text-[11px] font-medium ${
job.running
? 'text-blue-300'
: job.runtime_status === 'rate_limited'
? 'text-amber-300'
: job.runtime_status === 'error'
? 'text-red-300'
: job.enabled
? 'text-emerald-400'
: 'text-gray-500'
}`}
>
{job.running
? 'Running'
: job.runtime_status === 'rate_limited'
? 'Paused (rate-limited)'
: job.runtime_status === 'error'
? 'Last run error'
: job.enabled
? 'Active'
: 'Inactive'}
</span>
{job.enabled && job.next_run_at && (
<span className="text-[11px] text-gray-500">
@@ -49,6 +139,35 @@ export function JobControls() {
<span className="text-[11px] text-red-400">Not registered</span>
)}
</div>
{job.running && (
<div className="mt-2 space-y-1.5">
<div className="flex items-center justify-between text-[11px] text-gray-400">
<span>
{job.runtime_processed ?? 0}
{typeof job.runtime_total === 'number' ? ` / ${job.runtime_total}` : ''}
{' '}processed
</span>
{typeof job.runtime_progress_pct === 'number' && (
<span>{Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}%</span>
)}
</div>
<div className="h-1.5 w-56 rounded-full bg-slate-700/80 overflow-hidden">
<div
className="h-full bg-blue-400 transition-all duration-500"
style={{
width: `${
typeof job.runtime_progress_pct === 'number'
? Math.max(5, Math.min(100, job.runtime_progress_pct))
: 30
}%`,
}}
/>
</div>
{job.runtime_current_ticker && (
<div className="text-[11px] text-gray-500">Current: {job.runtime_current_ticker}</div>
)}
</div>
)}
</div>
</div>
@@ -68,13 +187,26 @@ export function JobControls() {
<button
type="button"
onClick={() => triggerJob.mutate(job.name)}
disabled={triggerJob.isPending || !job.enabled}
disabled={triggerJob.isPending || !job.enabled || anyJobRunning}
className="btn-gradient px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{triggerJob.isPending ? 'Triggering…' : 'Trigger Now'}</span>
<span>
{job.running
? 'Running…'
: triggerJob.isPending
? 'Triggering…'
: anyJobRunning
? 'Blocked'
: 'Trigger Now'}
</span>
</button>
</div>
</div>
{anyJobRunning && !job.running && (
<div className="mt-2 text-[11px] text-gray-500">
Manual trigger blocked while {runningJobLabel ?? 'another job'} is running.
</div>
)}
</div>
))}
</div>

View File

@@ -0,0 +1,105 @@
import { useQueryClient } from '@tanstack/react-query';
import { usePipelineReadiness } from '../../hooks/useAdmin';
import { useFetchSymbolData } from '../../hooks/useFetchSymbolData';
import { formatDateTime } from '../../lib/format';
import { SkeletonTable } from '../ui/Skeleton';
function scoreBadge(score: number | null) {
if (score === null) return <span className="text-[11px] text-gray-500"></span>;
const cls = score >= 60 ? 'text-emerald-400' : score >= 40 ? 'text-amber-400' : 'text-red-400';
return <span className={`text-[11px] font-medium ${cls}`}>{score.toFixed(0)}</span>;
}
export function PipelineReadinessPanel() {
const queryClient = useQueryClient();
const { data, isLoading, isError, error, isFetching } = usePipelineReadiness();
const fetchMutation = useFetchSymbolData({
includeSymbolPrefix: true,
invalidatePipelineReadiness: true,
});
if (isLoading) return <SkeletonTable rows={6} cols={6} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load pipeline readiness'}</p>;
const rows = data ?? [];
return (
<div className="glass p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-gray-200">Pipeline Readiness</h3>
<p className="text-xs text-gray-500">Shows why tickers may be missing in scanner/rankings and what is incomplete.</p>
</div>
<button
type="button"
className="rounded border border-white/[0.12] px-3 py-1.5 text-xs text-gray-300 hover:text-white disabled:opacity-50"
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] })}
disabled={isFetching}
>
{isFetching ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{rows.length === 0 ? (
<p className="text-sm text-gray-500">No tickers available.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-white/[0.08] text-gray-500 uppercase tracking-wider">
<th className="px-2 py-2">Symbol</th>
<th className="px-2 py-2">OHLCV</th>
<th className="px-2 py-2">Dims</th>
<th className="px-2 py-2">S/R</th>
<th className="px-2 py-2">Scanner</th>
<th className="px-2 py-2">Missing Reasons</th>
<th className="px-2 py-2">Action</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.symbol} className="border-b border-white/[0.05] align-top">
<td className="px-2 py-2 font-medium text-gray-200">{row.symbol}</td>
<td className="px-2 py-2 text-gray-300">
<div>{row.ohlcv_bars} bars</div>
<div className="text-[11px] text-gray-500">{row.ohlcv_last_date ? formatDateTime(row.ohlcv_last_date) : '—'}</div>
</td>
<td className="px-2 py-2 text-gray-300">
<div className="grid grid-cols-5 gap-1">
{scoreBadge(row.dimensions.technical)}
{scoreBadge(row.dimensions.sr_quality)}
{scoreBadge(row.dimensions.sentiment)}
{scoreBadge(row.dimensions.fundamental)}
{scoreBadge(row.dimensions.momentum)}
</div>
<div className="mt-1 text-[10px] text-gray-500">T SR S F M</div>
</td>
<td className="px-2 py-2 text-gray-300">{row.sr_level_count}</td>
<td className="px-2 py-2">
<span className={`inline-block rounded px-2 py-0.5 text-[11px] ${row.ready_for_scanner ? 'bg-emerald-500/20 text-emerald-300' : 'bg-amber-500/20 text-amber-300'}`}>
{row.ready_for_scanner ? 'Ready' : 'Blocked'}
</span>
<div className="mt-1 text-[11px] text-gray-500">setups: {row.trade_setup_count}</div>
</td>
<td className="px-2 py-2 text-[11px] text-amber-300">
{row.missing_reasons.length ? row.missing_reasons.join(', ') : <span className="text-emerald-300">none</span>}
</td>
<td className="px-2 py-2">
<button
type="button"
className="rounded border border-white/[0.12] px-2.5 py-1 text-[11px] text-gray-300 hover:text-white disabled:opacity-50"
onClick={() => fetchMutation.mutate(row.symbol)}
disabled={fetchMutation.isPending}
>
{fetchMutation.isPending && fetchMutation.variables === row.symbol ? 'Fetching…' : 'Fetch Data'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import type { RecommendationConfig } from '../../lib/types';
import { useRecommendationSettings, useUpdateRecommendationSettings } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: RecommendationConfig = {
high_confidence_threshold: 70,
moderate_confidence_threshold: 50,
confidence_diff_threshold: 20,
signal_alignment_weight: 0.15,
sr_strength_weight: 0.2,
distance_penalty_factor: 0.1,
momentum_technical_divergence_threshold: 30,
fundamental_technical_divergence_threshold: 40,
};
function NumberInput({
label,
value,
min,
max,
step,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
}) {
return (
<label className="block space-y-1">
<span className="text-xs text-gray-400">{label}</span>
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full input-glass px-3 py-2 text-sm"
/>
</label>
);
}
export function RecommendationSettings() {
const { data, isLoading, isError, error } = useRecommendationSettings();
const update = useUpdateRecommendationSettings();
const [form, setForm] = useState<RecommendationConfig>(DEFAULTS);
useEffect(() => {
if (data) setForm(data);
}, [data]);
const setField = (field: keyof RecommendationConfig, value: number) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const onSave = () => {
update.mutate(form as unknown as Record<string, number>);
};
const onReset = () => {
setForm(DEFAULTS);
update.mutate(DEFAULTS as unknown as Record<string, number>);
};
if (isLoading) return <SkeletonTable rows={6} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load recommendation settings'}</p>;
return (
<div className="glass p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-200">Recommendation Configuration</h3>
<div className="grid gap-4 md:grid-cols-3">
<NumberInput label="High Confidence Threshold (%)" value={form.high_confidence_threshold} min={0} max={100} onChange={(v) => setField('high_confidence_threshold', v)} />
<NumberInput label="Moderate Confidence Threshold (%)" value={form.moderate_confidence_threshold} min={0} max={100} onChange={(v) => setField('moderate_confidence_threshold', v)} />
<NumberInput label="Confidence Difference Threshold (%)" value={form.confidence_diff_threshold} min={0} max={100} onChange={(v) => setField('confidence_diff_threshold', v)} />
<NumberInput label="Signal Alignment Weight" value={form.signal_alignment_weight} min={0} max={1} step={0.01} onChange={(v) => setField('signal_alignment_weight', v)} />
<NumberInput label="S/R Strength Weight" value={form.sr_strength_weight} min={0} max={1} step={0.01} onChange={(v) => setField('sr_strength_weight', v)} />
<NumberInput label="Distance Penalty Factor" value={form.distance_penalty_factor} min={0} max={1} step={0.01} onChange={(v) => setField('distance_penalty_factor', v)} />
<NumberInput label="Momentum-Technical Divergence Threshold" value={form.momentum_technical_divergence_threshold} min={0} max={100} onChange={(v) => setField('momentum_technical_divergence_threshold', v)} />
<NumberInput label="Fundamental-Technical Divergence Threshold" value={form.fundamental_technical_divergence_threshold} min={0} max={100} onChange={(v) => setField('fundamental_technical_divergence_threshold', v)} />
</div>
<div className="flex items-center gap-2">
<button className="btn-gradient px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
{update.isPending ? 'Saving…' : 'Save Configuration'}
</button>
<button className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white" onClick={onReset} disabled={update.isPending}>
Reset to Defaults
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import {
useBootstrapTickers,
useTickerUniverseSetting,
useUpdateTickerUniverseSetting,
} from '../../hooks/useAdmin';
import type { TickerUniverse } from '../../lib/types';
const UNIVERSE_OPTIONS: Array<{ value: TickerUniverse; label: string }> = [
{ value: 'sp500', label: 'S&P 500' },
{ value: 'nasdaq100', label: 'NASDAQ 100' },
{ value: 'nasdaq_all', label: 'NASDAQ All' },
];
export function TickerUniverseBootstrap() {
const { data, isLoading, isError, error } = useTickerUniverseSetting();
const updateDefault = useUpdateTickerUniverseSetting();
const bootstrap = useBootstrapTickers();
const [universe, setUniverse] = useState<TickerUniverse>('sp500');
const [pruneMissing, setPruneMissing] = useState(false);
useEffect(() => {
if (data?.universe) {
setUniverse(data.universe);
}
}, [data]);
const onSaveDefault = () => {
updateDefault.mutate(universe);
};
const onBootstrap = () => {
bootstrap.mutate({ universe, pruneMissing });
};
return (
<div className="glass p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-200">Ticker Universe Discovery</h3>
<p className="text-xs text-gray-500">
Auto-discover tickers from a predefined universe and keep your registry updated.
</p>
{isError && (
<p className="text-sm text-red-400">
{(error as Error)?.message || 'Failed to load ticker universe setting'}
</p>
)}
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1 md:col-span-2">
<span className="text-xs text-gray-400">Default Universe</span>
<select
value={universe}
onChange={(e) => setUniverse(e.target.value as TickerUniverse)}
className="w-full input-glass px-3 py-2 text-sm"
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
>
{UNIVERSE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="flex items-end gap-2 pb-2">
<input
type="checkbox"
checked={pruneMissing}
onChange={(e) => setPruneMissing(e.target.checked)}
disabled={bootstrap.isPending}
className="h-4 w-4 rounded border-white/20 bg-transparent"
/>
<span className="text-xs text-gray-400">Prune removed symbols</span>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button
className="btn-gradient px-4 py-2 text-sm disabled:opacity-50"
onClick={onSaveDefault}
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
>
{updateDefault.isPending ? 'Saving…' : 'Save Default Universe'}
</button>
<button
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white disabled:opacity-50"
onClick={onBootstrap}
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
>
{bootstrap.isPending ? 'Bootstrapping…' : 'Bootstrap Now'}
</button>
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { Link } from 'react-router-dom';
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
export type SortColumn = 'symbol' | 'direction' | 'entry_price' | 'stop_loss' | 'target' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'composite_score' | 'detected_at';
export type SortColumn = 'symbol' | 'direction' | 'recommended_action' | 'confidence_score' | 'entry_price' | 'stop_loss' | 'target' | 'best_target_probability' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'risk_level' | 'composite_score' | 'detected_at';
export type SortDirection = 'asc' | 'desc';
interface TradeTableProps {
@@ -14,15 +15,19 @@ interface TradeTableProps {
const columns: { key: SortColumn; label: string }[] = [
{ key: 'symbol', label: 'Symbol' },
{ key: 'recommended_action', label: 'Recommended Action' },
{ key: 'confidence_score', label: 'Confidence' },
{ key: 'direction', label: 'Direction' },
{ key: 'entry_price', label: 'Entry' },
{ key: 'stop_loss', label: 'Stop Loss' },
{ key: 'target', label: 'Target' },
{ key: 'best_target_probability', label: 'Best Target' },
{ key: 'risk_amount', label: 'Risk $' },
{ key: 'reward_amount', label: 'Reward $' },
{ key: 'rr_ratio', label: 'R:R' },
{ key: 'stop_pct', label: '% to Stop' },
{ key: 'target_pct', label: '% to Target' },
{ key: 'risk_level', label: 'Risk' },
{ key: 'composite_score', label: 'Score' },
{ key: 'detected_at', label: 'Detected' },
];
@@ -53,6 +58,19 @@ function sortIndicator(column: SortColumn, active: SortColumn, dir: SortDirectio
return dir === 'asc' ? ' ▲' : ' ▼';
}
function riskLevelClass(riskLevel: TradeSetup['risk_level']) {
if (riskLevel === 'Low') return 'text-emerald-400';
if (riskLevel === 'Medium') return 'text-amber-400';
if (riskLevel === 'High') return 'text-red-400';
return 'text-gray-400';
}
function bestTargetText(trade: TradeSetup) {
if (!trade.targets || trade.targets.length === 0) return '—';
const best = [...trade.targets].sort((a, b) => b.probability - a.probability)[0];
return `${formatPrice(best.price)} (${best.probability.toFixed(0)}%)`;
}
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) {
if (trades.length === 0) {
return <p className="py-8 text-center text-sm text-gray-500">No trade setups match the current filters.</p>;
@@ -84,6 +102,17 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
{trade.symbol}
</Link>
</td>
<td className="px-4 py-3.5">
<div className="space-y-0.5">
<span className="text-xs font-semibold text-indigo-300">{recommendationActionLabel(trade.recommended_action)}</span>
{recommendationActionDirection(trade.recommended_action) !== 'neutral' && recommendationActionDirection(trade.recommended_action) !== trade.direction && (
<div className="text-[10px] text-amber-400">Alternative setup (not preferred)</div>
)}
</div>
</td>
<td className="px-4 py-3.5">
<span className="font-mono text-gray-200">{trade.confidence_score === null ? '—' : `${trade.confidence_score.toFixed(1)}%`}</span>
</td>
<td className="px-4 py-3.5">
<span className={trade.direction === 'long' ? 'font-medium text-emerald-400' : 'font-medium text-red-400'}>
{trade.direction}
@@ -92,11 +121,13 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{bestTargetText(trade)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.risk_amount)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.reward_amount)}</td>
<td className={`px-4 py-3.5 font-mono font-semibold ${rrColorClass(trade.rr_ratio)}`}>{trade.rr_ratio.toFixed(2)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPercent(analysis.stop_pct)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPercent(analysis.target_pct)}</td>
<td className={`px-4 py-3.5 font-semibold ${riskLevelClass(trade.risk_level)}`}>{trade.risk_level ?? '—'}</td>
<td className="px-4 py-3.5">
<span className={`font-semibold ${trade.composite_score > 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}>
{Math.round(trade.composite_score)}

View File

@@ -0,0 +1,168 @@
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent } from '../../lib/format';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
interface RecommendationPanelProps {
symbol: string;
longSetup?: TradeSetup;
shortSetup?: TradeSetup;
}
function riskClass(risk: TradeSetup['risk_level']) {
if (risk === 'Low') return 'text-emerald-400';
if (risk === 'Medium') return 'text-amber-400';
if (risk === 'High') return 'text-red-400';
return 'text-gray-400';
}
function isRecommended(setup: TradeSetup | undefined, action: TradeSetup['recommended_action'] | undefined) {
if (!setup || !action) return false;
if (setup.direction === 'long') return action.startsWith('LONG');
return action.startsWith('SHORT');
}
function TargetTable({ setup }: { setup: TradeSetup }) {
if (!setup.targets || setup.targets.length === 0) {
return <p className="text-xs text-gray-500">No target probabilities available.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
<th className="py-2 pr-3">Classification</th>
<th className="py-2 pr-3">Price</th>
<th className="py-2 pr-3">Distance</th>
<th className="py-2 pr-3">R:R</th>
<th className="py-2">Probability</th>
</tr>
</thead>
<tbody>
{setup.targets.map((target) => (
<tr key={`${setup.id}-${target.sr_level_id}-${target.price}`} className="border-b border-white/[0.04]">
<td className="py-2 pr-3 text-gray-300">{target.classification}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{target.rr_ratio.toFixed(2)}</td>
<td className="py-2 font-mono text-gray-200">{target.probability.toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup['recommended_action'] }) {
if (!setup) {
return (
<div className="glass-sm p-4 text-xs text-gray-500">
Setup unavailable for this direction.
</div>
);
}
const recommended = isRecommended(setup, action);
return (
<div
data-direction={setup.direction}
className={`glass-sm p-4 space-y-3 ${recommended ? 'border border-emerald-500/40' : 'opacity-80'}`}
>
<div className="flex items-center justify-between">
<h4 className={`text-sm font-semibold ${setup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
{setup.direction.toUpperCase()}
</h4>
<span className="text-xs text-gray-300">{setup.confidence_score?.toFixed(1) ?? '—'}%</span>
</div>
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (
<p className="text-[11px] text-amber-400">Alternative setup (ticker bias currently favors the opposite direction).</p>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-gray-500">Entry</div><div className="font-mono text-gray-200">{formatPrice(setup.entry_price)}</div>
<div className="text-gray-500">Stop</div><div className="font-mono text-gray-200">{formatPrice(setup.stop_loss)}</div>
<div className="text-gray-500">Primary Target</div><div className="font-mono text-gray-200">{formatPrice(setup.target)}</div>
<div className="text-gray-500">R:R</div><div className="font-mono text-gray-200">{setup.rr_ratio.toFixed(2)}</div>
</div>
<TargetTable setup={setup} />
{setup.conflict_flags.length > 0 && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] text-amber-300">
{setup.conflict_flags.join(' • ')}
</div>
)}
</div>
);
}
export function RecommendationPanel({ symbol, longSetup, shortSetup }: RecommendationPanelProps) {
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
const preferredDirection = recommendationActionDirection(action);
const preferredSetup =
preferredDirection === 'long'
? longSetup
: preferredDirection === 'short'
? shortSetup
: undefined;
const alternativeSetup =
preferredDirection === 'long'
? shortSetup
: preferredDirection === 'short'
? longSetup
: undefined;
if (!longSetup && !shortSetup) {
return null;
}
return (
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Recommendation</h2>
<div className="glass p-5 space-y-4">
<div className="flex flex-wrap items-center gap-4">
<span className="text-sm font-semibold text-indigo-300">{recommendationActionLabel(action)}</span>
<span className={`text-sm font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
Risk: {summary?.risk_level ?? '—'}
</span>
<span className="text-sm text-gray-300">Composite: {summary?.composite_score?.toFixed(1) ?? '—'}</span>
<span className="text-xs text-gray-500">{symbol.toUpperCase()}</span>
</div>
<p className="text-xs text-gray-500">Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.</p>
{summary?.reasoning && (
<p className="text-sm text-gray-300">{summary.reasoning}</p>
)}
{preferredDirection !== 'neutral' && preferredSetup ? (
<div className="space-y-3">
<SetupCard setup={preferredSetup} action={action} />
{alternativeSetup && (
<details className="glass-sm p-3">
<summary className="cursor-pointer text-xs font-medium text-gray-300">
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
</summary>
<div className="mt-3">
<SetupCard setup={alternativeSetup} action={action} />
</div>
</details>
)}
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<SetupCard setup={longSetup} action={action} />
<SetupCard setup={shortSetup} action={action} />
</div>
)}
</div>
</section>
);
}