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.
This commit is contained in:
2026-07-18 13:03:22 +02:00
parent e07da0f8f0
commit b0e33e1606
25 changed files with 429 additions and 221 deletions
-6
View File
@@ -56,12 +56,6 @@ export function updateSetting(key: string, value: string) {
.then((r) => r.data);
}
export function updateRegistration(enabled: boolean) {
return apiClient
.put<{ message: string }>('admin/settings/registration', { enabled })
.then((r) => r.data);
}
export function getRecommendationSettings() {
return apiClient
.get<RecommendationConfig>('admin/settings/recommendations')
-4
View File
@@ -14,7 +14,3 @@ export function list(params?: TradeListParams) {
export function bySymbol(symbol: string) {
return apiClient.get<TradeSetup[]>(`trades/${symbol.toUpperCase()}`).then((r) => r.data);
}
export function history(symbol: string) {
return apiClient.get<TradeSetup[]>(`trades/${symbol.toUpperCase()}/history`).then((r) => r.data);
}
@@ -3,10 +3,11 @@ import type { ActivationConfig } from '../../lib/types';
import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
/** Mirrors app.services.admin_service.ACTIVATION_DEFAULTS — keep in sync. */
const DEFAULTS: ActivationConfig = {
min_momentum_percentile: 80,
min_rr: 1.2,
min_confidence: 55,
min_rr: 2.0,
min_confidence: 0,
require_high_conviction: false,
exclude_conflicts: false,
exclude_neutral: true,
+4 -4
View File
@@ -39,7 +39,7 @@ export function RBar({ r, max = 1.6 }: { r: number | null; max?: number }) {
}
/* ------------------------------------------------------------------ */
/* PriceRail — stop → entry → now → target laid out spatially */
/* PriceRail — stop → entry → now → gate level laid out spatially */
/* ------------------------------------------------------------------ */
export function PriceRail({
@@ -69,7 +69,7 @@ export function PriceRail({
const progressWidth = current != null ? Math.abs(pct(current) - pct(entry)) : 0;
return (
<div className="hz-rail" role="img" aria-label={
`Stop ${fmt(stop)}, entry ${fmt(entry)}, now ${current != null ? fmt(current) : 'unknown'}, target ${fmt(target)}`
`Stop ${fmt(stop)}, entry ${fmt(entry)}, now ${current != null ? fmt(current) : 'unknown'}, gate ${fmt(target)}`
}>
<div className="hz-rail-track" />
<div
@@ -111,10 +111,10 @@ export function PriceRail({
</span>
</div>
)}
<div className="hz-rail-mark" style={{ left: `${pct(target)}%` }}>
<div className="hz-rail-mark" style={{ left: `${pct(target)}%` }} title="Gate level — screening only, not a take-profit">
<span className="hz-rail-ring" />
<span className="hz-rail-label">
<em>target</em>
<em>gate</em>
<b>{fmt(target)}</b>
{rTarget != null && <i>+{fmt(rTarget, 1)}R</i>}
</span>
+42 -10
View File
@@ -4,7 +4,25 @@ import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
import { primaryTarget } from '../../lib/qualification';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
export type SortColumn = 'symbol' | 'direction' | 'recommended_action' | 'confidence_score' | 'entry_price' | 'stop_loss' | 'target' | 'primary_target_probability' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'risk_level' | 'composite_score' | 'detected_at';
export type SortColumn =
| 'symbol'
| 'direction'
| 'recommended_action'
| 'confidence_score'
| 'entry_price'
| 'stop_loss'
| 'target'
| 'primary_target_probability'
| 'risk_amount'
| 'reward_amount'
| 'rr_ratio'
| 'stop_pct'
| 'target_pct'
| 'risk_level'
| 'composite_score'
| 'strategy_rank'
| 'momentum_percentile'
| 'detected_at';
export type SortDirection = 'asc' | 'desc';
interface TradeTableProps {
@@ -16,20 +34,22 @@ interface TradeTableProps {
const columns: { key: SortColumn; label: string }[] = [
{ key: 'symbol', label: 'Symbol' },
{ key: 'strategy_rank', label: 'Prod rank' },
{ key: 'momentum_percentile', label: 'Mom %ile' },
{ 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: 'primary_target_probability', label: 'Primary Target' },
{ key: 'target', label: 'Gate level' },
{ key: 'primary_target_probability', label: 'Gate reach' },
{ key: 'risk_amount', label: 'Risk $' },
{ key: 'reward_amount', label: 'Reward $' },
{ key: 'rr_ratio', label: 'R:R' },
{ key: 'rr_ratio', label: 'Gate R:R' },
{ key: 'stop_pct', label: '% to Stop' },
{ key: 'target_pct', label: '% to Target' },
{ key: 'target_pct', label: '% to gate' },
{ key: 'risk_level', label: 'Risk' },
{ key: 'composite_score', label: 'Score' },
{ key: 'composite_score', label: 'Composite' },
{ key: 'detected_at', label: 'Detected' },
];
@@ -105,6 +125,12 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
{trade.symbol}
</Link>
</td>
<td className="px-4 py-3.5 font-mono text-gray-200" title="80% residual momentum + 20% vol — production book order">
{trade.strategy_rank != null ? trade.strategy_rank.toFixed(1) : '—'}
</td>
<td className="px-4 py-3.5 font-mono text-gray-200" title="Residual 12-1 momentum percentile (activation gate)">
{trade.momentum_percentile != null ? trade.momentum_percentile.toFixed(0) : '—'}
</td>
<td className="px-4 py-3.5">
<div className="space-y-0.5">
<span className="text-xs font-semibold text-blue-300">{recommendationActionLabel(trade.recommended_action)}</span>
@@ -123,15 +149,21 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
</td>
<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">{primaryTargetText(trade)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200" title="Gate Target Ladder level — screening only, not an exit">
{formatPrice(trade.target)}
</td>
<td className="px-4 py-3.5 font-mono text-gray-200" title="Reach probability for the gate level before stop">
{primaryTargetText(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 font-semibold ${rrColorClass(trade.rr_ratio)}`} title="Gate R:R — not the live trail exit">
{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">
<td className="px-4 py-3.5" title="Display quality only — does not select trades">
<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)}
</span>
@@ -44,6 +44,10 @@ function getComputedValue(trade: TradeSetup, column: SortColumn): number {
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;
@@ -79,6 +83,8 @@ function sortTrades(
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;
@@ -108,7 +114,8 @@ export function SetupsPanel() {
const [minConfidence, setMinConfidence] = useState(0);
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
// 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
@@ -244,10 +251,11 @@ export function SetupsPanel() {
<Disclosure summary="How the scanner works & action glossary">
<p className="mb-3 text-xs text-gray-400">
The scanner identifies asymmetric risk-reward trade setups by analyzing S/R levels as
price targets and using ATR-based stops to define risk. Click{' '}
<span className="font-medium text-gray-300">Run Scanner</span> to scan all tickers now,
or wait for the scheduled run.
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) => (
@@ -109,19 +109,19 @@ export function TrackRecordPanel() {
<Disclosure summary="Track-record maintenance">
<div className="space-y-4 pt-1">
<p className="max-w-2xl text-xs text-gray-500">
The live check replays every setup against the daily bars after detection: target before stop =
win, stop first = loss (both in one bar counts conservatively as a loss), neither within 30
trading days = expired at 0R. Only setups whose full window has elapsed count; younger ones are
still maturing (near stops resolve fast, far targets need time, so early numbers skew negative).
The evaluator scores <span className="text-gray-300">all</span> setups qualified or not, so
unqualified ones stay a control group and runs nightly.
<span className="text-amber-300/90">Diagnostic only not production P&amp;L.</span>{' '}
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
initial stop / ATR trail / max hold see paper trades and the portfolio monitor above.
Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading
days = expired at 0R. Only matured windows count. Scores{' '}
<span className="text-gray-300">all</span> setups as a control group; runs nightly.
</p>
{/* Diagnostic, not strategy validation: live target/stop outcomes vs the backtest's target/stop model. */}
<div className="glass-sm space-y-2 p-4">
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-1">
<span className="text-sm text-gray-300">Setup-outcome pipeline check</span>
<span className="text-sm text-gray-300">Gate barrier pipeline check</span>
<span className="text-sm text-gray-400">
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
</span>
@@ -129,7 +129,7 @@ export function TrackRecordPanel() {
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
</span>
<span className="text-xs text-gray-500">
{liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · qualified target/stop
{liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · not ATR-trail book
</span>
</div>
<StatusChip status={status} />
@@ -203,6 +203,31 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
selectedPrice?: number | null;
onSelectPrice?: (price: number) => void;
}) {
// Hooks must run unconditionally (Rules of Hooks) even when setup is missing.
const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false);
const [takeShares, setTakeShares] = useState(0);
const [takeEntry, setTakeEntry] = useState(0);
const [takeTarget, setTakeTarget] = useState(0);
const [internalSel, setInternalSel] = useState<number | null>(null);
useEffect(() => {
if (!setup) return;
const next = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
setTakeShares(next?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price);
setTakeTarget(setup.target);
}, [setup, currentPrice, risk.accountSize, risk.riskPct]);
useEffect(() => {
if (!taking) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') setTaking(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [taking]);
if (!setup) {
return (
<div className="rounded-xl border border-white/[0.07] p-4 text-xs text-gray-500">
@@ -222,16 +247,9 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
const exitPlan = deriveExitPlan(setup, exitPolicy);
const honorsTarget = exitPlan?.honorsTarget ?? false;
const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false);
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price);
const [takeTarget, setTakeTarget] = useState<number>(setup.target);
// Target choice from the ladder drives the rail, the chips, and the take
// flow — the scanner's primary is just the default. Controlled by the page
// when provided (so the candlestick overlay follows), else local.
const [internalSel, setInternalSel] = useState<number | null>(null);
const selPrice = selectedPrice !== undefined ? selectedPrice : internalSel;
const selectTargetPrice = (p: number) => {
if (onSelectPrice) onSelectPrice(p);
@@ -242,16 +260,6 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
const activeRR = selected?.rr_ratio ?? setup.rr_ratio;
const activeProb = selected?.probability ?? prob;
// Close the take dialog on Escape.
useEffect(() => {
if (!taking) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') setTaking(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [taking]);
const confirmTake = () => {
createTrade.mutate(
{
@@ -1,32 +0,0 @@
import { ReferenceLine } from 'recharts';
import type { SRLevel } from '../../lib/types';
import { formatPrice } from '../../lib/format';
interface SROverlayProps {
levels: SRLevel[];
}
export function SROverlay({ levels }: SROverlayProps) {
return (
<>
{levels.map((level) => {
const isSupport = level.type === 'support';
return (
<ReferenceLine
key={level.id}
y={level.price_level}
stroke={isSupport ? '#22c55e' : '#ef4444'}
strokeDasharray="6 3"
strokeWidth={1.5}
label={{
value: formatPrice(level.price_level),
position: 'right',
fill: isSupport ? '#22c55e' : '#ef4444',
fontSize: 11,
}}
/>
);
})}
</>
);
}
+3 -3
View File
@@ -118,9 +118,9 @@ export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): s
/**
* Symbol of the current single 'top pick' — the #1 row the dashboard highlights:
* the highest residual 12-1 momentum percentile among qualified setups. Returns
* null when there are no actionable setups. Keep in step with the Top Setups
* ranking in DashboardPage.
* highest production strategy_rank (80/20 mom/vol) among qualified setups,
* falling back to residual momentum percentile. Returns null when there are no
* actionable setups. Keep in step with the Top Setups ranking in DashboardPage.
*/
export function topPickSymbol(
trades: TradeSetup[] | undefined,