Files
signal-platform/frontend/src/components/signals/SetupsPanel.tsx
T
dennisthiessen b0e33e1606 fix: align production defaults and close review parity gaps
Ship greenfield min_rr=2.0 and conf=0, read-only Structural S/R, indicator
cache invalidation, and UI/gate language that treats GTL as screening not exit.
Align strategy_rank missing-vol fallback live vs backtest, single-source
PRIMARY_TARGET_MIN_RR, expand prod parity tests, and drop dead FE clients.
2026-07-18 13:03:22 +02:00

295 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation';
import { useTrades } from '../../hooks/useTrades';
import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../../lib/qualification';
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
import { SkeletonTable } from '../ui/Skeleton';
import { useToast } from '../ui/Toast';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Field, Input } from '../ui/Field';
import { Dropdown } from '../ui/Dropdown';
import { triggerJob } from '../../api/admin';
import type { TradeSetup } from '../../lib/types';
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
type DirectionFilter = 'both' | 'long' | 'short';
type ActionFilter = 'all' | 'LONG_HIGH' | 'LONG_MODERATE' | 'SHORT_HIGH' | 'SHORT_MODERATE' | 'NEUTRAL';
function filterTrades(
trades: TradeSetup[],
minRR: number,
direction: DirectionFilter,
minConfidence: number,
action: ActionFilter,
): TradeSetup[] {
return trades.filter((t) => {
if (t.rr_ratio < minRR) return false;
if (direction !== 'both' && t.direction !== direction) return false;
if (minConfidence > 0 && (t.confidence_score ?? 0) < minConfidence) return false;
if (action !== 'all' && t.recommended_action !== action) return false;
return true;
});
}
function getComputedValue(trade: TradeSetup, column: SortColumn): number {
const analysis = computeTradeAnalysis(trade);
switch (column) {
case 'risk_amount': return analysis.risk_amount;
case 'reward_amount': return analysis.reward_amount;
case 'stop_pct': return analysis.stop_pct;
case 'target_pct': return analysis.target_pct;
case 'confidence_score': return trade.confidence_score ?? -1;
case 'primary_target_probability':
return primaryTargetProbability(trade) ?? -1;
case 'strategy_rank':
return trade.strategy_rank ?? trade.momentum_percentile ?? -1;
case 'momentum_percentile':
return trade.momentum_percentile ?? -1;
case 'risk_level':
if (trade.risk_level === 'Low') return 1;
if (trade.risk_level === 'Medium') return 2;
if (trade.risk_level === 'High') return 3;
return 0;
default: return 0;
}
}
function sortTrades(
trades: TradeSetup[],
column: SortColumn,
direction: SortDirection,
): TradeSetup[] {
const sorted = [...trades].sort((a, b) => {
let cmp = 0;
switch (column) {
case 'symbol':
cmp = a.symbol.localeCompare(b.symbol);
break;
case 'direction':
cmp = a.direction.localeCompare(b.direction);
break;
case 'recommended_action':
cmp = (a.recommended_action ?? '').localeCompare(b.recommended_action ?? '');
break;
case 'detected_at':
cmp = new Date(a.detected_at).getTime() - new Date(b.detected_at).getTime();
break;
case 'risk_amount':
case 'reward_amount':
case 'stop_pct':
case 'target_pct':
case 'confidence_score':
case 'primary_target_probability':
case 'strategy_rank':
case 'momentum_percentile':
case 'risk_level':
cmp = getComputedValue(a, column) - getComputedValue(b, column);
break;
case 'entry_price':
case 'stop_loss':
case 'target':
case 'rr_ratio':
case 'composite_score':
cmp = a[column] - b[column];
break;
}
return direction === 'asc' ? cmp : -cmp;
});
return sorted;
}
export function SetupsPanel() {
const { data: trades, isLoading, isError, error } = useTrades();
const activation = useActivation();
const queryClient = useQueryClient();
const toast = useToast();
// "Qualified only" applies the admin activation gate; the refinement filters
// can raise the bar further.
const [qualifiedOnly, setQualifiedOnly] = useState(true);
const [minRR, setMinRR] = useState(0);
const [minConfidence, setMinConfidence] = useState(0);
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
// Production book orders by 80/20 strategy_rank, not raw R:R.
const [sortColumn, setSortColumn] = useState<SortColumn>('strategy_rank');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
// Keep the Min R:R / Min Confidence inputs showing the *effective* floor: when
// qualified-only is on they reflect the activation gate (so they're never a
// misleading 0); off, they reset to 0 (no minimum).
useEffect(() => {
if (qualifiedOnly && activation.data) {
setMinRR(activation.data.min_rr);
setMinConfidence(activation.data.min_confidence);
} else if (!qualifiedOnly) {
setMinRR(0);
setMinConfidence(0);
}
}, [qualifiedOnly, activation.data]);
const scanMutation = useMutation({
mutationFn: () => triggerJob('rr_scanner'),
onSuccess: () => {
toast.addToast('success', 'Scanner triggered. Results will refresh shortly.');
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['trades'] }), 3000);
},
onError: () => {
toast.addToast('error', 'Failed to trigger scanner');
},
});
const handleSort = (column: SortColumn) => {
if (column === sortColumn) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const processed = useMemo(() => {
if (!trades) return [];
let base = trades;
if (qualifiedOnly && activation.data) {
base = base.filter((t) => qualifiesSetup(t, activation.data!));
}
const filtered = filterTrades(base, minRR, directionFilter, minConfidence, actionFilter);
return sortTrades(filtered, sortColumn, sortDirection);
}, [trades, qualifiedOnly, activation.data, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
return (
<div className="space-y-4">
{/* Action row — Run Scanner is a job trigger, kept apart from the filters */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-gray-500">
Setups from the latest scan. Re-run to refresh against current prices.
</p>
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
{scanMutation.isPending ? 'Scanning…' : 'Run Scanner'}
</Button>
</div>
{/* Filters — qualified gate on top, refinements below */}
<div className="glass-sm space-y-4 p-4">
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={qualifiedOnly}
onChange={(e) => setQualifiedOnly(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Qualified only
{activation.data && (
<span className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
)}
</span>
</label>
<div className="flex flex-wrap items-end gap-4 border-t border-white/[0.06] pt-4">
<Field label="Direction" htmlFor="direction">
<Dropdown
id="direction"
value={directionFilter}
onChange={(v) => setDirectionFilter(v as DirectionFilter)}
className="w-32"
options={[
{ value: 'both', label: 'Both' },
{ value: 'long', label: 'Long' },
{ value: 'short', label: 'Short' },
]}
/>
</Field>
<Field label="Min Risk:Reward" htmlFor="min-rr">
<div className="flex items-center gap-1">
<span className="text-sm text-gray-400">1 :</span>
<Input
id="min-rr"
type="number"
min={0}
step={0.1}
value={minRR}
onChange={(e) => setMinRR(Number(e.target.value) || 0)}
className="w-20"
/>
</div>
</Field>
<Field label="Min Confidence" htmlFor="min-confidence">
<Input
id="min-confidence"
type="number"
min={0}
max={100}
step={1}
value={minConfidence}
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
className="w-24"
/>
</Field>
<Field label="Recommended Action" htmlFor="action">
<Dropdown
id="action"
value={actionFilter}
onChange={(v) => setActionFilter(v as ActionFilter)}
className="w-56"
options={[
{ value: 'all', label: 'All' },
{ value: 'LONG_HIGH', label: RECOMMENDATION_ACTION_LABELS.LONG_HIGH },
{ value: 'LONG_MODERATE', label: RECOMMENDATION_ACTION_LABELS.LONG_MODERATE },
{ value: 'SHORT_HIGH', label: RECOMMENDATION_ACTION_LABELS.SHORT_HIGH },
{ value: 'SHORT_MODERATE', label: RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE },
{ value: 'NEUTRAL', label: RECOMMENDATION_ACTION_LABELS.NEUTRAL },
]}
/>
</Field>
</div>
</div>
<Disclosure summary="How the scanner works & action glossary">
<p className="mb-3 text-xs text-gray-400">
The scanner builds long setups with a 1.5× ATR stop and a Gate Target Ladder proposal used
only for R:R / reach-probability screening not as a take-profit. Structural chart S/R is
separate. Live exit is the ATR trail / max hold. Click{' '}
<span className="font-medium text-gray-300">Run Scanner</span> to scan all tickers now, or
wait for the scheduled run.
</p>
<div className="grid gap-1 md:grid-cols-2">
{RECOMMENDATION_ACTION_GLOSSARY.map((item) => (
<p key={item.action} className="text-xs text-gray-300">
<span className="font-semibold text-blue-300">{RECOMMENDATION_ACTION_LABELS[item.action]}:</span>{' '}
{item.description}
</p>
))}
</div>
</Disclosure>
{isLoading && <SkeletonTable rows={8} cols={8} />}
{isError && (
<Callout variant="error">
{error instanceof Error ? error.message : 'Failed to load trade setups'}
</Callout>
)}
{trades && processed.length === 0 && !isLoading && (
<Callout variant="empty">
No trade setups match the current filters. Try lowering the Min R:R or click Run Scanner to refresh.
</Callout>
)}
{trades && processed.length > 0 && (
<TradeTable
trades={processed}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
)}
</div>
);
}